From 61e71a4b0a4bedf665ae5d60611ce20add836b21 Mon Sep 17 00:00:00 2001 From: John Austin Date: Sat, 13 Jun 2026 15:39:24 -0700 Subject: [PATCH 1/5] Converts error diagnostics to a data type rather than logging. --- .../Assets/Tests/DiagnosticCatalogTests.cs | 41 ++++ .../Tests/DiagnosticCatalogTests.cs.meta | 2 + .../Editor/Lattice/LatticeGraphToolbar.cs | 1 + .../Editor/Lattice/LatticeNodeView.cs | 4 + com.pontoco.lattice/Editor/Tools/MenuItems.cs | 3 + com.pontoco.lattice/Runtime/IR/Diagnostics.cs | 190 ++++++++++++++++++ .../Runtime/IR/Diagnostics.cs.meta | 2 + com.pontoco.lattice/Runtime/IR/GlobalGraph.cs | 3 + .../Runtime/IR/GraphCompilation.cs | 132 +++++++----- .../Runtime/IR/GraphCompiler.cs | 59 ++++-- .../Runtime/IR/ILGeneration.cs | 35 ++-- .../Runtime/IR/Nodes/IRNode.cs | 9 +- .../Runtime/Nodes/LatticeNode.cs | 7 +- 13 files changed, 390 insertions(+), 98 deletions(-) create mode 100644 LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs create mode 100644 LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs.meta create mode 100644 com.pontoco.lattice/Runtime/IR/Diagnostics.cs create mode 100644 com.pontoco.lattice/Runtime/IR/Diagnostics.cs.meta diff --git a/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs b/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs new file mode 100644 index 0000000..b1fe251 --- /dev/null +++ b/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Reflection; +using Lattice.IR; +using NUnit.Framework; + +namespace Tests.Runtime +{ + // Enforces the D5 rule from the type-system design (designs/type_system_refactor.html, section 4.3): + // artist-facing diagnostics never use compiler vocabulary. The catalog is the single source of those + // strings, so we lint it directly -- a const that smuggles in "infer", "qualifier", etc. fails the build. + // The catalog doesn't know this test exists; adding a new message template is automatically covered. + public class DiagnosticCatalogTests + { + [Test] + public void CatalogUsesNoCompilerJargon() + { + List violations = new(); + + foreach (FieldInfo field in typeof(DiagnosticMessages).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.FieldType != typeof(string) || !(field.IsLiteral || field.IsInitOnly)) + { + continue; // Only the message templates; BannedWords itself is a string[], naturally skipped. + } + + string template = ((string)field.GetValue(null)).ToLowerInvariant(); + foreach (string banned in DiagnosticMessages.BannedWords) + { + if (template.Contains(banned.ToLowerInvariant())) + { + violations.Add($"[{field.Name}] contains banned word [{banned}]"); + } + } + } + + Assert.IsEmpty(violations, + "Diagnostic messages must read for artists, not compiler authors. Offenders:\n" + + string.Join("\n", violations)); + } + } +} diff --git a/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs.meta b/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs.meta new file mode 100644 index 0000000..e2066f8 --- /dev/null +++ b/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 79c7d3d659cb4f10b20e0eeab8ea38cf diff --git a/com.pontoco.lattice/Editor/Lattice/LatticeGraphToolbar.cs b/com.pontoco.lattice/Editor/Lattice/LatticeGraphToolbar.cs index 4c7e760..c80f0b7 100644 --- a/com.pontoco.lattice/Editor/Lattice/LatticeGraphToolbar.cs +++ b/com.pontoco.lattice/Editor/Lattice/LatticeGraphToolbar.cs @@ -67,6 +67,7 @@ private void AddButtons() { // Render graphviz just for this graph. GraphCompilation compilation = GraphCompiler.CompileStandalone(graphView.Graph); + DiagnosticConsole.Log(compilation); string dotString = GraphCompilation.ToDot(compilation); GraphUtils.OpenGraphviz(dotString); }, left: false); diff --git a/com.pontoco.lattice/Editor/Lattice/LatticeNodeView.cs b/com.pontoco.lattice/Editor/Lattice/LatticeNodeView.cs index 4ca1876..cbecaf0 100644 --- a/com.pontoco.lattice/Editor/Lattice/LatticeNodeView.cs +++ b/com.pontoco.lattice/Editor/Lattice/LatticeNodeView.cs @@ -434,6 +434,10 @@ public override void BuildContextualMenu(ContextualMenuPopulateEvent evt) _ => { Target.Last.DoNotLogErrors = !Target.Last.DoNotLogErrors; + + // The flag is baked into the compilation, so it only takes effect through a recompile. + EditorUtility.SetDirty(Owner.Graph); + GlobalGraph.LanguageServer.RecompileIfNeeded(force: true); }); evt.menu.AppendAction("Show Selected In GraphViz", _ => diff --git a/com.pontoco.lattice/Editor/Tools/MenuItems.cs b/com.pontoco.lattice/Editor/Tools/MenuItems.cs index 92ae44b..f1a8c80 100644 --- a/com.pontoco.lattice/Editor/Tools/MenuItems.cs +++ b/com.pontoco.lattice/Editor/Tools/MenuItems.cs @@ -38,6 +38,7 @@ public static void RecompileAll() { Debug = false }); + DiagnosticConsole.Log(compilation); nodes = compilation.Graph.Nodes.Count; } catch (Exception) @@ -68,6 +69,7 @@ public static void ProjectWideGraphViz() { Debug = false } ); + DiagnosticConsole.Log(compilation); string dotString = GraphCompilation.ToDot(compilation); var p = FileUtil.GetUniqueTempPathInProject() + ".dot"; @@ -89,6 +91,7 @@ public static void SaveAssembly() Debug = true, AssemblyAccess = AssemblyBuilderAccess.RunAndSave }); + DiagnosticConsole.Log(graph); // Generate work units first. graph.GenerateWorkUnits(); diff --git a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs new file mode 100644 index 0000000..56b704b --- /dev/null +++ b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs @@ -0,0 +1,190 @@ +using System; +using JetBrains.Annotations; +using Lattice.Base; +using UnityEngine; + +namespace Lattice.IR +{ + // The compiler reports problems as Diagnostic values accumulated on GraphCompilation.Diagnostics, + // and never writes to the console itself. Callers choose policy: GlobalGraph and the editor tools + // replay the stream to the console (DiagnosticConsole.Log), the editor renders badges from it, and + // tests assert on its contents. NodeCompilationError keeps its role as the value that *propagates* + // an error through node metadata; a Diagnostic is how that error *surfaces*. + + /// A single problem found while compiling a set of graphs. Immutable. + public readonly struct Diagnostic + { + /// Stable identity for this class of failure. Tools and tests key on this, never on message text. + public readonly DiagnosticCode Code; + + public readonly DiagnosticSeverity Severity; + + /// The graph asset the problem occurred in, if known. Doubles as the console context object. + [CanBeNull] + public readonly LatticeGraph Graph; + + /// FileId of the authored node the problem is attributed to, or null for graph-level problems. + [CanBeNull] + public readonly string NodeFileId; + + /// The port the problem is attributed to, or null for node-level problems. + [CanBeNull] + public readonly string PortId; + + /// Pre-formatted text. Null for problems carried entirely by . + [CanBeNull] + public readonly string Message; + + /// The original exception, when one exists. The console replay shows it with its stack trace. + [CanBeNull] + public readonly Exception Exception; + + /// + /// True when the authored node opted out of console logging (DoNotLogErrors). The diagnostic is still + /// recorded; only the console replay skips it. ICE diagnostics are never muted. + /// + public readonly bool Muted; + + public Diagnostic(DiagnosticCode code, DiagnosticSeverity severity, [CanBeNull] string message, + LatticeGraph graph = null, string nodeFileId = null, string portId = null, + Exception exception = null, bool muted = false) + { + Code = code; + Severity = severity; + Message = message; + Graph = graph; + NodeFileId = nodeFileId; + PortId = portId; + Exception = exception; + Muted = muted; + } + + /// Digs the port identifier out of an exception chain, for errors that know which port they are about. + [CanBeNull] + public static string PortIdFrom([CanBeNull] Exception e) + { + while (e != null) + { + if (e is LatticePortException portException) + { + return portException.PortIdentifier; + } + + e = e.InnerException; + } + + return null; + } + } + + /// + /// Stable identities for every class of problem the compiler reports. Codes prefixed Ice are internal + /// compiler errors: impossible unless we have a bug, and never muted. + /// + public enum DiagnosticCode + { + // -- User/content errors: something wrong in the authored graphs. -- + NodeError, // A static error on a node, when no more specific code applies. + NodeThrewWhileBuilding, // CompileToIR threw; the node was replaced with a malformed placeholder. + MixedEntityScopes, // A node combined values scoped to two different entities. + PhasesNotOrdered, // Inputs come from systems with no UpdateAfter/UpdateBefore ordering between them. + GraphNameNotUnique, // Two top-level graphs share a name. + + // -- ICEs. -- + IceGraphAddedTwice, + IceDuplicateNodeId, + IcePreviousBackRefMissing, + IceUnmappedPort, // An edge referenced a port with no IR mapping. + IcePortMapping, // A node's port maps are missing or incomplete. + IceRefEdgesUnsupported, + IceCompileNodeFailed, // Analysis threw while computing a node's metadata. + IceUnresolvedGeneric, // A generic type survived to type check. + IcePortUnconnected, // A required port has no inputs after construction. + IceInvalidOutputType, // A node's output type can't be executed (by-ref/managed). + IceTypeMismatch, // A port was assigned an incompatible input type. + IceMutatorReplaceFailed, + IceGeneratedFunctionDuplicated, + } + + public enum DiagnosticSeverity + { + Info, + Warning, + Error + } + + /// + /// The artist-facing message catalog. Every user-facing diagnostic text lives here, so there is one place + /// to read and police the voice. + /// + public static class DiagnosticMessages + { + // Messages are written for artists: they name nodes, ports, systems, and types the way the editor shows + // them, say what went wrong, and say what to do next. Compiler vocabulary is banned -- an artist has + // never heard of inference or qualifiers, and a catalog entry using one of these words is a bug. + // (See designs/type_system_refactor.html, section 4.3.) ICE messages are exempt: they describe compiler + // bugs, for us, and keep their ICE: prefix. + public static readonly string[] BannedWords = + { + "infer", "unify", "type variable", "constraint", "qualifier", "signature" + }; + + public const string MixedEntityScopesTemplate = + "This node mixes values from two different entities ({0} and {1}). A node can only combine values from one entity."; + + public const string PhasesNotOrderedTemplate = + "This node reads from {0} and {1}, but Lattice can't tell which runs first. Add an ordering between those systems.\n" + + "(Missing UpdateAfter/UpdateBefore between [{0}] and [{1}].)"; + + public static string MixedEntityScopes(Qualifier first, Qualifier second) + { + return string.Format(MixedEntityScopesTemplate, first.ShortName(), second.ShortName()); + } + + public static string PhasesNotOrdered(Type firstGroup, Type secondGroup) + { + return string.Format(PhasesNotOrderedTemplate, firstGroup.Name, secondGroup.Name); + } + } + + /// + /// Replays a compilation's diagnostics to the Unity console. Callers that want console output invoke this + /// once after compiling; the compiler itself never logs. + /// + public static class DiagnosticConsole + { + public static void Log(GraphCompilation compilation) + { + foreach (Diagnostic d in compilation.Diagnostics) + { + if (d.Muted) + { + continue; + } + + if (d.Message != null) + { + switch (d.Severity) + { + case DiagnosticSeverity.Error: + Debug.LogError(d.Message, d.Graph); + break; + case DiagnosticSeverity.Warning: + Debug.LogWarning(d.Message, d.Graph); + break; + case DiagnosticSeverity.Info: + Debug.Log(d.Message, d.Graph); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + if (d.Exception != null) + { + Debug.LogException(d.Exception, d.Graph); + } + } + } + } +} diff --git a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs.meta b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs.meta new file mode 100644 index 0000000..19504ca --- /dev/null +++ b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 31753c727cea64e0e8452f2623f39dfc \ No newline at end of file diff --git a/com.pontoco.lattice/Runtime/IR/GlobalGraph.cs b/com.pontoco.lattice/Runtime/IR/GlobalGraph.cs index 75471c0..2a3881c 100644 --- a/com.pontoco.lattice/Runtime/IR/GlobalGraph.cs +++ b/com.pontoco.lattice/Runtime/IR/GlobalGraph.cs @@ -102,6 +102,9 @@ public GraphCompilation RecompileIfNeeded(bool force = false) try { Graph = GraphCompiler.CompileFixedSet(toplevelGraphs, settings); + + // The compiler only accumulates diagnostics; the service is where console policy lives. + DiagnosticConsole.Log(Graph); } catch (Exception e) { diff --git a/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs b/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs index 4d81289..58ae91c 100644 --- a/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs +++ b/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs @@ -61,6 +61,12 @@ public class GraphCompilation /// If true, the graph is invalid enough that we shouldn't try to execute it. public bool CannotBeExecuted; + /// + /// Every problem found while building and analyzing the graphs, in the order found. The compiler only + /// accumulates these; callers decide policy. + /// + public readonly List Diagnostics = new(); + /// Set to true after the compiler does its high level analysis like type checking. public bool AnalysisFinished; @@ -145,7 +151,8 @@ public void AddToplevelGraph(LatticeGraph graphAsset) { if (TopLevelGraphs.Contains(graphAsset)) { - Debug.LogError($"(Lattice) ICE: Graph [{graphAsset.name}] was added to compilation twice."); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IceGraphAddedTwice, DiagnosticSeverity.Error, + $"(Lattice) ICE: Graph [{graphAsset.name}] was added to compilation twice.", graphAsset)); return; } @@ -197,12 +204,9 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) } catch (Exception e) { - if (!node.DoNotLogErrors) - { - Debug.LogError( - $"Syntax Error at [{node}]: {e.Message}\n\n StackTrace:\n" + e.StackTrace + "\n\n", - graphAsset); - } + Diagnostics.Add(new Diagnostic(DiagnosticCode.NodeThrewWhileBuilding, DiagnosticSeverity.Error, + $"Syntax Error at [{node}]: {e.Message}", graphAsset, node.FileId, Diagnostic.PortIdFrom(e), + e, node.DoNotLogErrors)); graph.ReplaceNodeWithMalformed(node.ToRootPath(), e); } @@ -215,7 +219,8 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) } catch (Exception e) { - Debug.LogException(e, graphAsset); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, null, + graphAsset, node.FileId, exception: e)); } } @@ -229,9 +234,9 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) if (!graph.GetOutputMap(((LatticeNode)edge.fromNode).ToRootPath()) .TryGetValue(edge.fromPortIdentifier, out IRNodeRef inputNode)) { - Debug.LogError( + Diagnostics.Add(new Diagnostic(DiagnosticCode.IceUnmappedPort, DiagnosticSeverity.Error, $"ICE: No IRNode was mapped for output port [{edge.fromPortIdentifier}] on node [{edge.fromNode}].", - graphAsset); + graphAsset, edge.fromNode.FileId, edge.fromPortIdentifier)); continue; } @@ -239,9 +244,9 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) if (!graph.GetInputMap(((LatticeNode)edge.toNode).ToRootPath()).TryGetValue(edge.toPortIdentifier, out (IRNode node, string irPort)? portMap)) { - Debug.LogError( + Diagnostics.Add(new Diagnostic(DiagnosticCode.IceUnmappedPort, DiagnosticSeverity.Error, $"ICE: No IRNode was mapped for input port [{edge.toPortIdentifier}] on node [{edge.toNode}].", - graphAsset); + graphAsset, edge.toNode.FileId, edge.toPortIdentifier)); continue; } @@ -257,7 +262,8 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) else { // Stitch this node into the mutation pipeline. - Debug.LogError($"ICE: Ref edges are not currently supported. Edge: [{edge}]"); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IceRefEdgesUnsupported, DiagnosticSeverity.Error, + $"ICE: Ref edges are not currently supported. Edge: [{edge}]", graphAsset)); } } @@ -268,7 +274,8 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) { if (ids.TryGetValue(node.Id, out IRNode n)) { - Debug.LogError($"ICE: Two IRNodes have the same Id. Node [{node}] and [{n}]. Id:[{node.Id}]"); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IceDuplicateNodeId, DiagnosticSeverity.Error, + $"ICE: Two IRNodes have the same Id. Node [{node}] and [{n}]. Id:[{node.Id}]", graphAsset)); } ids.Add(node.Id, node); } @@ -277,8 +284,8 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) { if (n.Node.Ports.Count != 1) { - Debug.LogError( - $"ICE: Input node in graph does not have 1 port. [{n.Node}][{n.Node.Ports.Count}]"); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + $"ICE: Input node in graph does not have 1 port. [{n.Node}][{n.Node.Ports.Count}]", graphAsset)); } } } @@ -328,10 +335,14 @@ public Metadata CompileNode(IRNode node) } catch (Exception e) { - Debug.LogWarning($"ICE: Node failed to compile. [{node}]", Graph.GetOwner(node)?.Last.Graph); - Debug.LogException(e, Graph.GetOwner(node)?.Last.Graph); + CodePath? owner = Graph.GetOwner(node); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IceCompileNodeFailed, DiagnosticSeverity.Warning, + $"ICE: Node failed to compile. [{node}]", owner?.Last.Graph, owner?.Last.FileId, exception: e)); metadata = new Metadata(null, typeof(ITypeUnknown), LatticePhases.GetLatticeDefaultPhase(), - new NodeCompilationError(node, "ICE: Node failed to compile.")); + new NodeCompilationError(node, "ICE: Node failed to compile.") + { + Code = DiagnosticCode.IceCompileNodeFailed + }); } MetadataDb[node] = metadata; @@ -397,7 +408,10 @@ public Metadata CalculateMetadata(IRNode node, IEnumerable<(string portId, Metad if (node is MalformedIRNode mnode) { return new Metadata(null, typeof(MalformedException), LatticePhases.GetLatticeDefaultPhase(), - new NodeCompilationError(node, mnode.Reason)); + new NodeCompilationError(node, mnode.Reason) + { + Code = DiagnosticCode.NodeThrewWhileBuilding + }); } if (node is EntityIRNode enode) @@ -445,7 +459,10 @@ public Metadata CalculateMetadata(IRNode node, IEnumerable<(string portId, Metad ) { error = new NodeCompilationError(node, - $"Inputs with different entity qualifiers. Input qualifiers: [{inputMetadata.Qualifier}][{qualifier}]"); + DiagnosticMessages.MixedEntityScopes(inputMetadata.Qualifier.Value, qualifier.Value)) + { + Code = DiagnosticCode.MixedEntityScopes + }; qualifier = null; // Null out the qualifier, because we'll just be returning an exception. } else @@ -523,14 +540,16 @@ private void TryMergePhase(IRNode node, ref Type existingPhase, Type inputPhase, if (!orderedAfter.HasValue) { error = new NodeCompilationError(node, - "Cannot compile: Lattice Phases for input nodes were not fully ordered (UpdateAfter/UpdateBefore). Info:\n" + - $"Phases not fully ordered: [{inputPhase}] and [{existingPhase}]\n" + - $"Because: missing system UpdateAfter/UpdateBefore between systems: [{inputGroup}] and [{existingGroup}]"); - if (!node.DoNotLogErrors) + DiagnosticMessages.PhasesNotOrdered(inputGroup, existingGroup)) { - Debug.LogException(error); - CannotBeExecuted = true; - } + Code = DiagnosticCode.PhasesNotOrdered + }; + + // The record and the execution veto always happen; DoNotLogErrors only silences the console. + CodePath? owner = Graph.GetOwner(node); + Diagnostics.Add(new Diagnostic(DiagnosticCode.PhasesNotOrdered, DiagnosticSeverity.Error, null, + owner?.Last.Graph, owner?.Last.FileId, exception: error, muted: node.DoNotLogErrors)); + CannotBeExecuted = true; return; } @@ -641,9 +660,10 @@ public void DeduplicatedCodeGen(string typeName, Func output) /// Makes sure nodes have an output port mapping and some other default mappings. For single output port nodes, /// just maps them to the added node. /// - private static void SetupDefaults(IRGraph compilation, CodePath span) + private void SetupDefaults(IRGraph graph, CodePath span) { // Add primary node marker if there's only one ir node. - if (compilation.GetPrimaryNode(span) == null) + if (graph.GetPrimaryNode(span) == null) { - var nodes = compilation.GetNodesUnderPath(span); + var nodes = graph.GetNodesUnderPath(span); if (nodes.Count > 1) { - Debug.LogError($"No primary node specified for node [{span}]."); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + $"No primary node specified for node [{span}].", span.Root, span.Last.FileId)); } else if (nodes.Count == 1) { - compilation.SetPrimaryNode(span, nodes[0]); + graph.SetPrimaryNode(span, nodes[0]); } else { - Debug.LogError($"No nodes created for node [{span}]."); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + $"No nodes created for node [{span}].", span.Root, span.Last.FileId)); } } // (Doesn't overwrite manually specified output mapping.) // If the user didn't specify an output port map, attempt to generate it. - var outputMap = compilation.GetOutputMap(span); + var outputMap = graph.GetOutputMap(span); if (!outputMap.Any()) { // Add default mapping for single output port nodes. if (span.Last.GetValueOutputs().Count() == 1) { - var nodes = compilation.GetNodesUnderPath(span); + var nodes = graph.GetNodesUnderPath(span); if (nodes.Count != 1) { - Debug.LogError( - $"Couldn't add default output port mapping to node [{span}]. Node count: {nodes.Count}."); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + $"Couldn't add default output port mapping to node [{span}]. Node count: {nodes.Count}.", + span.Root, span.Last.FileId)); return; } - compilation.MapOutputPort(span, span.Last.OutputPorts[0].portData.identifier, nodes[0]); + graph.MapOutputPort(span, span.Last.OutputPorts[0].portData.identifier, nodes[0]); } else if (span.Last.GetValueOutputs().Count() > 1) { - Debug.LogError($"No output port mapping found for compiled node [{span}]."); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + $"No output port mapping found for compiled node [{span}].", span.Root, span.Last.FileId)); } } else @@ -899,13 +923,14 @@ private static void SetupDefaults(IRGraph compilation, CodePath span) { if (!outputMap.ContainsKey(output.portData.identifier)) { - Debug.LogError( - $"Incomplete OutputPortMap for compiled node [{span}]. Missing port [{output.portData.identifier}]"); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + $"Incomplete OutputPortMap for compiled node [{span}]. Missing port [{output.portData.identifier}]", + span.Root, span.Last.FileId, output.portData.identifier)); } } } - var inputMap = compilation.GetInputMap(span); + var inputMap = graph.GetInputMap(span); if (inputMap.Any()) { // If the node specified an input port mapping, verify it. @@ -913,15 +938,16 @@ private static void SetupDefaults(IRGraph compilation, CodePath span) { if (!inputMap.ContainsKey(input.portData.identifier)) { - Debug.LogError( - $"Incomplete InputPortMap for compiled node [{span}]. Missing port [{input.portData.identifier}]"); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + $"Incomplete InputPortMap for compiled node [{span}]. Missing port [{input.portData.identifier}]", + span.Root, span.Last.FileId, input.portData.identifier)); } } } else { // If no input port map is provided, and there's only a single node, map inputs there. - var nodes = compilation.GetNodesUnderPath(span); + var nodes = graph.GetNodesUnderPath(span); if (nodes.Count == 1) { if (nodes[0] is MalformedIRNode) @@ -931,17 +957,18 @@ private static void SetupDefaults(IRGraph compilation, CodePath span) // the type information for the editor. foreach (NodePort port in span.Last.GetLogicalInputs()) { - compilation.MapInputPort(span, port.portData.identifier, null); + graph.MapInputPort(span, port.portData.identifier, null); } } else { - compilation.MapInputPorts(span, nodes[0]); + graph.MapInputPorts(span, nodes[0]); } } else if (span.Last.GetLogicalInputs().Any()) { - Debug.LogError($"No input port mapping found for compiled node [{span}]."); + Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + $"No input port mapping found for compiled node [{span}].", span.Root, span.Last.FileId)); } } } @@ -1084,6 +1111,9 @@ public class NodeCompilationError : Exception { public IRNode Node; + /// Which class of failure this is, so the diagnostic that surfaces it carries the right code. + public DiagnosticCode Code = DiagnosticCode.NodeError; + public NodeCompilationError(IRNode node, string message, Exception e) : base(message, e) { Node = node; diff --git a/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs b/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs index 8c115ef..56e3ce7 100644 --- a/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs +++ b/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs @@ -121,7 +121,9 @@ internal static GraphCompilation CompileFixedSet(IEnumerable topLe { if (!names.Add(g.name)) { - Debug.LogError($"Lattice graph named [{g.name}] was defined twice. Graphs must have unique names."); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.GraphNameNotUnique, + DiagnosticSeverity.Error, + $"Lattice graph named [{g.name}] was defined twice. Graphs must have unique names.", g)); continue; } graphsWithUniqueNames.Add(g); @@ -170,7 +172,9 @@ internal static GraphCompilation CompileFixedSet(IEnumerable topLe { if (ids.TryGetValue(node.Id, out IRNode n)) { - Debug.LogError($"ICE: Two IRNodes have the same Id. Node [{node}] and [{n}]. Id:[{node.Id}]"); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceDuplicateNodeId, + DiagnosticSeverity.Error, + $"ICE: Two IRNodes have the same Id. Node [{node}] and [{n}]. Id:[{node.Id}]")); } ids.Add(node.Id, node); } @@ -229,7 +233,8 @@ internal static GraphCompilation CompileFixedSet(IEnumerable topLe { if (p.BackRef == null) { - Debug.LogError("ICE: PreviousNode with null BackRef."); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePreviousBackRefMissing, + DiagnosticSeverity.Error, "ICE: PreviousNode with null BackRef.")); continue; } compilation.Graph.ReferencedByPreviousNode.Add(p.BackRef.Node); @@ -248,21 +253,19 @@ internal static GraphCompilation CompileFixedSet(IEnumerable topLe } - // Emit compilation errors for nodes, but keep going (malformed nodes are valid runtime nodes) + // Surface propagated node errors as diagnostics, once each, at the node that caused them. + // Compilation keeps going -- malformed nodes are valid runtime nodes. private static void EmitCompilationErrors(GraphCompilation compilation) { foreach (var n in compilation.Graph.Nodes) { - if (n.DoNotLogErrors) - { - continue; - } var error = compilation.CompileNode(n).CompilationError; if (error != null && error.Node == n) { - Debug.LogError( - $"Syntax Error at [{error.Node}]: {error}\n\n", - compilation.Graph.GetOwner(error.Node)?.Last.Graph); + CodePath? owner = compilation.Graph.GetOwner(error.Node); + compilation.Diagnostics.Add(new Diagnostic(error.Code, DiagnosticSeverity.Error, + $"Syntax Error at [{error.Node}]: {error}\n\n", owner?.Last.Graph, owner?.Last.FileId, + Diagnostic.PortIdFrom(error), error, n.DoNotLogErrors)); } } } @@ -463,8 +466,11 @@ bool TypesAreCompatible(Type portType, Type inputType) { if (compilation.CompileNode(node).OutputType.IsGenericParameter) { - Debug.LogError( - $"ICE: Generic type was found in node [{node}]. All generics must resolve into concrete types after node compilation."); + CodePath? owner = compilation.Graph.GetOwner(node); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceUnresolvedGeneric, + DiagnosticSeverity.Error, + $"ICE: Generic type was found in node [{node}]. All generics must resolve into concrete types after node compilation.", + owner?.Last.Graph, owner?.Last.FileId)); compilation.CannotBeExecuted = true; } } @@ -476,23 +482,29 @@ bool TypesAreCompatible(Type portType, Type inputType) if (port.Inputs.Count == 0 && !port.IsOptional) { // FunctionIRNodes must be connected on all ports. - Debug.LogError($"ICE: Port [{id}] must be connected on node [{node}]. No inputs connected."); + CodePath? owner = compilation.Graph.GetOwner(node); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortUnconnected, + DiagnosticSeverity.Error, + $"ICE: Port [{id}] must be connected on node [{node}]. No inputs connected.", + owner?.Last.Graph, owner?.Last.FileId, id)); compilation.CannotBeExecuted = true; } var compileData = compilation.CompileNode(node); if (compileData.OutputType.IsByRef) { - Debug.LogError( - $"$ICE: Node has output of type 'ref'. [{node}] ByRef types are not allowed because pointers can't be stored across frames."); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceInvalidOutputType, + DiagnosticSeverity.Error, + $"$ICE: Node has output of type 'ref'. [{node}] ByRef types are not allowed because pointers can't be stored across frames.")); compilation.CannotBeExecuted = true; } if (compileData.OutputType.IsByRef) { // Managed types aren't supported yet, as codegen assumes all types can be boxed/nullable-wrapped. - Debug.LogError( - $"$ICE: Node has a managed output type. [{node}] Managed types are not allowed, yet."); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceInvalidOutputType, + DiagnosticSeverity.Error, + $"$ICE: Node has a managed output type. [{node}] Managed types are not allowed, yet.")); compilation.CannotBeExecuted = true; } @@ -501,8 +513,11 @@ bool TypesAreCompatible(Type portType, Type inputType) Type inputType = compilation.CompileNode(input).OutputType; if (!TypesAreCompatible(port.Type, inputType)) { - Debug.LogError( - $"ICE TypeError: Port [{id}][{port.Type}] cannot be assigned input of type [{inputType}]. Node: [{node}] Input: [{input}]"); + CodePath? owner = compilation.Graph.GetOwner(node); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceTypeMismatch, + DiagnosticSeverity.Error, + $"ICE TypeError: Port [{id}][{port.Type}] cannot be assigned input of type [{inputType}]. Node: [{node}] Input: [{input}]", + owner?.Last.Graph, owner?.Last.FileId, id)); compilation.CannotBeExecuted = true; } } @@ -751,8 +766,8 @@ public static void Pass_ConvertPreviousNodesToPointers(GraphCompilation compilat } catch (Exception e) { - Debug.LogError("Replacing MutatorIRNodes failed."); - Debug.LogException(e); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceMutatorReplaceFailed, + DiagnosticSeverity.Error, "Replacing MutatorIRNodes failed.", exception: e)); } } diff --git a/com.pontoco.lattice/Runtime/IR/ILGeneration.cs b/com.pontoco.lattice/Runtime/IR/ILGeneration.cs index a7113ba..ff52d9f 100644 --- a/com.pontoco.lattice/Runtime/IR/ILGeneration.cs +++ b/com.pontoco.lattice/Runtime/IR/ILGeneration.cs @@ -19,7 +19,6 @@ using Label = GrEmit.GroboIL.Label; // Performance improvements: -// - Move DoNotLogErrors check to compile-time. // - Can we emit more efficient code for the big function calls at the end of a node's execution? namespace Lattice.IR @@ -799,12 +798,16 @@ void EmitReadNodeValue(IRNode node, [CanBeNull] Local entityIdx, Local outNodeVa emit.Ldloc(exceptionValue); // Load the exceptionValue (or null) as arg5. emit.Call(typeof(ILGeneration).GetMethod(nameof(DebugNodeExecution)).MakeGenericMethod(executionOutputType)); - // Check the node's output for errors and log them. - emit.Ldarg(argumentExecutionIdx); // Load the IRExecution as arg1. - emit.Ldc_I4(nodeIdx); // Load the node index as arg 2. - emit.Ldloc(latticeEntity); // Load the entity (or Entity.Null) as arg3. - emit.Ldloc(exceptionValue); // Load the exceptionValue (or null) as arg4. - emit.Call(typeof(ILGeneration).GetMethod(nameof(LogNodeErrors))); + // Check the node's output for errors and log them. Nodes that opted out of error logging + // simply don't get the call emitted; the choice is baked in at compile time. + if (!node.DoNotLogErrors) + { + emit.Ldarg(argumentExecutionIdx); // Load the IRExecution as arg1. + emit.Ldc_I4(nodeIdx); // Load the node index as arg 2. + emit.Ldloc(latticeEntity); // Load the entity (or Entity.Null) as arg3. + emit.Ldloc(exceptionValue); // Load the exceptionValue (or null) as arg4. + emit.Call(typeof(ILGeneration).GetMethod(nameof(LogNodeErrors))); + } // If the node is needed in the next frame, write it to the entity's state. if (compilation.Graph.ReferencedByPreviousNode.Contains(node)) @@ -1148,20 +1151,16 @@ public static void DebugNodeExecution(IRExecution execution, int nodeIdx, Ent // Debug.Log($"Executed [{node}] [{exceptionValue ?? outputValue}]"); } - // Logs a node's errors if it returned an exception. + // Logs a node's errors if it returned an exception. Muted nodes (DoNotLogErrors) never emit a call to this. public static void LogNodeErrors(IRExecution execution, int nodeIdx, Entity qualifier, Exception exceptionValue) { - IRNode node = execution.Compilation.Graph.Nodes[nodeIdx]; - - if (!node.DoNotLogErrors) + // Only log an error if the exception originated from this node. If the exception is InputNodeException + // it originated from an earlier node in this node's input graph. + if (exceptionValue != null && exceptionValue is not InputNodeException) { - // Only log an error if the exception originated from this node. If the exception is InputNodeException - // it originated from an earlier node in this node's input graph. - if (exceptionValue != null && exceptionValue is not InputNodeException) - { - Debug.LogError($"Lattice node error: [{node}:{qualifier}]", execution.Compilation.Graph.GetOwner(node)?.Root); - Debug.LogException(exceptionValue, execution.Compilation.Graph.GetOwner(node)?.Root); - } + IRNode node = execution.Compilation.Graph.Nodes[nodeIdx]; + Debug.LogError($"Lattice node error: [{node}:{qualifier}]", execution.Compilation.Graph.GetOwner(node)?.Root); + Debug.LogException(exceptionValue, execution.Compilation.Graph.GetOwner(node)?.Root); } } diff --git a/com.pontoco.lattice/Runtime/IR/Nodes/IRNode.cs b/com.pontoco.lattice/Runtime/IR/Nodes/IRNode.cs index 3acde49..29511d9 100644 --- a/com.pontoco.lattice/Runtime/IR/Nodes/IRNode.cs +++ b/com.pontoco.lattice/Runtime/IR/Nodes/IRNode.cs @@ -125,10 +125,11 @@ public abstract class IRNode public string DebugName; /// - /// If set to true, the compilation and execution of graphs will not log errors to the Unity console for syntax - /// and runtime errors. Everything else will act like normal, just no logging. ICE's will still log. This is useful for - /// unit tests which would fail on console messages, but where we still want to test to make sure errors propagate - /// correctly. + /// If set to true, errors on this node stay out of the Unity console. Compile errors are still recorded as + /// muted s, and the generated code simply omits the runtime error logging call -- + /// the flag is baked in at IL emission, so flipping it after compilation does nothing. Everything else acts + /// like normal: errors still propagate, and ICEs still log. Useful for graphs with intentional errors, like + /// tests. /// internal bool DoNotLogErrors = false; diff --git a/com.pontoco.lattice/Runtime/Nodes/LatticeNode.cs b/com.pontoco.lattice/Runtime/Nodes/LatticeNode.cs index 212f10c..a42234b 100644 --- a/com.pontoco.lattice/Runtime/Nodes/LatticeNode.cs +++ b/com.pontoco.lattice/Runtime/Nodes/LatticeNode.cs @@ -43,9 +43,10 @@ public abstract class LatticeNode : BaseNode public List ActionPorts = new(); /// - /// If set to true, the compilation and execution of graphs will not log errors to the Unity console for syntax and runtime errors. - /// Everything else will act like normal, just no logging. ICE's will still log. This is useful for unit tests which would fail on console - /// messages, but where we still want to test to make sure errors propagate correctly. + /// If set to true, errors on this node stay out of the Unity console: compile errors become muted + /// s and the compiled graph omits runtime error logging for it. + /// Errors still propagate like normal, and ICEs still log. Toggled per node from the editor's context + /// menu; useful for graphs with intentional errors, like tests. /// [SerializeField] [HideInInspector] From 346b7473f3c496b9de706a8614ab430f98137824 Mon Sep 17 00:00:00 2001 From: John Austin Date: Sun, 14 Jun 2026 20:33:36 -0700 Subject: [PATCH 2/5] Changes after a review. --- .../Editor/Lattice/LatticeGraphToolbar.cs | 1 - com.pontoco.lattice/Editor/Tools/MenuItems.cs | 3 - com.pontoco.lattice/Runtime/IR/Diagnostics.cs | 100 +++--------------- com.pontoco.lattice/Runtime/IR/GlobalGraph.cs | 3 - .../Runtime/IR/GraphCompilation.cs | 92 +++++++--------- .../Runtime/IR/GraphCompiler.cs | 76 ++++++++----- 6 files changed, 102 insertions(+), 173 deletions(-) diff --git a/com.pontoco.lattice/Editor/Lattice/LatticeGraphToolbar.cs b/com.pontoco.lattice/Editor/Lattice/LatticeGraphToolbar.cs index c80f0b7..4c7e760 100644 --- a/com.pontoco.lattice/Editor/Lattice/LatticeGraphToolbar.cs +++ b/com.pontoco.lattice/Editor/Lattice/LatticeGraphToolbar.cs @@ -67,7 +67,6 @@ private void AddButtons() { // Render graphviz just for this graph. GraphCompilation compilation = GraphCompiler.CompileStandalone(graphView.Graph); - DiagnosticConsole.Log(compilation); string dotString = GraphCompilation.ToDot(compilation); GraphUtils.OpenGraphviz(dotString); }, left: false); diff --git a/com.pontoco.lattice/Editor/Tools/MenuItems.cs b/com.pontoco.lattice/Editor/Tools/MenuItems.cs index f1a8c80..92ae44b 100644 --- a/com.pontoco.lattice/Editor/Tools/MenuItems.cs +++ b/com.pontoco.lattice/Editor/Tools/MenuItems.cs @@ -38,7 +38,6 @@ public static void RecompileAll() { Debug = false }); - DiagnosticConsole.Log(compilation); nodes = compilation.Graph.Nodes.Count; } catch (Exception) @@ -69,7 +68,6 @@ public static void ProjectWideGraphViz() { Debug = false } ); - DiagnosticConsole.Log(compilation); string dotString = GraphCompilation.ToDot(compilation); var p = FileUtil.GetUniqueTempPathInProject() + ".dot"; @@ -91,7 +89,6 @@ public static void SaveAssembly() Debug = true, AssemblyAccess = AssemblyBuilderAccess.RunAndSave }); - DiagnosticConsole.Log(graph); // Generate work units first. graph.GenerateWorkUnits(); diff --git a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs index 56b704b..a1d7c9f 100644 --- a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs +++ b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs @@ -1,39 +1,27 @@ using System; using JetBrains.Annotations; -using Lattice.Base; using UnityEngine; namespace Lattice.IR { // The compiler reports problems as Diagnostic values accumulated on GraphCompilation.Diagnostics, - // and never writes to the console itself. Callers choose policy: GlobalGraph and the editor tools - // replay the stream to the console (DiagnosticConsole.Log), the editor renders badges from it, and - // tests assert on its contents. NodeCompilationError keeps its role as the value that *propagates* - // an error through node metadata; a Diagnostic is how that error *surfaces*. + // and never writes to the console itself. Callers choose policy: CompileFixedSet replays the stream + // to the console at the end of a compile (unless suppressed), and tests can read the list directly. + // NodeCompilationError keeps its role as the value that *propagates* an error through node metadata; + // a Diagnostic is how that error *surfaces*. /// A single problem found while compiling a set of graphs. Immutable. public readonly struct Diagnostic { - /// Stable identity for this class of failure. Tools and tests key on this, never on message text. - public readonly DiagnosticCode Code; - public readonly DiagnosticSeverity Severity; - /// The graph asset the problem occurred in, if known. Doubles as the console context object. - [CanBeNull] - public readonly LatticeGraph Graph; - - /// FileId of the authored node the problem is attributed to, or null for graph-level problems. + /// Artist-facing text. Null for problems carried entirely by . [CanBeNull] - public readonly string NodeFileId; - - /// The port the problem is attributed to, or null for node-level problems. - [CanBeNull] - public readonly string PortId; + public readonly string Message; - /// Pre-formatted text. Null for problems carried entirely by . + /// The graph the problem occurred in, if known. Used as the clickable console context object. [CanBeNull] - public readonly string Message; + public readonly LatticeGraph Graph; /// The original exception, when one exists. The console replay shows it with its stack trace. [CanBeNull] @@ -41,69 +29,19 @@ public readonly struct Diagnostic /// /// True when the authored node opted out of console logging (DoNotLogErrors). The diagnostic is still - /// recorded; only the console replay skips it. ICE diagnostics are never muted. + /// recorded; only the console replay skips it. /// public readonly bool Muted; - public Diagnostic(DiagnosticCode code, DiagnosticSeverity severity, [CanBeNull] string message, - LatticeGraph graph = null, string nodeFileId = null, string portId = null, + public Diagnostic(DiagnosticSeverity severity, [CanBeNull] string message, LatticeGraph graph = null, Exception exception = null, bool muted = false) { - Code = code; Severity = severity; Message = message; Graph = graph; - NodeFileId = nodeFileId; - PortId = portId; Exception = exception; Muted = muted; } - - /// Digs the port identifier out of an exception chain, for errors that know which port they are about. - [CanBeNull] - public static string PortIdFrom([CanBeNull] Exception e) - { - while (e != null) - { - if (e is LatticePortException portException) - { - return portException.PortIdentifier; - } - - e = e.InnerException; - } - - return null; - } - } - - /// - /// Stable identities for every class of problem the compiler reports. Codes prefixed Ice are internal - /// compiler errors: impossible unless we have a bug, and never muted. - /// - public enum DiagnosticCode - { - // -- User/content errors: something wrong in the authored graphs. -- - NodeError, // A static error on a node, when no more specific code applies. - NodeThrewWhileBuilding, // CompileToIR threw; the node was replaced with a malformed placeholder. - MixedEntityScopes, // A node combined values scoped to two different entities. - PhasesNotOrdered, // Inputs come from systems with no UpdateAfter/UpdateBefore ordering between them. - GraphNameNotUnique, // Two top-level graphs share a name. - - // -- ICEs. -- - IceGraphAddedTwice, - IceDuplicateNodeId, - IcePreviousBackRefMissing, - IceUnmappedPort, // An edge referenced a port with no IR mapping. - IcePortMapping, // A node's port maps are missing or incomplete. - IceRefEdgesUnsupported, - IceCompileNodeFailed, // Analysis threw while computing a node's metadata. - IceUnresolvedGeneric, // A generic type survived to type check. - IcePortUnconnected, // A required port has no inputs after construction. - IceInvalidOutputType, // A node's output type can't be executed (by-ref/managed). - IceTypeMismatch, // A port was assigned an incompatible input type. - IceMutatorReplaceFailed, - IceGeneratedFunctionDuplicated, } public enum DiagnosticSeverity @@ -115,20 +53,12 @@ public enum DiagnosticSeverity /// /// The artist-facing message catalog. Every user-facing diagnostic text lives here, so there is one place - /// to read and police the voice. + /// to read and police the voice: messages name nodes, ports, systems, and types the way the editor shows + /// them, say what went wrong, and say what to do next. An artist has never heard of inference, so compiler + /// vocabulary doesn't belong here. (See designs/type_system_refactor.html, section 4.3.) /// public static class DiagnosticMessages { - // Messages are written for artists: they name nodes, ports, systems, and types the way the editor shows - // them, say what went wrong, and say what to do next. Compiler vocabulary is banned -- an artist has - // never heard of inference or qualifiers, and a catalog entry using one of these words is a bug. - // (See designs/type_system_refactor.html, section 4.3.) ICE messages are exempt: they describe compiler - // bugs, for us, and keep their ICE: prefix. - public static readonly string[] BannedWords = - { - "infer", "unify", "type variable", "constraint", "qualifier", "signature" - }; - public const string MixedEntityScopesTemplate = "This node mixes values from two different entities ({0} and {1}). A node can only combine values from one entity."; @@ -148,8 +78,8 @@ public static string PhasesNotOrdered(Type firstGroup, Type secondGroup) } /// - /// Replays a compilation's diagnostics to the Unity console. Callers that want console output invoke this - /// once after compiling; the compiler itself never logs. + /// Replays a compilation's diagnostics to the Unity console. The compile entry point invokes this once a + /// compile finishes (and on the way out of a failed one); the compiler itself never logs. /// public static class DiagnosticConsole { diff --git a/com.pontoco.lattice/Runtime/IR/GlobalGraph.cs b/com.pontoco.lattice/Runtime/IR/GlobalGraph.cs index 2a3881c..75471c0 100644 --- a/com.pontoco.lattice/Runtime/IR/GlobalGraph.cs +++ b/com.pontoco.lattice/Runtime/IR/GlobalGraph.cs @@ -102,9 +102,6 @@ public GraphCompilation RecompileIfNeeded(bool force = false) try { Graph = GraphCompiler.CompileFixedSet(toplevelGraphs, settings); - - // The compiler only accumulates diagnostics; the service is where console policy lives. - DiagnosticConsole.Log(Graph); } catch (Exception e) { diff --git a/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs b/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs index 58ae91c..5000496 100644 --- a/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs +++ b/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs @@ -151,7 +151,7 @@ public void AddToplevelGraph(LatticeGraph graphAsset) { if (TopLevelGraphs.Contains(graphAsset)) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IceGraphAddedTwice, DiagnosticSeverity.Error, + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"(Lattice) ICE: Graph [{graphAsset.name}] was added to compilation twice.", graphAsset)); return; } @@ -204,9 +204,8 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) } catch (Exception e) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.NodeThrewWhileBuilding, DiagnosticSeverity.Error, - $"Syntax Error at [{node}]: {e.Message}", graphAsset, node.FileId, Diagnostic.PortIdFrom(e), - e, node.DoNotLogErrors)); + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, + $"Syntax Error at [{node}]: {e.Message}", graphAsset, e, node.DoNotLogErrors)); graph.ReplaceNodeWithMalformed(node.ToRootPath(), e); } @@ -219,8 +218,8 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) } catch (Exception e) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, null, - graphAsset, node.FileId, exception: e)); + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, + $"ICE: Setting up port defaults failed for node [{node}].", graphAsset, e)); } } @@ -234,9 +233,9 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) if (!graph.GetOutputMap(((LatticeNode)edge.fromNode).ToRootPath()) .TryGetValue(edge.fromPortIdentifier, out IRNodeRef inputNode)) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IceUnmappedPort, DiagnosticSeverity.Error, + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"ICE: No IRNode was mapped for output port [{edge.fromPortIdentifier}] on node [{edge.fromNode}].", - graphAsset, edge.fromNode.FileId, edge.fromPortIdentifier)); + graphAsset)); continue; } @@ -244,9 +243,9 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) if (!graph.GetInputMap(((LatticeNode)edge.toNode).ToRootPath()).TryGetValue(edge.toPortIdentifier, out (IRNode node, string irPort)? portMap)) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IceUnmappedPort, DiagnosticSeverity.Error, + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"ICE: No IRNode was mapped for input port [{edge.toPortIdentifier}] on node [{edge.toNode}].", - graphAsset, edge.toNode.FileId, edge.toPortIdentifier)); + graphAsset)); continue; } @@ -262,7 +261,7 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) else { // Stitch this node into the mutation pipeline. - Diagnostics.Add(new Diagnostic(DiagnosticCode.IceRefEdgesUnsupported, DiagnosticSeverity.Error, + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"ICE: Ref edges are not currently supported. Edge: [{edge}]", graphAsset)); } } @@ -274,7 +273,7 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) { if (ids.TryGetValue(node.Id, out IRNode n)) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IceDuplicateNodeId, DiagnosticSeverity.Error, + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"ICE: Two IRNodes have the same Id. Node [{node}] and [{n}]. Id:[{node.Id}]", graphAsset)); } ids.Add(node.Id, node); @@ -284,7 +283,7 @@ private IRGraph BuildGraphBody(LatticeGraph graphAsset) { if (n.Node.Ports.Count != 1) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"ICE: Input node in graph does not have 1 port. [{n.Node}][{n.Node.Ports.Count}]", graphAsset)); } } @@ -336,13 +335,10 @@ public Metadata CompileNode(IRNode node) catch (Exception e) { CodePath? owner = Graph.GetOwner(node); - Diagnostics.Add(new Diagnostic(DiagnosticCode.IceCompileNodeFailed, DiagnosticSeverity.Warning, - $"ICE: Node failed to compile. [{node}]", owner?.Last.Graph, owner?.Last.FileId, exception: e)); + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Warning, + $"ICE: Node failed to compile. [{node}]", owner?.Last.Graph, e)); metadata = new Metadata(null, typeof(ITypeUnknown), LatticePhases.GetLatticeDefaultPhase(), - new NodeCompilationError(node, "ICE: Node failed to compile.") - { - Code = DiagnosticCode.IceCompileNodeFailed - }); + new NodeCompilationError(node, "ICE: Node failed to compile.")); } MetadataDb[node] = metadata; @@ -408,10 +404,7 @@ public Metadata CalculateMetadata(IRNode node, IEnumerable<(string portId, Metad if (node is MalformedIRNode mnode) { return new Metadata(null, typeof(MalformedException), LatticePhases.GetLatticeDefaultPhase(), - new NodeCompilationError(node, mnode.Reason) - { - Code = DiagnosticCode.NodeThrewWhileBuilding - }); + new NodeCompilationError(node, mnode.Reason)); } if (node is EntityIRNode enode) @@ -459,10 +452,7 @@ public Metadata CalculateMetadata(IRNode node, IEnumerable<(string portId, Metad ) { error = new NodeCompilationError(node, - DiagnosticMessages.MixedEntityScopes(inputMetadata.Qualifier.Value, qualifier.Value)) - { - Code = DiagnosticCode.MixedEntityScopes - }; + DiagnosticMessages.MixedEntityScopes(inputMetadata.Qualifier.Value, qualifier.Value)); qualifier = null; // Null out the qualifier, because we'll just be returning an exception. } else @@ -539,16 +529,11 @@ private void TryMergePhase(IRNode node, ref Type existingPhase, Type inputPhase, if (!orderedAfter.HasValue) { + // Set the error for propagation and veto execution. Surfacing is left to EmitCompilationErrors, + // which reports it once, cleanly, at this node. The veto is unconditional -- DoNotLogErrors only + // silences the console, never changes whether the graph runs. error = new NodeCompilationError(node, - DiagnosticMessages.PhasesNotOrdered(inputGroup, existingGroup)) - { - Code = DiagnosticCode.PhasesNotOrdered - }; - - // The record and the execution veto always happen; DoNotLogErrors only silences the console. - CodePath? owner = Graph.GetOwner(node); - Diagnostics.Add(new Diagnostic(DiagnosticCode.PhasesNotOrdered, DiagnosticSeverity.Error, null, - owner?.Last.Graph, owner?.Last.FileId, exception: error, muted: node.DoNotLogErrors)); + DiagnosticMessages.PhasesNotOrdered(inputGroup, existingGroup)); CannotBeExecuted = true; return; } @@ -662,8 +647,8 @@ public void DeduplicatedCodeGen(string typeName, Func 1) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, - $"No primary node specified for node [{span}].", span.Root, span.Last.FileId)); + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, + $"No primary node specified for node [{span}].", span.Root)); } else if (nodes.Count == 1) { @@ -885,8 +870,8 @@ private void SetupDefaults(IRGraph graph, CodePath span) } else { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, - $"No nodes created for node [{span}].", span.Root, span.Last.FileId)); + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, + $"No nodes created for node [{span}].", span.Root)); } } @@ -902,9 +887,9 @@ private void SetupDefaults(IRGraph graph, CodePath span) var nodes = graph.GetNodesUnderPath(span); if (nodes.Count != 1) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"Couldn't add default output port mapping to node [{span}]. Node count: {nodes.Count}.", - span.Root, span.Last.FileId)); + span.Root)); return; } @@ -912,8 +897,8 @@ private void SetupDefaults(IRGraph graph, CodePath span) } else if (span.Last.GetValueOutputs().Count() > 1) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, - $"No output port mapping found for compiled node [{span}].", span.Root, span.Last.FileId)); + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, + $"No output port mapping found for compiled node [{span}].", span.Root)); } } else @@ -923,9 +908,9 @@ private void SetupDefaults(IRGraph graph, CodePath span) { if (!outputMap.ContainsKey(output.portData.identifier)) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"Incomplete OutputPortMap for compiled node [{span}]. Missing port [{output.portData.identifier}]", - span.Root, span.Last.FileId, output.portData.identifier)); + span.Root)); } } } @@ -938,9 +923,9 @@ private void SetupDefaults(IRGraph graph, CodePath span) { if (!inputMap.ContainsKey(input.portData.identifier)) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"Incomplete InputPortMap for compiled node [{span}]. Missing port [{input.portData.identifier}]", - span.Root, span.Last.FileId, input.portData.identifier)); + span.Root)); } } } @@ -967,8 +952,8 @@ private void SetupDefaults(IRGraph graph, CodePath span) } else if (span.Last.GetLogicalInputs().Any()) { - Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortMapping, DiagnosticSeverity.Error, - $"No input port mapping found for compiled node [{span}].", span.Root, span.Last.FileId)); + Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, + $"No input port mapping found for compiled node [{span}].", span.Root)); } } } @@ -1111,9 +1096,6 @@ public class NodeCompilationError : Exception { public IRNode Node; - /// Which class of failure this is, so the diagnostic that surfaces it carries the right code. - public DiagnosticCode Code = DiagnosticCode.NodeError; - public NodeCompilationError(IRNode node, string message, Exception e) : base(message, e) { Node = node; diff --git a/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs b/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs index 56e3ce7..5766ed6 100644 --- a/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs +++ b/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs @@ -38,16 +38,26 @@ public struct Settings : IEquatable /// Overrides the debug setting, allowing inspecting of node outputs. Defaults to false. public bool Debug; + /// + /// Keeps the compiler's diagnostics out of the Unity console. The list on the compilation is still + /// populated; only the automatic replay is skipped. Tests that assert on diagnostics set this. + /// + public bool SuppressConsoleDiagnostics; + public bool Equals(Settings other) { - return AssemblyAccess == other.AssemblyAccess && Debug == other.Debug; + return AssemblyAccess == other.AssemblyAccess && Debug == other.Debug && + SuppressConsoleDiagnostics == other.SuppressConsoleDiagnostics; } public override int GetHashCode() { unchecked { - return (AssemblyAccess.GetHashCode() * 397) ^ Debug.GetHashCode(); + int hash = AssemblyAccess.GetHashCode(); + hash = (hash * 397) ^ Debug.GetHashCode(); + hash = (hash * 397) ^ SuppressConsoleDiagnostics.GetHashCode(); + return hash; } } } @@ -114,6 +124,25 @@ internal static GraphCompilation CompileFixedSet(IEnumerable topLe // =============================== GraphCompilation compilation = new(settings); + // The compiler accumulates problems as data; this is the one place console policy lives. The finally + // flushes whatever was found even if a pass throws partway, so a fatal compile still leaves breadcrumbs. + try + { + return CompileFixedSetInner(compilation, topLevelGraphs, settings); + } + finally + { + if (!settings.SuppressConsoleDiagnostics) + { + DiagnosticConsole.Log(compilation); + } + } + } + + private static GraphCompilation CompileFixedSetInner(GraphCompilation compilation, + IEnumerable topLevelGraphs, + Settings settings) + { // All graphs in the compilation must have a unique name. This is because we use names for GUIDs, until we have a better solution. HashSet names = new HashSet(); var graphsWithUniqueNames = new List(); @@ -121,8 +150,7 @@ internal static GraphCompilation CompileFixedSet(IEnumerable topLe { if (!names.Add(g.name)) { - compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.GraphNameNotUnique, - DiagnosticSeverity.Error, + compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"Lattice graph named [{g.name}] was defined twice. Graphs must have unique names.", g)); continue; } @@ -172,8 +200,7 @@ internal static GraphCompilation CompileFixedSet(IEnumerable topLe { if (ids.TryGetValue(node.Id, out IRNode n)) { - compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceDuplicateNodeId, - DiagnosticSeverity.Error, + compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"ICE: Two IRNodes have the same Id. Node [{node}] and [{n}]. Id:[{node.Id}]")); } ids.Add(node.Id, node); @@ -233,8 +260,8 @@ internal static GraphCompilation CompileFixedSet(IEnumerable topLe { if (p.BackRef == null) { - compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePreviousBackRefMissing, - DiagnosticSeverity.Error, "ICE: PreviousNode with null BackRef.")); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, + "ICE: PreviousNode with null BackRef.")); continue; } compilation.Graph.ReferencedByPreviousNode.Add(p.BackRef.Node); @@ -262,10 +289,12 @@ private static void EmitCompilationErrors(GraphCompilation compilation) var error = compilation.CompileNode(n).CompilationError; if (error != null && error.Node == n) { + // The artist-facing text lives on the inner exception -- NodeCompilationError stashes its + // message there. No exception is attached: a real throw already logged its stack where it was + // caught (BuildGraphBody), and a logic error (mixed scopes, unordered phases) has no useful one. CodePath? owner = compilation.Graph.GetOwner(error.Node); - compilation.Diagnostics.Add(new Diagnostic(error.Code, DiagnosticSeverity.Error, - $"Syntax Error at [{error.Node}]: {error}\n\n", owner?.Last.Graph, owner?.Last.FileId, - Diagnostic.PortIdFrom(error), error, n.DoNotLogErrors)); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, + error.InnerException?.Message ?? error.Message, owner?.Last.Graph, muted: n.DoNotLogErrors)); } } } @@ -467,10 +496,9 @@ bool TypesAreCompatible(Type portType, Type inputType) if (compilation.CompileNode(node).OutputType.IsGenericParameter) { CodePath? owner = compilation.Graph.GetOwner(node); - compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceUnresolvedGeneric, - DiagnosticSeverity.Error, + compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"ICE: Generic type was found in node [{node}]. All generics must resolve into concrete types after node compilation.", - owner?.Last.Graph, owner?.Last.FileId)); + owner?.Last.Graph)); compilation.CannotBeExecuted = true; } } @@ -483,18 +511,16 @@ bool TypesAreCompatible(Type portType, Type inputType) { // FunctionIRNodes must be connected on all ports. CodePath? owner = compilation.Graph.GetOwner(node); - compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IcePortUnconnected, - DiagnosticSeverity.Error, + compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"ICE: Port [{id}] must be connected on node [{node}]. No inputs connected.", - owner?.Last.Graph, owner?.Last.FileId, id)); + owner?.Last.Graph)); compilation.CannotBeExecuted = true; } var compileData = compilation.CompileNode(node); if (compileData.OutputType.IsByRef) { - compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceInvalidOutputType, - DiagnosticSeverity.Error, + compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"$ICE: Node has output of type 'ref'. [{node}] ByRef types are not allowed because pointers can't be stored across frames.")); compilation.CannotBeExecuted = true; } @@ -502,8 +528,7 @@ bool TypesAreCompatible(Type portType, Type inputType) if (compileData.OutputType.IsByRef) { // Managed types aren't supported yet, as codegen assumes all types can be boxed/nullable-wrapped. - compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceInvalidOutputType, - DiagnosticSeverity.Error, + compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"$ICE: Node has a managed output type. [{node}] Managed types are not allowed, yet.")); compilation.CannotBeExecuted = true; } @@ -514,10 +539,9 @@ bool TypesAreCompatible(Type portType, Type inputType) if (!TypesAreCompatible(port.Type, inputType)) { CodePath? owner = compilation.Graph.GetOwner(node); - compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceTypeMismatch, - DiagnosticSeverity.Error, + compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, $"ICE TypeError: Port [{id}][{port.Type}] cannot be assigned input of type [{inputType}]. Node: [{node}] Input: [{input}]", - owner?.Last.Graph, owner?.Last.FileId, id)); + owner?.Last.Graph)); compilation.CannotBeExecuted = true; } } @@ -766,8 +790,8 @@ public static void Pass_ConvertPreviousNodesToPointers(GraphCompilation compilat } catch (Exception e) { - compilation.Diagnostics.Add(new Diagnostic(DiagnosticCode.IceMutatorReplaceFailed, - DiagnosticSeverity.Error, "Replacing MutatorIRNodes failed.", exception: e)); + compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, + "Replacing MutatorIRNodes failed.", exception: e)); } } From 8fa7189fb3ebfec5ef65826c825cbbec4fa13671 Mon Sep 17 00:00:00 2001 From: John Austin Date: Sun, 14 Jun 2026 21:20:27 -0700 Subject: [PATCH 3/5] Another pass at review. --- .../Assets/Tests/DiagnosticCatalogTests.cs | 41 ------------------- .../Tests/DiagnosticCatalogTests.cs.meta | 2 - com.pontoco.lattice/Runtime/IR/Diagnostics.cs | 6 +-- .../Runtime/IR/GraphCompilation.cs | 2 +- .../Runtime/IR/GraphCompiler.cs | 17 +++++--- 5 files changed, 16 insertions(+), 52 deletions(-) delete mode 100644 LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs delete mode 100644 LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs.meta diff --git a/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs b/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs deleted file mode 100644 index b1fe251..0000000 --- a/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using System.Reflection; -using Lattice.IR; -using NUnit.Framework; - -namespace Tests.Runtime -{ - // Enforces the D5 rule from the type-system design (designs/type_system_refactor.html, section 4.3): - // artist-facing diagnostics never use compiler vocabulary. The catalog is the single source of those - // strings, so we lint it directly -- a const that smuggles in "infer", "qualifier", etc. fails the build. - // The catalog doesn't know this test exists; adding a new message template is automatically covered. - public class DiagnosticCatalogTests - { - [Test] - public void CatalogUsesNoCompilerJargon() - { - List violations = new(); - - foreach (FieldInfo field in typeof(DiagnosticMessages).GetFields(BindingFlags.Public | BindingFlags.Static)) - { - if (field.FieldType != typeof(string) || !(field.IsLiteral || field.IsInitOnly)) - { - continue; // Only the message templates; BannedWords itself is a string[], naturally skipped. - } - - string template = ((string)field.GetValue(null)).ToLowerInvariant(); - foreach (string banned in DiagnosticMessages.BannedWords) - { - if (template.Contains(banned.ToLowerInvariant())) - { - violations.Add($"[{field.Name}] contains banned word [{banned}]"); - } - } - } - - Assert.IsEmpty(violations, - "Diagnostic messages must read for artists, not compiler authors. Offenders:\n" + - string.Join("\n", violations)); - } - } -} diff --git a/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs.meta b/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs.meta deleted file mode 100644 index e2066f8..0000000 --- a/LatticeTestProject/Assets/Tests/DiagnosticCatalogTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 79c7d3d659cb4f10b20e0eeab8ea38cf diff --git a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs index a1d7c9f..22f612d 100644 --- a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs +++ b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs @@ -54,8 +54,8 @@ public enum DiagnosticSeverity /// /// The artist-facing message catalog. Every user-facing diagnostic text lives here, so there is one place /// to read and police the voice: messages name nodes, ports, systems, and types the way the editor shows - /// them, say what went wrong, and say what to do next. An artist has never heard of inference, so compiler - /// vocabulary doesn't belong here. (See designs/type_system_refactor.html, section 4.3.) + /// them, say what went wrong, and say what to do next. An artist has never heard of inference or a + /// qualifier, so compiler vocabulary doesn't belong in these strings. /// public static class DiagnosticMessages { @@ -83,7 +83,7 @@ public static string PhasesNotOrdered(Type firstGroup, Type secondGroup) /// public static class DiagnosticConsole { - public static void Log(GraphCompilation compilation) + public static void Replay(GraphCompilation compilation) { foreach (Diagnostic d in compilation.Diagnostics) { diff --git a/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs b/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs index 5000496..ada27aa 100644 --- a/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs +++ b/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs @@ -63,7 +63,7 @@ public class GraphCompilation /// /// Every problem found while building and analyzing the graphs, in the order found. The compiler only - /// accumulates these; callers decide policy. + /// accumulates these; callers decide policy. /// public readonly List Diagnostics = new(); diff --git a/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs b/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs index 5766ed6..42a84aa 100644 --- a/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs +++ b/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs @@ -134,7 +134,7 @@ internal static GraphCompilation CompileFixedSet(IEnumerable topLe { if (!settings.SuppressConsoleDiagnostics) { - DiagnosticConsole.Log(compilation); + DiagnosticConsole.Replay(compilation); } } } @@ -290,11 +290,18 @@ private static void EmitCompilationErrors(GraphCompilation compilation) if (error != null && error.Node == n) { // The artist-facing text lives on the inner exception -- NodeCompilationError stashes its - // message there. No exception is attached: a real throw already logged its stack where it was - // caught (BuildGraphBody), and a logic error (mixed scopes, unordered phases) has no useful one. + // message there. We name the authored node, since these messages ("this node...") otherwise + // can't be located on a big graph. No exception is attached: a real throw already logged its + // stack where it was caught (BuildGraphBody), and a logic error has no useful one. CodePath? owner = compilation.Graph.GetOwner(error.Node); - compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, - error.InnerException?.Message ?? error.Message, owner?.Last.Graph, muted: n.DoNotLogErrors)); + string message = error.InnerException?.Message ?? error.Message; + if (owner.HasValue) + { + message = $"{owner.Value.Last.GetPath()}: {message}"; + } + + compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, message, + owner?.Last.Graph, muted: n.DoNotLogErrors)); } } } From 4c67b1078fdb9ae4ea633252b7f9fbf83f0b8490 Mon Sep 17 00:00:00 2001 From: John Austin Date: Sun, 14 Jun 2026 21:42:24 -0700 Subject: [PATCH 4/5] Pass 3 --- .../Editor/Lattice/LatticeNodeView.cs | 4 +++- com.pontoco.lattice/Runtime/IR/Diagnostics.cs | 21 +++++++++++++------ .../Runtime/IR/Diagnostics.cs.meta | 3 ++- .../Runtime/IR/GraphCompiler.cs | 21 ++++++++++++------- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/com.pontoco.lattice/Editor/Lattice/LatticeNodeView.cs b/com.pontoco.lattice/Editor/Lattice/LatticeNodeView.cs index cbecaf0..4c95dea 100644 --- a/com.pontoco.lattice/Editor/Lattice/LatticeNodeView.cs +++ b/com.pontoco.lattice/Editor/Lattice/LatticeNodeView.cs @@ -435,7 +435,9 @@ public override void BuildContextualMenu(ContextualMenuPopulateEvent evt) { Target.Last.DoNotLogErrors = !Target.Last.DoNotLogErrors; - // The flag is baked into the compilation, so it only takes effect through a recompile. + // The flag is baked into the compilation, so it only takes effect through a recompile. This + // refreshes the editor's view; a graph executing in play mode keeps its old logging until the + // Runtime graph itself recompiles. EditorUtility.SetDirty(Owner.Graph); GlobalGraph.LanguageServer.RecompileIfNeeded(force: true); }); diff --git a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs index 22f612d..342bb95 100644 --- a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs +++ b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs @@ -52,20 +52,24 @@ public enum DiagnosticSeverity } /// - /// The artist-facing message catalog. Every user-facing diagnostic text lives here, so there is one place - /// to read and police the voice: messages name nodes, ports, systems, and types the way the editor shows - /// them, say what went wrong, and say what to do next. An artist has never heard of inference or a - /// qualifier, so compiler vocabulary doesn't belong in these strings. + /// The artist-facing diagnostic messages, each composing compiler values into a sentence, so their wording + /// sits in one place to read and police: they name nodes, ports, systems, and types the way the editor shows + /// them, say what went wrong, and say what to do next. ICE messages -- addressed to us, not the artist -- + /// stay inline at their site. An artist has never heard of inference or a qualifier, so that vocabulary + /// doesn't belong in here. /// public static class DiagnosticMessages { - public const string MixedEntityScopesTemplate = + private const string MixedEntityScopesTemplate = "This node mixes values from two different entities ({0} and {1}). A node can only combine values from one entity."; - public const string PhasesNotOrderedTemplate = + private const string PhasesNotOrderedTemplate = "This node reads from {0} and {1}, but Lattice can't tell which runs first. Add an ordering between those systems.\n" + "(Missing UpdateAfter/UpdateBefore between [{0}] and [{1}].)"; + private const string GraphNameNotUniqueTemplate = + "Two Lattice graphs are both named [{0}]. Give each graph a unique name."; + public static string MixedEntityScopes(Qualifier first, Qualifier second) { return string.Format(MixedEntityScopesTemplate, first.ShortName(), second.ShortName()); @@ -75,6 +79,11 @@ public static string PhasesNotOrdered(Type firstGroup, Type secondGroup) { return string.Format(PhasesNotOrderedTemplate, firstGroup.Name, secondGroup.Name); } + + public static string GraphNameNotUnique(string graphName) + { + return string.Format(GraphNameNotUniqueTemplate, graphName); + } } /// diff --git a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs.meta b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs.meta index 19504ca..bad9961 100644 --- a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs.meta +++ b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs.meta @@ -1,2 +1,3 @@ fileFormatVersion: 2 -guid: 31753c727cea64e0e8452f2623f39dfc \ No newline at end of file +guid: 31753c727cea64e0e8452f2623f39dfc +timeCreated: 1781498018 diff --git a/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs b/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs index 42a84aa..c34552a 100644 --- a/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs +++ b/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs @@ -40,24 +40,29 @@ public struct Settings : IEquatable /// /// Keeps the compiler's diagnostics out of the Unity console. The list on the compilation is still - /// populated; only the automatic replay is skipped. Tests that assert on diagnostics set this. + /// populated; only the automatic replay is skipped. Tests that assert on diagnostics set this. It is + /// not part of Equals/GetHashCode: it changes console policy, not the compiled result, so it must not + /// split the recompile cache. /// public bool SuppressConsoleDiagnostics; public bool Equals(Settings other) { - return AssemblyAccess == other.AssemblyAccess && Debug == other.Debug && - SuppressConsoleDiagnostics == other.SuppressConsoleDiagnostics; + return AssemblyAccess == other.AssemblyAccess && Debug == other.Debug; + } + + // Overridden so the boxed/object path agrees with the typed Equals and GetHashCode -- otherwise the + // default ValueType.Equals compares every field, including SuppressConsoleDiagnostics, and disagrees. + public override bool Equals(object obj) + { + return obj is Settings other && Equals(other); } public override int GetHashCode() { unchecked { - int hash = AssemblyAccess.GetHashCode(); - hash = (hash * 397) ^ Debug.GetHashCode(); - hash = (hash * 397) ^ SuppressConsoleDiagnostics.GetHashCode(); - return hash; + return (AssemblyAccess.GetHashCode() * 397) ^ Debug.GetHashCode(); } } } @@ -151,7 +156,7 @@ private static GraphCompilation CompileFixedSetInner(GraphCompilation compilatio if (!names.Add(g.name)) { compilation.Diagnostics.Add(new Diagnostic(DiagnosticSeverity.Error, - $"Lattice graph named [{g.name}] was defined twice. Graphs must have unique names.", g)); + DiagnosticMessages.GraphNameNotUnique(g.name), g)); continue; } graphsWithUniqueNames.Add(g); From fab937f18b26fef100d28f17a06af64decaef163 Mon Sep 17 00:00:00 2001 From: John Austin Date: Sun, 14 Jun 2026 22:03:14 -0700 Subject: [PATCH 5/5] Pass 4 --- com.pontoco.lattice/Runtime/IR/Diagnostics.cs | 42 ----------- .../Runtime/IR/GraphCompilation.cs | 3 +- .../Runtime/IR/GraphCompiler.cs | 70 ++++++++++++------- 3 files changed, 48 insertions(+), 67 deletions(-) diff --git a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs index 342bb95..9607f03 100644 --- a/com.pontoco.lattice/Runtime/IR/Diagnostics.cs +++ b/com.pontoco.lattice/Runtime/IR/Diagnostics.cs @@ -1,6 +1,5 @@ using System; using JetBrains.Annotations; -using UnityEngine; namespace Lattice.IR { @@ -85,45 +84,4 @@ public static string GraphNameNotUnique(string graphName) return string.Format(GraphNameNotUniqueTemplate, graphName); } } - - /// - /// Replays a compilation's diagnostics to the Unity console. The compile entry point invokes this once a - /// compile finishes (and on the way out of a failed one); the compiler itself never logs. - /// - public static class DiagnosticConsole - { - public static void Replay(GraphCompilation compilation) - { - foreach (Diagnostic d in compilation.Diagnostics) - { - if (d.Muted) - { - continue; - } - - if (d.Message != null) - { - switch (d.Severity) - { - case DiagnosticSeverity.Error: - Debug.LogError(d.Message, d.Graph); - break; - case DiagnosticSeverity.Warning: - Debug.LogWarning(d.Message, d.Graph); - break; - case DiagnosticSeverity.Info: - Debug.Log(d.Message, d.Graph); - break; - default: - throw new ArgumentOutOfRangeException(); - } - } - - if (d.Exception != null) - { - Debug.LogException(d.Exception, d.Graph); - } - } - } - } } diff --git a/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs b/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs index ada27aa..7e98751 100644 --- a/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs +++ b/com.pontoco.lattice/Runtime/IR/GraphCompilation.cs @@ -63,7 +63,8 @@ public class GraphCompilation /// /// Every problem found while building and analyzing the graphs, in the order found. The compiler only - /// accumulates these; callers decide policy. + /// accumulates these; whether they reach the console is the caller's choice (the logToConsole flag on + /// the compile entry points, defaulting on). /// public readonly List Diagnostics = new(); diff --git a/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs b/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs index c34552a..f0b196b 100644 --- a/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs +++ b/com.pontoco.lattice/Runtime/IR/GraphCompiler.cs @@ -38,26 +38,11 @@ public struct Settings : IEquatable /// Overrides the debug setting, allowing inspecting of node outputs. Defaults to false. public bool Debug; - /// - /// Keeps the compiler's diagnostics out of the Unity console. The list on the compilation is still - /// populated; only the automatic replay is skipped. Tests that assert on diagnostics set this. It is - /// not part of Equals/GetHashCode: it changes console policy, not the compiled result, so it must not - /// split the recompile cache. - /// - public bool SuppressConsoleDiagnostics; - public bool Equals(Settings other) { return AssemblyAccess == other.AssemblyAccess && Debug == other.Debug; } - // Overridden so the boxed/object path agrees with the typed Equals and GetHashCode -- otherwise the - // default ValueType.Equals compares every field, including SuppressConsoleDiagnostics, and disagrees. - public override bool Equals(object obj) - { - return obj is Settings other && Equals(other); - } - public override int GetHashCode() { unchecked @@ -68,15 +53,17 @@ public override int GetHashCode() } /// Compiles a standalone compilation including just this graph and its dependencies. - public static GraphCompilation CompileStandalone(LatticeGraph graph, Settings settings = default) + public static GraphCompilation CompileStandalone(LatticeGraph graph, Settings settings = default, + bool logToConsole = true) { - return CompileFixedSet(GetGraphDependencies(graph), settings); + return CompileFixedSet(GetGraphDependencies(graph), settings, logToConsole); } /// Compiles a standalone compilation including all the given graphs and their dependencies. - public static GraphCompilation CompileStandalone(IEnumerable graphs, Settings settings = default) + public static GraphCompilation CompileStandalone(IEnumerable graphs, Settings settings = default, + bool logToConsole = true) { - return CompileFixedSet(graphs.SelectMany(GetGraphDependencies).ToHashSet(), settings); + return CompileFixedSet(graphs.SelectMany(GetGraphDependencies).ToHashSet(), settings, logToConsole); } internal static HashSet GetGraphDependencies(LatticeGraph startingGraph) @@ -108,7 +95,7 @@ void AddDownstreamRecursive(LatticeGraph graph) /// IR graph for all nodes. /// internal static GraphCompilation CompileFixedSet(IEnumerable topLevelGraphs, - Settings settings = default) + Settings settings = default, bool logToConsole = true) { using ProfilerMarker.AutoScope marker = new ProfilerMarker("Lattice Compile").Auto(); @@ -129,17 +116,52 @@ internal static GraphCompilation CompileFixedSet(IEnumerable topLe // =============================== GraphCompilation compilation = new(settings); - // The compiler accumulates problems as data; this is the one place console policy lives. The finally - // flushes whatever was found even if a pass throws partway, so a fatal compile still leaves breadcrumbs. + // The compiler accumulates problems as data; whether to echo them to the console is the caller's + // choice, defaulting on. The finally flushes whatever was found even if a pass throws partway, so a + // fatal compile still leaves breadcrumbs. A caller that compiled quietly can walk Diagnostics itself. try { return CompileFixedSetInner(compilation, topLevelGraphs, settings); } finally { - if (!settings.SuppressConsoleDiagnostics) + if (logToConsole) + { + LogDiagnosticMessages(compilation); + } + } + } + + private static void LogDiagnosticMessages(GraphCompilation compilation) + { + foreach (Diagnostic d in compilation.Diagnostics) + { + if (d.Muted) + { + continue; + } + + if (d.Message != null) + { + switch (d.Severity) + { + case DiagnosticSeverity.Error: + Debug.LogError(d.Message, d.Graph); + break; + case DiagnosticSeverity.Warning: + Debug.LogWarning(d.Message, d.Graph); + break; + case DiagnosticSeverity.Info: + Debug.Log(d.Message, d.Graph); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + if (d.Exception != null) { - DiagnosticConsole.Replay(compilation); + Debug.LogException(d.Exception, d.Graph); } } }