Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/MeshWeaver.Compiler/CompilationInputs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,18 @@ namespace MeshWeaver.Compiler;
/// whether their cached workspace is still valid.
/// </para>
/// </summary>
/// <param name="GlobalUsingsSource">The compile's import scope rendered as <c>global using</c>
/// directives (<c>DynamicMeshNodeAttributeGenerator.GenerateGlobalUsingsSource</c>).
/// <para>🚨 REQUIRED for correctness, not a convenience. <see cref="Sources"/> is one tree per file
/// and a C# <c>using</c> is FILE-SCOPED, so without this document the skeleton's imports reach none
/// of the user trees and the language service reports phantom CS0246/CS0308 on source that compiles
/// and ships — a false FAIL on a cleanly loaded assembly (Systemorph/MeshWeaver#1802). Any consumer
/// that assembles a compilation or workspace from these inputs MUST include it as a document.</para></param>
internal sealed record CompilationInputs(
string AssemblyName,
ImmutableArray<(string Path, string Code)> Sources,
string SkeletonSource,
string GlobalUsingsSource,
ImmutableArray<MetadataReference> References,
CSharpParseOptions ParseOptions,
CSharpCompilationOptions CompilationOptions,
Expand Down
21 changes: 18 additions & 3 deletions src/MeshWeaver.Compiler/CompileDiagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ internal static string FormatCompileFailure(string nodePath, IEnumerable<Diagnos
/// </summary>
internal const string SkeletonDiagnosticsPath = "__skeleton__.cs";

/// <summary>
/// Sentinel FilePath for the generated <c>global using</c> import scope (#1802) — filtered out
/// of results for the same reason as the skeleton: generated, and not editable by the user.
/// </summary>
internal const string GlobalUsingsDiagnosticsPath = "__global_usings__.cs";

/// <summary>
/// Re-derives a failed compile's diagnostics in their structured, per-source-file form by
/// assembling ONE LSP-style compilation (skeleton tree + one tree per src/test Code node,
Expand All @@ -57,11 +63,19 @@ internal static string FormatCompileFailure(string nodePath, IEnumerable<Diagnos
/// </summary>
internal static IReadOnlyList<Lsp.DiagnosticInfo> DiagnoseInputs(CompilationInputs inputs)
{
var trees = new List<SyntaxTree>(inputs.Sources.Length + 1)
var trees = new List<SyntaxTree>(inputs.Sources.Length + 2)
{
CSharpSyntaxTree.ParseText(
Microsoft.CodeAnalysis.Text.SourceText.From(inputs.SkeletonSource),
inputs.ParseOptions, path: SkeletonDiagnosticsPath),
// 🚨 The import scope, as `global using`. The per-file trees below give each diagnostic
// the Code node's path (the whole point here), but a C# `using` is FILE-SCOPED, so
// without this document the skeleton's preamble covers none of them and this reports
// errors the emit path does not have — #1802, which put 12 phantom CS0246/CS0308 on a
// NodeType whose assembly had built and loaded.
CSharpSyntaxTree.ParseText(
Microsoft.CodeAnalysis.Text.SourceText.From(inputs.GlobalUsingsSource),
inputs.ParseOptions, path: GlobalUsingsDiagnosticsPath),
};
foreach (var (path, code) in inputs.Sources)
trees.Add(CSharpSyntaxTree.ParseText(
Expand All @@ -81,8 +95,9 @@ internal static string FormatCompileFailure(string nodePath, IEnumerable<Diagnos
foreach (var d in diags)
{
if (d.Severity is not (DiagnosticSeverity.Error or DiagnosticSeverity.Warning)) continue;
// Skeleton-internal diagnostics are framework noise the user can't act on.
if (d.Location.SourceTree?.FilePath == SkeletonDiagnosticsPath) continue;
// Generated-tree diagnostics are framework noise the user can't act on.
var treePath = d.Location.SourceTree?.FilePath;
if (treePath == SkeletonDiagnosticsPath || treePath == GlobalUsingsDiagnosticsPath) continue;
result.Add(ToDiagnosticInfo(d));
}
// Errors first, then by file then position — stable order for the GUI.
Expand Down
120 changes: 96 additions & 24 deletions src/MeshWeaver.Compiler/DynamicMeshNodeAttributeGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using System.Collections.Immutable;
using System.Text;
using System.Text.RegularExpressions;
using MeshWeaver.ContentCollections;
using MeshWeaver.Mesh;
Expand All @@ -11,6 +12,96 @@ namespace MeshWeaver.Compiler;
/// </summary>
internal class DynamicMeshNodeAttributeGenerator
{
/// <summary>
/// The namespaces the generated skeleton puts in scope for user code. Immutable, written once,
/// never mutated at runtime — a constant lookup, not a cache.
///
/// <para>🚨 This list is the ONE declaration of "what a NodeType source can assume is imported",
/// and BOTH compile paths must read it from here. The emit path gets it by concatenating user
/// code INTO the skeleton file (<c>GenerateAttributeSource</c>, so these are ordinary
/// file-scoped usings that cover the concatenated whole); the language-service path keeps one
/// syntax tree PER source file for Monaco positions, so it gets it from
/// <see cref="GenerateGlobalUsingsSource"/> instead. Two renderings of one list — never two
/// lists (Systemorph/MeshWeaver#1802).</para>
/// </summary>
internal static readonly ImmutableArray<string> StandardUsings =
[
"System",
"System.Collections.Generic",
"System.ComponentModel",
"System.ComponentModel.DataAnnotations",
"System.Linq",
"System.Reactive.Linq",
"System.Text.Json.Serialization",
"MeshWeaver.Mesh",
"MeshWeaver.Messaging",
"MeshWeaver.Data",
"MeshWeaver.Domain",
"MeshWeaver.Graph",
"MeshWeaver.Graph.Configuration",
"MeshWeaver.Layout",
"MeshWeaver.Layout.Composition",
"MeshWeaver.Layout.Domain",
"MeshWeaver.Layout.Views",
"MeshWeaver.Application.Styles",
"MeshWeaver.ContentCollections",
"MeshWeaver.Mesh.Services",
"Microsoft.Extensions.DependencyInjection",
"Microsoft.Extensions.Configuration",
];

/// <summary>
/// Renders the compile's import scope as <c>global using</c> directives, for the
/// language-service path — which keeps ONE SYNTAX TREE PER SOURCE FILE so a diagnostic, hover
/// or completion carries the MeshNode path the user is actually editing.
///
/// <para><b>Why this exists (#1802).</b> The emit path concatenates every source into the
/// skeleton file, so <see cref="StandardUsings"/> and every file's own <c>using</c>s cover the
/// whole compilation. A C# <c>using</c> is <b>file-scoped</b>, so under per-file trees the
/// skeleton's imports reach NOTHING — and the language service reported phantom CS0246/CS0308
/// on source that compiles and ships. <c>global using</c> is the language feature for exactly
/// this: declared in one tree, in scope for the whole compilation. That restores emit's scope
/// WITHOUT collapsing the per-file trees the language service exists to provide.</para>
///
/// <para>The union of the authored <c>using</c>s is included for the same reason: concatenation
/// hoists them to the top of the single emitted file, so under emit every file already sees
/// every other file's imports. Reproducing that here uses the SAME
/// <see cref="ExtractUsingStatements"/> the emit path hoists with, so the two cannot drift.</para>
/// </summary>
/// <param name="userSources">The effective source bodies of the compilation — for a speculative
/// check, with the proposed body already substituted, so a <c>using</c> the user just typed
/// counts.</param>
public string GenerateGlobalUsingsSource(IEnumerable<string?> userSources)
{
// Ordinal set: `using` directives are case-sensitive, and a stable order keeps the
// generated document byte-identical across calls (it is a cache key upstream).
var namespaces = new SortedSet<string>(StringComparer.Ordinal);
foreach (var ns in StandardUsings)
namespaces.Add(ns);

foreach (var code in userSources)
{
var (usings, _) = ExtractUsingStatements(code);
foreach (var directive in usings)
{
// ExtractUsingStatements returns whole lines ("using X;", possibly indented, and
// possibly `using static X;` or an alias). Only a plain namespace import can be
// promoted to `global using` verbatim; an alias/static form is emitted as-is with
// the global modifier, which C# also accepts.
var trimmed = directive.Trim();
if (trimmed.StartsWith("using ", StringComparison.Ordinal))
namespaces.Add(trimmed["using ".Length..].TrimEnd(';', ' '));
}
}

var sb = new StringBuilder();
sb.AppendLine("// Auto-generated import scope for language services — do not edit.");
sb.AppendLine("// Mirrors the emit path's concatenated-file scope; see #1802.");
foreach (var ns in namespaces)
sb.AppendLine($"global using {ns};");
return sb.ToString();
}

/// <summary>
/// Generates the complete C# source code for a dynamic node assembly.
/// </summary>
Expand Down Expand Up @@ -40,29 +131,10 @@ public string GenerateAttributeSource(
sb.AppendLine("// Source file for debugging support - do not edit manually");
sb.AppendLine();

// Using statements (standard ones)
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using System.ComponentModel;");
sb.AppendLine("using System.ComponentModel.DataAnnotations;");
sb.AppendLine("using System.Linq;");
sb.AppendLine("using System.Reactive.Linq;");
sb.AppendLine("using System.Text.Json.Serialization;");
sb.AppendLine("using MeshWeaver.Mesh;");
sb.AppendLine("using MeshWeaver.Messaging;");
sb.AppendLine("using MeshWeaver.Data;");
sb.AppendLine("using MeshWeaver.Domain;");
sb.AppendLine("using MeshWeaver.Graph;");
sb.AppendLine("using MeshWeaver.Graph.Configuration;");
sb.AppendLine("using MeshWeaver.Layout;");
sb.AppendLine("using MeshWeaver.Layout.Composition;");
sb.AppendLine("using MeshWeaver.Layout.Domain;");
sb.AppendLine("using MeshWeaver.Layout.Views;");
sb.AppendLine("using MeshWeaver.Application.Styles;");
sb.AppendLine("using MeshWeaver.ContentCollections;");
sb.AppendLine("using MeshWeaver.Mesh.Services;");
sb.AppendLine("using Microsoft.Extensions.DependencyInjection;");
sb.AppendLine("using Microsoft.Extensions.Configuration;");
// Using statements (standard ones) — read from the single declaration so the emit path and
// the language service's `global using` rendering cannot drift apart (#1802).
foreach (var ns in StandardUsings)
sb.AppendLine($"using {ns};");

// User-defined using statements (extracted from code files)
foreach (var userUsing in userUsings)
Expand Down
57 changes: 40 additions & 17 deletions src/MeshWeaver.Compiler/SpeculativeCompilation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ internal sealed class SpeculativeCompilation(INuGetAssemblyResolver nugetResolve
{
private const string SkeletonDocumentPath = Compiler.CompileDiagnostics.SkeletonDiagnosticsPath;

/// <summary>
/// Document path of the generated <c>global using</c> scope. Like the skeleton it is framework
/// output the user cannot edit, so its own diagnostics are filtered out of the result.
/// </summary>
internal const string GlobalUsingsDocumentPath = Compiler.CompileDiagnostics.GlobalUsingsDiagnosticsPath;

public async Task<IReadOnlyList<DiagnosticInfo>> GetDiagnosticsAsync(
CompilationInputs inputs,
string sourcePath,
Expand All @@ -53,37 +59,51 @@ public async Task<IReadOnlyList<DiagnosticInfo>> GetDiagnosticsAsync(
}
}

var trees = new List<SyntaxTree>(inputs.Sources.Length + 1)
{
CSharpSyntaxTree.ParseText(
SourceText.From(inputs.SkeletonSource),
inputs.ParseOptions,
path: SkeletonDocumentPath),
};

// Resolve the EFFECTIVE source set first (proposed body substituted), because the import
// scope below is derived from it: a `using` the user just typed in Monaco has to count.
var effectiveSources = new List<(string Path, string Code)>(inputs.Sources.Length + 1);
var substituted = false;
foreach (var (path, code) in inputs.Sources)
{
string treeCode;
if (string.Equals(path, sourcePath, StringComparison.OrdinalIgnoreCase))
{
treeCode = cleanedProposed;
effectiveSources.Add((path, cleanedProposed));
substituted = true;
}
else
{
treeCode = code;
effectiveSources.Add((path, code));
}

trees.Add(CSharpSyntaxTree.ParseText(
SourceText.From(treeCode), inputs.ParseOptions, path: path));
}

if (!substituted)
{
// Proposed source path doesn't match any existing source — treat as a new file.
effectiveSources.Add((sourcePath, cleanedProposed));
}

var trees = new List<SyntaxTree>(effectiveSources.Count + 2)
{
CSharpSyntaxTree.ParseText(
SourceText.From(inputs.SkeletonSource),
inputs.ParseOptions,
path: SkeletonDocumentPath),
// 🚨 Not optional. Each source below is its OWN tree (so a diagnostic carries the
// MeshNode path the user edits), and a C# `using` is FILE-SCOPED — so without this
// document the skeleton's imports cover nothing and every source that relies on them
// reports phantom CS0246/CS0308 while the emit path compiles it cleanly (#1802).
// Recomputed here, not taken from `inputs`, so the proposed body's own usings apply.
CSharpSyntaxTree.ParseText(
SourceText.From(new DynamicMeshNodeAttributeGenerator()
.GenerateGlobalUsingsSource(effectiveSources.Select(s => (string?)s.Code))),
inputs.ParseOptions,
path: GlobalUsingsDocumentPath),
};

foreach (var (path, code) in effectiveSources)
{
trees.Add(CSharpSyntaxTree.ParseText(
SourceText.From(cleanedProposed), inputs.ParseOptions, path: sourcePath));
SourceText.From(code), inputs.ParseOptions, path: path));
}

var compilation = CSharpCompilation.Create(
Expand All @@ -98,8 +118,11 @@ public async Task<IReadOnlyList<DiagnosticInfo>> GetDiagnosticsAsync(
var result = new List<DiagnosticInfo>(diags.Length);
foreach (var d in diags)
{
// Skeleton-tree diagnostics are framework noise — the user can't act on them.
if (d.Location.SourceTree?.FilePath == SkeletonDocumentPath) continue;
// Skeleton- and global-usings-tree diagnostics are framework noise — the user can't
// act on them. (A CS8019 "unnecessary using" on the generated scope is expected: it
// imports for the whole compilation, so most files use only part of it.)
var treePath = d.Location.SourceTree?.FilePath;
if (treePath == SkeletonDocumentPath || treePath == GlobalUsingsDocumentPath) continue;
result.Add(ToDiagnosticInfo(d));
}
return result;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
Name: The editor stops reporting errors that are not there
Category: Fix
Description: Code diagnostics now use the same import scope the real compiler does, so a NodeType source that builds and ships is no longer flagged with errors it does not have.
Icon: CheckmarkCircle
Order: -20260817
---

# The editor stops reporting errors that are not there

Opening a NodeType source could show a row of red errors — `The type or namespace name
'IMeshService' could not be found`, `The non-generic method 'IServiceProvider.GetService(Type)'
cannot be used with type arguments` — on code that compiles perfectly and is already running in
production. The compile status for the very same NodeType said *"the assembly was built without
errors and is loaded"*. Both came from the platform, in the same minute, and they disagreed.

The editor was wrong. When a NodeType is built for real, all of its source files are combined into
one file that begins with the framework's standard imports, so every file can use `IMeshService`,
`Controls`, `GetService<T>` and the rest without importing them itself. The diagnostics service
instead looked at each file on its own — which is what lets an error point at the exact file and
line you are editing — and in C# an import only covers the file it is written in. So the standard
imports covered nothing, and every source that relied on them was reported broken.

Diagnostics now build that same import scope explicitly, including the imports declared in
neighbouring files, which the real compiler also shares out. Errors keep pointing at the precise
file and line as before; there are simply no longer any phantom ones. If you have been adding
`using` lines to silence errors the build never had, they were never needed.

The lesson we took from it: a compile verdict is only trustworthy when the thing reporting it is
the thing that compiles. The diagnostics path now derives its imports from the same declaration the
build uses, so the two cannot drift apart again.
Original file line number Diff line number Diff line change
Expand Up @@ -1398,6 +1398,13 @@ private IObservable<Unit> ValidateCellSurfaceSingleHome(
AssemblyName: $"DynamicNode_{nodeName}",
Sources: sourcesArray,
SkeletonSource: skeleton,
// The skeleton above is generated with codeFile:null, so its `using`s are
// file-scoped to a tree that holds no user code. Under the per-file trees the
// language service needs for Monaco positions that leaves every source without the
// framework imports the emit path gives it by concatenation — the #1802 false FAIL.
// Render the same scope as `global using` so both paths agree.
GlobalUsingsSource: _attributeGenerator.GenerateGlobalUsingsSource(
strippedSources.Select(s => (string?)s.Code)),
References: references,
ParseOptions: parseOptions,
CompilationOptions: compilationOptions,
Expand Down
Loading
Loading