Skip to content

feat: Add Roslyn analyzers for HTML validation (ABIES001-005)#90

Merged
MCGPPeters merged 1 commit into
mainfrom
feat/typed-html-dsl-86
Feb 19, 2026
Merged

feat: Add Roslyn analyzers for HTML validation (ABIES001-005)#90
MCGPPeters merged 1 commit into
mainfrom
feat/typed-html-dsl-86

Conversation

@MCGPPeters
Copy link
Copy Markdown
Contributor

@MCGPPeters MCGPPeters commented Feb 17, 2026

📝 Description

What

Add Roslyn analyzers that validate HTML correctness at compile time for the Abies HTML DSL.

Why

The Abies HTML DSL is stringly typed — all elements return Node, all attributes return DOM.Attribute, and all values are string. This means the type system allows illegal HTML states that compile but produce invalid markup (e.g., img() without alt, div nested inside span).

A full type-safe HTML DSL prototype was built (~9,300 lines, 90+ files) but was rejected due to massive API surface explosion, broken composability, and high migration cost. Roslyn analyzers achieve the same compile-time validation with zero breaking changes. See ADR-021 for the full decision record.

How

Two analyzer classes inspect InvocationExpressionSyntax nodes via the Roslyn semantic model:

  • MissingAttributeAnalyzer — checks for required/recommended attributes on element calls
  • ContentModelAnalyzer — detects flow content nested inside phrasing-only parents

The analyzer DLL is bundled into the Abies NuGet package at analyzers/dotnet/cs/, so all consumers get validation automatically.

🔗 Related Issues

Resolves #86

✅ Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 📚 Documentation update
  • ✅ Test update

🧪 Testing

Test Coverage

  • Unit tests added/updated
  • Integration tests added/updated
  • E2E tests added/updated
  • Manual testing performed

Testing Details

  • 17 analyzer unit tests covering all 5 diagnostic rules (happy paths + edge cases)
  • Verified ABIES001 fires on real Conduit codebase (6 violations found and fixed)
  • Verified NuGet package structure (dotnet pack produces correct analyzers/dotnet/cs/ layout)
  • All 105 existing Abies.Tests pass
  • All 51 integration tests pass
  • All 17 analyzer tests pass

✨ Changes Made

  • Added Abies.Analyzers project (netstandard2.0, Roslyn 4.8.0) with 5 diagnostic rules:
    • ABIES001 (Warning): img() missing alt() attribute
    • ABIES002 (Warning): Flow content inside phrasing-only parents (e.g., div in span)
    • ABIES003 (Info): a() missing href() attribute
    • ABIES004 (Info): button() missing type() attribute
    • ABIES005 (Info): input() missing type() attribute
  • Added Abies.Analyzers.Tests project with 17 tests
  • Wired analyzer into Abies.csproj for NuGet packaging (analyzers/dotnet/cs/ convention)
  • Added explicit analyzer ProjectReference to Abies.Conduit.csproj (needed for solution-level consumers)
  • Added both projects to Abies.sln
  • Fixed all 6 ABIES001 violations in the Conduit app (added alt attributes to profile/avatar images in Profile.cs, Article.cs, Home.cs)
  • Created ADR-021 documenting the decision to use analyzers over the typed DSL
  • Updated ADR index

🔍 Code Review Checklist

  • Code follows the project's style guidelines
  • Self-review of code performed
  • Comments added for complex/non-obvious code
  • Documentation updated (if needed)
  • No new warnings generated
  • Tests added/updated and passing
  • All commits follow Conventional Commits format
  • Branch is up-to-date with main
  • No merge conflicts

🚀 Deployment Notes

None — analyzers are compile-time only and have no runtime impact.

📋 Additional Context

MSBuild Discovery

OutputItemType="Analyzer" does not propagate transitively through ProjectReference chains. This means solution-level consumers (like Abies.Conduit) need an explicit ProjectReference to Abies.Analyzers, while NuGet package consumers get the analyzer automatically via the analyzers/dotnet/cs/ convention.

Architecture

Abies.Analyzers/                      # netstandard2.0, Roslyn 4.8.0
├── DiagnosticDescriptors.cs          # ABIES001–ABIES005 definitions
├── HtmlSpec.cs                       # HTML content model data
├── AnalysisHelpers.cs                # Shared semantic model utilities
├── MissingAttributeAnalyzer.cs       # ABIES001, ABIES003–ABIES005
└── ContentModelAnalyzer.cs           # ABIES002

Abies.Analyzers.Tests/                # net10.0, xUnit
├── AbiesStubs.cs                     # Minimal type stubs for testing
├── MissingAttributeAnalyzerTests.cs  # 12 tests
└── ContentModelAnalyzerTests.cs      # 5 tests

Copilot AI review requested due to automatic review settings February 17, 2026 13:52
Comment thread Abies.TypedHtml/Adapter.cs Fixed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a new Abies.TypedHtml project providing a type-safe HTML DSL (content-model/structural constraints + typed attribute enums) with an adapter to the runtime Abies.DOM tree, and updates various docs/presentation materials to reflect recent runtime/benchmarking changes and .NET 10-only targeting.

Changes:

  • Add Abies.TypedHtml (marker interfaces, element records, attribute helpers/enums, factory API, DOM adapter, README) and wire it into Abies.sln.
  • Refresh documentation around binary batching, keyed diffing (LIS/head-tail skip), benchmarks/ADRs, and .NET 10-only messaging.
  • Update Abies.Presentation UI (menu + deck navigation) and associated styling.

Reviewed changes

Copilot reviewed 23 out of 24 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
docs/reference/virtual-dom-algorithm.md Updates patch list and describes binary batching + keyed diffing approach.
docs/index.md Adds ADR-018/019/020 to index listing.
docs/getting-started/templates.md Removes net9 guidance; documents --Framework net10.0 only.
docs/codeql-setup.md Updates language about .NET 10 support.
docs/benchmarks.md Major rewrite of benchmarking docs (currently appears malformed).
docs/adr/README.md Adds ADR-020 and note about duplicate ADR-005 numbering.
docs/adr/ADR-005-security-scanning-sast-dast-sca.md Updates wording to .NET 10-only.
SECURITY.md Updates NuGetAudit guidance to .NET 10+.
README.md Expands project structure list and updates performance/benchmarking section.
CONTRIBUTING.md Updates contribution license statement to Apache 2.0.
CHANGELOG.md Expands entries for benchmarks, binary batching, diffing improvements, and framework targeting.
Abies.sln Adds Abies.TypedHtml project to solution.
Abies.TypedHtml/README.md Documents the TypedHtml DSL, constraints, and adapter usage.
Abies.TypedHtml/Nodes.cs Adds core node/attribute types for TypedHtml.
Abies.TypedHtml/Html.cs Adds factory functions for constructing typed HTML trees.
Abies.TypedHtml/Elements.cs Defines sealed record types per HTML element with content-model typing.
Abies.TypedHtml/ContentCategories.cs Adds marker interfaces for content categories and structural constraints.
Abies.TypedHtml/Attributes.cs Adds global/ARIA/data-* attribute factory helpers.
Abies.TypedHtml/AttributeTypes.cs Adds enums + helpers for constrained attribute values.
Abies.TypedHtml/Adapter.cs Converts TypedHtml trees into Abies.DOM nodes/documents.
Abies.TypedHtml/Abies.TypedHtml.csproj New net10.0 project referencing Abies.
Abies.Presentation/wwwroot/site.css Adds styling for new menu/cards and form input elements.
Abies.Presentation/Program.cs Refactors presentation app to add a menu + two deck modes (Full/Express) and new view layout.
.github/E2E_TIMEOUT_HANDLING.md Removes a stale link from related documentation section.

Comment thread Abies.TypedHtml/Adapter.cs Outdated
Comment on lines +211 to +226
return new DOM.Element(id, tag, [new DOM.Attribute($"{id}:id", "id", id), .. domAttrs], domChildren);
}

/// <summary>Creates a void element (no children).</summary>
static DOM.Element Void(string id, string tag, HtmlAttribute[] attrs) =>
new(id, tag, [new DOM.Attribute($"{id}:id", "id", id), .. ConvertAttributes(id, attrs)]);

/// <summary>Converts HtmlAttribute[] to DOM.Attribute[].</summary>
static DOM.Attribute[] ConvertAttributes(string parentId, HtmlAttribute[] attrs)
{
var result = new DOM.Attribute[attrs.Length];
for (var i = 0; i < attrs.Length; i++)
{
var a = attrs[i];
result[i] = new DOM.Attribute($"{parentId}:{a.Name}", a.Name, a.Value);
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adapter currently injects an explicit DOM attribute named "id" (and doesn’t filter user-supplied "id" attributes). Since Render.RenderNode already writes id="{element.Id}" and JS patching looks up nodes via document.getElementById(element.Id), any user-provided Attr.id(...) (or duplicate id attrs) can desync the runtime ID from the actual DOM id and break patch application. Consider mirroring Abies.Html.Elements.element() behavior: treat an explicit "id" HtmlAttribute as the element Id and remove it from the attribute list (or disallow/strip "id" entirely), and avoid emitting a second "id" attribute in the Attributes array.

Suggested change
return new DOM.Element(id, tag, [new DOM.Attribute($"{id}:id", "id", id), .. domAttrs], domChildren);
}
/// <summary>Creates a void element (no children).</summary>
static DOM.Element Void(string id, string tag, HtmlAttribute[] attrs) =>
new(id, tag, [new DOM.Attribute($"{id}:id", "id", id), .. ConvertAttributes(id, attrs)]);
/// <summary>Converts HtmlAttribute[] to DOM.Attribute[].</summary>
static DOM.Attribute[] ConvertAttributes(string parentId, HtmlAttribute[] attrs)
{
var result = new DOM.Attribute[attrs.Length];
for (var i = 0; i < attrs.Length; i++)
{
var a = attrs[i];
result[i] = new DOM.Attribute($"{parentId}:{a.Name}", a.Name, a.Value);
}
// Do not inject an explicit "id" attribute here; the renderer is responsible
// for emitting id="{element.Id}" in the DOM based on the positional element Id.
return new DOM.Element(id, tag, domAttrs, domChildren);
}
/// <summary>Creates a void element (no children).</summary>
static DOM.Element Void(string id, string tag, HtmlAttribute[] attrs) =>
// As with Elem, we avoid emitting a synthetic "id" attribute here. The runtime
// positional element Id drives the DOM id attribute during rendering.
new(id, tag, ConvertAttributes(id, attrs));
/// <summary>Converts HtmlAttribute[] to DOM.Attribute[].</summary>
static DOM.Attribute[] ConvertAttributes(string parentId, HtmlAttribute[] attrs)
{
// We intentionally skip any explicit "id" attributes here. Element identity and
// the corresponding DOM id attribute are owned by the runtime (Element.Id), and
// allowing user-specified "id" attributes would desynchronize patching, which
// relies on document.getElementById(Element.Id).
var count = 0;
for (var i = 0; i < attrs.Length; i++)
{
var attr = attrs[i];
if (attr.Name is "id")
{
continue;
}
count++;
}
if (count == 0)
{
return [];
}
var result = new DOM.Attribute[count];
var index = 0;
for (var i = 0; i < attrs.Length; i++)
{
var a = attrs[i];
if (a.Name is "id")
{
continue;
}
result[index++] = new DOM.Attribute($"{parentId}:{a.Name}", a.Name, a.Value);
}

Copilot uses AI. Check for mistakes.
Comment thread Abies.TypedHtml/Adapter.cs Outdated
Comment on lines +200 to +217
// ─── Element builders ───────────────────────────────────────────────

/// <summary>Creates a DOM element with child conversion.</summary>
static DOM.Element Elem(string id, string tag, HtmlAttribute[] attrs, HtmlNode[] children)
{
var domAttrs = ConvertAttributes(id, attrs);
var domChildren = new DOM.Node[children.Length];
for (var i = 0; i < children.Length; i++)
{
domChildren[i] = Convert(children[i], $"{id}-{i}");
}
return new DOM.Element(id, tag, [new DOM.Attribute($"{id}:id", "id", id), .. domAttrs], domChildren);
}

/// <summary>Creates a void element (no children).</summary>
static DOM.Element Void(string id, string tag, HtmlAttribute[] attrs) =>
new(id, tag, [new DOM.Attribute($"{id}:id", "id", id), .. ConvertAttributes(id, attrs)]);

Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No tests were added for the new TypedHtml adapter/conversion logic. Given this is a new public surface area and the adapter is responsible for generating DOM IDs/attributes correctly (a critical correctness path), it would be good to add unit tests covering at least: (1) explicit id handling, (2) required attributes merging (href/src/alt/type), and (3) void element children behavior via generated DOM shape.

Copilot generated this review using guidance from repository custom instructions.
Comment thread Abies.Presentation/Program.cs Outdated
Comment on lines +165 to +178
div([class_("presentation-cards")],
[
div([class_("presentation-card"), onclick(new Message.SelectPresentation(PresentationMode.Full))],
[
div([class_("card-icon")], [text("\U0001F332")]),
h2([], [text("Full Conference")]),
p([class_("card-desc")], [text("Deep-dive into every aspect of Abies — from core MVU concepts through virtual DOM internals, the binary batching protocol, E2E benchmarks, and production deployment.")]),
div([class_("card-meta")],
[
span([class_("pill")], [text($"{FullSlides.Length} slides")]),
span([class_("pill")], [text("\u2248 60 min")])
])
]),
div([class_("presentation-card"), onclick(new Message.SelectPresentation(PresentationMode.Express))],
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The menu cards are implemented as <div> with onclick(...), which are not keyboard-focusable by default and won’t be announced as interactive controls by screen readers. Prefer using a semantic <button>/<a> element, or add role="button" + tabindex="0" + key handlers to ensure keyboard accessibility.

Copilot uses AI. Check for mistakes.
Comment thread Abies.Presentation/Program.cs Outdated
[
div([class_("menu-header")],
[
img([class_("menu-logo"), src("abies-logo.png"), width("80"), height("80")]),
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Images are rendered without an alt attribute (e.g., the menu logo). For accessibility, provide meaningful alt text or alt="" if the image is purely decorative.

Suggested change
img([class_("menu-logo"), src("abies-logo.png"), width("80"), height("80")]),
img([class_("menu-logo"), src("abies-logo.png"), alt("Abies logo"), width("80"), height("80")]),

Copilot uses AI. Check for mistakes.
Comment thread Abies.Presentation/Program.cs Outdated
[
div([class_("brand")],
[
img([class_("brand-logo"), src("abies-logo.png"), alt("Abies logo")]),
img([class_("brand-logo"), src("abies-logo.png"), width("40"), height("40")]),
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The topbar logo image is missing an alt attribute. Add alt text (or alt="" if decorative) so screen readers don’t announce it as an unlabeled image.

Suggested change
img([class_("brand-logo"), src("abies-logo.png"), width("40"), height("40")]),
img([class_("brand-logo"), src("abies-logo.png"), alt("Abies logo"), width("40"), height("40")]),

Copilot uses AI. Check for mistakes.
Comment thread docs/benchmarks.md
Comment on lines +1 to +23
# Performance Benchmarks# Rendering Engine Benchmarks

This document describes the benchmarking infrastructure for the Abies rendering engine, including DOM diffing, HTML rendering, and event handler creation.

## Overview

The Abies framework uses a Virtual DOM diffing algorithm to compute minimal patches between UI states. Performance of this algorithm is critical because it runs on every UI update.
This document describes the benchmarking infrastructure for the Abies framework, covering both micro-benchmarks (BenchmarkDotNet) and end-to-end benchmarks (js-framework-benchmark).This document describes the benchmarking infrastructure for the Abies rendering engine, including DOM diffing, HTML rendering, and event handler creation.

## Benchmark Categories

The benchmark suite covers three categories:

## Benchmarking Strategy## Overview



Abies uses a **dual-layer benchmarking strategy**:The Abies framework uses a Virtual DOM diffing algorithm to compute minimal patches between UI states. Performance of this algorithm is critical because it runs on every UI update.



| Layer | Tool | Purpose | Trust Level |## Benchmark Categories

| --- | --- | --- | --- |

| **Primary (E2E)** | js-framework-benchmark | Real-world user-perceived performance | Source of truth |The benchmark suite covers three categories:

| **Secondary (Micro)** | BenchmarkDotNet | Algorithm comparison, allocation tracking | Development guidance |
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs/benchmarks.md appears to have been corrupted: headings and paragraphs are concatenated (e.g., # Performance Benchmarks# Rendering Engine Benchmarks, ## Benchmarking Strategy## Overview) and tables/code fences are interleaved with text. This will render incorrectly in Markdown; please restore proper newlines/spacing and reformat tables/code blocks so the document is readable.

Copilot uses AI. Check for mistakes.
@MCGPPeters MCGPPeters changed the title feat(TypedHtml): Type-safe HTML DSL — make illegal states unrepresentable feat(TypedHtml): Type-safe HTML + CSS DSL with Roslyn analyzers Feb 17, 2026
Comment thread Abies.TypedHtml/Css/Types.cs Fixed
Comment thread Abies.TypedHtml.Analyzers/AccessibilityAnalyzer.cs Fixed
Comment thread Abies.TypedHtml.Analyzers/ContentModelAnalyzer.cs Fixed
Comment thread Abies.TypedHtml.Analyzers/ContentModelAnalyzer.cs Fixed
@MCGPPeters MCGPPeters force-pushed the feat/typed-html-dsl-86 branch from 21e8b01 to 77e4a3b Compare February 18, 2026 21:50
@MCGPPeters MCGPPeters changed the title feat(TypedHtml): Type-safe HTML + CSS DSL with Roslyn analyzers feat: add Roslyn analyzers for HTML validation (ABIES001-005) Feb 18, 2026
Replace the abandoned type-safe HTML DSL approach with lightweight
Roslyn analyzers that validate HTML correctness at compile time while
keeping the existing stringly-typed DSL unchanged.

Analyzers:
- ABIES001: img() missing alt attribute (Warning)
- ABIES002: Flow content inside phrasing-only parents (Warning)
- ABIES003: a() missing href attribute (Info)
- ABIES004: button() missing type attribute (Info)
- ABIES005: input() missing type attribute (Info)

Distribution:
- Bundled in Abies NuGet package (analyzers/dotnet/cs/)
- Automatic for all PackageReference consumers
- Explicit ProjectReference needed for solution consumers

Includes:
- Abies.Analyzers project (netstandard2.0, Roslyn 4.8.0)
- Abies.Analyzers.Tests project (17 tests)
- ADR-021 documenting the decision

Resolves #86
@MCGPPeters MCGPPeters force-pushed the feat/typed-html-dsl-86 branch from 77e4a3b to 3f9f907 Compare February 19, 2026 13:42
]),
div([class_("card-footer")], [
img([class_("comment-author-img"), src(model.CurrentUser?.Image ?? "")]),
img([class_("comment-author-img"), src(model.CurrentUser?.Image ?? ""), alt($"{model.CurrentUser?.Username.Value ?? "User"} profile image")]),

Check warning

Code scanning / CodeQL

Constant condition Warning

Condition is always not null because of
... is ...
.

Copilot Autofix

AI 3 months ago

In general, to fix a "constant condition" complaint arising from a prior type/flow check, you push the knowledge from the earlier check into later code by removing redundant null-propagation or conditional checks in the guarded branch. Here, CommentForm already checks model.CurrentUser is null and only executes the form(...) branch when it is non-null. Inside that branch, model.CurrentUser is guaranteed non-null, so using ?. on it is unnecessary and causes the static analyzer to flag a constant condition. The best minimal fix is to replace model.CurrentUser?.Image ?? "" with a direct access model.CurrentUser.Image and simplify the username expression while still preserving the "User" fallback only for the Username.Value part, not for the entire user object. That maintains the current display semantics while removing the redundant null-check on model.CurrentUser.

Concretely, in Abies.Conduit/Page/Article.cs, within CommentForm(Model model), update line 238. Replace:

  • img([class_("comment-author-img"), src(model.CurrentUser?.Image ?? ""), alt($"{model.CurrentUser?.Username.Value ?? "User"} profile image")])

with:

  • img([class_("comment-author-img"), src(model.CurrentUser.Image), alt($"{model.CurrentUser.Username.Value ?? "User"} profile image")])

This uses the knowledge from the model.CurrentUser is null ? ... : ... ternary to directly access Image and Username while still allowing for Username.Value to be null and falling back to "User" in that case. No new methods or imports are required.

Suggested changeset 1
Abies.Conduit/Page/Article.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/Abies.Conduit/Page/Article.cs b/Abies.Conduit/Page/Article.cs
--- a/Abies.Conduit/Page/Article.cs
+++ b/Abies.Conduit/Page/Article.cs
@@ -235,7 +235,7 @@
                     ], [text(model.CommentInput)])
                 ]),
                 div([class_("card-footer")], [
-                    img([class_("comment-author-img"), src(model.CurrentUser?.Image ?? ""), alt($"{model.CurrentUser?.Username.Value ?? "User"} profile image")]),
+                    img([class_("comment-author-img"), src(model.CurrentUser.Image), alt($"{model.CurrentUser.Username.Value ?? "User"} profile image")]),
                     button([
                         class_("btn btn-sm btn-primary"),
                         type("submit"),
EOF
@@ -235,7 +235,7 @@
], [text(model.CommentInput)])
]),
div([class_("card-footer")], [
img([class_("comment-author-img"), src(model.CurrentUser?.Image ?? ""), alt($"{model.CurrentUser?.Username.Value ?? "User"} profile image")]),
img([class_("comment-author-img"), src(model.CurrentUser.Image), alt($"{model.CurrentUser.Username.Value ?? "User"} profile image")]),
button([
class_("btn btn-sm btn-primary"),
type("submit"),
Copilot is powered by AI and may make mistakes. Always verify output.
]),
div([class_("card-footer")], [
img([class_("comment-author-img"), src(model.CurrentUser?.Image ?? "")]),
img([class_("comment-author-img"), src(model.CurrentUser?.Image ?? ""), alt($"{model.CurrentUser?.Username.Value ?? "User"} profile image")]),

Check warning

Code scanning / CodeQL

Constant condition Warning

Condition is always not null because of
... is ...
.

Copilot Autofix

AI 3 months ago

In general, to fix a constant-condition/nullability style issue like this, remove redundant null-conditional operations or checks where control flow already guarantees non-nullness, or refactor the code so that the checks accurately reflect the possible states. This keeps the code clearer and avoids confusing both humans and static analyzers.

For this specific case in Abies.Conduit/Page/Article.cs, CommentForm uses a conditional expression: if model.CurrentUser is null, it returns a sign-in prompt; otherwise it returns the comment form. Therefore, in the form branch, model.CurrentUser is guaranteed not to be null. The attributes at line 238 and the surrounding expression currently use model.CurrentUser?.Image and model.CurrentUser?.Username.Value ?? "User", which implies that model.CurrentUser might still be null. The best fix is to remove the unnecessary null-conditional operator and the part of the null-coalescing that protects against a null CurrentUser, while still keeping any needed defaulting for inner nullable data (if any).

Concretely:

  • On line 238, replace model.CurrentUser?.Image ?? "" with model.CurrentUser.Image ?? "" (the inner Image might still be null, so the ?? "" is still reasonable).
  • Replace $"{model.CurrentUser?.Username.Value ?? "User"} profile image" with $"{model.CurrentUser.Username.Value} profile image", because model.CurrentUser and model.CurrentUser.Username/.Value are assumed non-null in this branch; if a default display name is desired, it should be handled earlier or with a separate, explicit fallback, but the current ternary guarantees the presence of a user for this path.

No new methods or imports are needed; the changes are local to the CommentForm method in Abies.Conduit/Page/Article.cs.


Suggested changeset 1
Abies.Conduit/Page/Article.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/Abies.Conduit/Page/Article.cs b/Abies.Conduit/Page/Article.cs
--- a/Abies.Conduit/Page/Article.cs
+++ b/Abies.Conduit/Page/Article.cs
@@ -235,7 +235,7 @@
                     ], [text(model.CommentInput)])
                 ]),
                 div([class_("card-footer")], [
-                    img([class_("comment-author-img"), src(model.CurrentUser?.Image ?? ""), alt($"{model.CurrentUser?.Username.Value ?? "User"} profile image")]),
+                    img([class_("comment-author-img"), src(model.CurrentUser.Image ?? ""), alt($"{model.CurrentUser.Username.Value} profile image")]),
                     button([
                         class_("btn btn-sm btn-primary"),
                         type("submit"),
EOF
@@ -235,7 +235,7 @@
], [text(model.CommentInput)])
]),
div([class_("card-footer")], [
img([class_("comment-author-img"), src(model.CurrentUser?.Image ?? ""), alt($"{model.CurrentUser?.Username.Value ?? "User"} profile image")]),
img([class_("comment-author-img"), src(model.CurrentUser.Image ?? ""), alt($"{model.CurrentUser.Username.Value} profile image")]),
button([
class_("btn btn-sm btn-primary"),
type("submit"),
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +76 to +83
foreach (var expr in attributeExpressions)
{
var attrName = GetAttributeName(expr, semanticModel);
if (attrName != null)
{
builder.Add(attrName);
}
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Select Note

This foreach loop immediately
maps its iteration variable to another variable
- consider mapping the sequence explicitly using '.Select(...)'.

Copilot Autofix

AI 3 months ago

Generally, to fix this issue you replace a loop where each iteration only serves to transform the iteration variable into another value with a LINQ pipeline that performs the transformation explicitly via Select (and Where for filtering if needed). This clarifies that the loop’s primary purpose is to iterate over the transformed sequence.

In this specific method, we can replace the foreach loop in GetAttributeNames with a LINQ pipeline that:

  1. Starts from attributeExpressions.
  2. Projects each expr to GetAttributeName(expr, semanticModel) via Select.
  3. Filters out null values via Where(attrName => attrName != null).
  4. Casts to non-nullable string and adds each to the ImmutableHashSet<string>.Builder.

There are two good options:

  • Fully LINQ: build the set directly from the sequence (e.g., attributeExpressions.Select(...).OfType<string>().ToImmutableHashSet()).
  • Minimal change: keep the existing builder and just change the loop to iterate over the transformed sequence.

To minimize functional changes and avoid assumptions about builder semantics, we’ll keep the builder and only change the loop to:

foreach (var attrName in attributeExpressions
    .Select(expr => GetAttributeName(expr, semanticModel))
    .Where(attrName => attrName != null))
{
    builder.Add(attrName);
}

This preserves the single iteration over attributeExpressions and the same null filtering behavior as the original if (attrName != null).

No new methods or types are required, and no additional using directives are strictly necessary because extension methods like Select and Where are likely already available in the compilation; however, if not present elsewhere, the file would need using System.Linq;. Since we must not change imports unless necessary and haven’t been shown them beyond the snippet, we will not modify the imports here.

The only code change is within Abies.Analyzers/AnalysisHelpers.cs, inside GetAttributeNames, lines 76–83.


Suggested changeset 1
Abies.Analyzers/AnalysisHelpers.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/Abies.Analyzers/AnalysisHelpers.cs b/Abies.Analyzers/AnalysisHelpers.cs
--- a/Abies.Analyzers/AnalysisHelpers.cs
+++ b/Abies.Analyzers/AnalysisHelpers.cs
@@ -73,15 +73,15 @@
         var firstArg = args[0].Expression;
         var attributeExpressions = GetCollectionElements(firstArg);
 
-        foreach (var expr in attributeExpressions)
+        foreach (var attrName in attributeExpressions
+            .Select(expr => GetAttributeName(expr, semanticModel))
+            .Where(attrName => attrName != null))
         {
-            var attrName = GetAttributeName(expr, semanticModel);
-            if (attrName != null)
-            {
-                builder.Add(attrName);
-            }
+            builder.Add(attrName!);
         }
 
+
+
         return builder.ToImmutable();
     }
 
EOF
@@ -73,15 +73,15 @@
var firstArg = args[0].Expression;
var attributeExpressions = GetCollectionElements(firstArg);

foreach (var expr in attributeExpressions)
foreach (var attrName in attributeExpressions
.Select(expr => GetAttributeName(expr, semanticModel))
.Where(attrName => attrName != null))
{
var attrName = GetAttributeName(expr, semanticModel);
if (attrName != null)
{
builder.Add(attrName);
}
builder.Add(attrName!);
}



return builder.ToImmutable();
}

Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +109 to +119
foreach (var expr in childExpressions)
{
if (expr is InvocationExpressionSyntax childInvocation)
{
var childName = GetElementName(childInvocation, semanticModel);
if (childName != null)
{
builder.Add((childName, childInvocation.GetLocation()));
}
}
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

Copilot Autofix

AI 3 months ago

In general, to fix this type of issue you replace a foreach loop that conditionally processes elements based on a type or predicate with a foreach over a sequence that has already been filtered via .Where(...). This moves the filtering logic into the sequence definition, improving readability and reducing conditional nesting in the loop body.

Here, the best fix is to apply a LINQ Where filter to childExpressions so that the loop iterates only over InvocationExpressionSyntax items. We can then safely remove the type check if (expr is InvocationExpressionSyntax childInvocation) and assume within the loop that expr is an InvocationExpressionSyntax. To avoid changing behavior, we should keep the null check on childName and the subsequent builder.Add(...) exactly as they are. We only need to adjust the loop header and the way we declare childInvocation. This change is fully contained within GetChildElementNames in Abies.Analyzers/AnalysisHelpers.cs, and does not require any new imports because System.Linq is not yet used in the shown snippet—so we will add using System.Linq; at the top of the file.

Concretely:

  • Add using System.Linq; alongside the other using directives.
  • Change the foreach (var expr in childExpressions) loop to foreach (var expr in childExpressions.Where(e => e is InvocationExpressionSyntax)).
  • Inside the loop, replace the pattern if (expr is InvocationExpressionSyntax childInvocation) with a direct cast var childInvocation = (InvocationExpressionSyntax)expr; (or equivalent), leaving the rest of the logic unchanged.
Suggested changeset 1
Abies.Analyzers/AnalysisHelpers.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/Abies.Analyzers/AnalysisHelpers.cs b/Abies.Analyzers/AnalysisHelpers.cs
--- a/Abies.Analyzers/AnalysisHelpers.cs
+++ b/Abies.Analyzers/AnalysisHelpers.cs
@@ -1,5 +1,6 @@
 using System.Collections.Generic;
 using System.Collections.Immutable;
+using System.Linq;
 using Microsoft.CodeAnalysis;
 using Microsoft.CodeAnalysis.CSharp;
 using Microsoft.CodeAnalysis.CSharp.Syntax;
@@ -106,15 +107,13 @@
         var secondArg = args[1].Expression;
         var childExpressions = GetCollectionElements(secondArg);
 
-        foreach (var expr in childExpressions)
+        foreach (var expr in childExpressions.Where(e => e is InvocationExpressionSyntax))
         {
-            if (expr is InvocationExpressionSyntax childInvocation)
+            var childInvocation = (InvocationExpressionSyntax)expr;
+            var childName = GetElementName(childInvocation, semanticModel);
+            if (childName != null)
             {
-                var childName = GetElementName(childInvocation, semanticModel);
-                if (childName != null)
-                {
-                    builder.Add((childName, childInvocation.GetLocation()));
-                }
+                builder.Add((childName, childInvocation.GetLocation()));
             }
         }
 
EOF
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
@@ -106,15 +107,13 @@
var secondArg = args[1].Expression;
var childExpressions = GetCollectionElements(secondArg);

foreach (var expr in childExpressions)
foreach (var expr in childExpressions.Where(e => e is InvocationExpressionSyntax))
{
if (expr is InvocationExpressionSyntax childInvocation)
var childInvocation = (InvocationExpressionSyntax)expr;
var childName = GetElementName(childInvocation, semanticModel);
if (childName != null)
{
var childName = GetElementName(childInvocation, semanticModel);
if (childName != null)
{
builder.Add((childName, childInvocation.GetLocation()));
}
builder.Add((childName, childInvocation.GetLocation()));
}
}

Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +70 to +78
foreach (var req in requiredAttrs)
{
if (!presentAttrs.Contains(req.AttributeName) &&
_descriptorMap.TryGetValue(req.DiagnosticId, out var descriptor))
{
context.ReportDiagnostic(
Diagnostic.Create(descriptor, invocation.GetLocation()));
}
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

Copilot Autofix

AI 3 months ago

In general, the fix is to replace the foreach loops that perform an implicit filter via an if (with continue or by wrapping the body) with foreach loops that iterate over requiredAttrs.Where(...) / recommendedAttrs.Where(...), moving the filtering predicate into the Where call. This expresses the intent explicitly and removes the inner conditional that only exists to skip elements.

For MissingAttributeAnalyzer.cs, the best minimal change is:

  • Add using System.Linq; so that Where is available as an extension method.
  • Change the loop foreach (var req in requiredAttrs) into foreach (var req in requiredAttrs.Where(...)), where the predicate is the conjunction of the two existing conditions: the attribute is not present, and its diagnostic descriptor exists in _descriptorMap.
  • Inside the loop, we can then assume the conditions already hold, so we only need to call context.ReportDiagnostic(...) using the already-resolved descriptor. That means we change the loop variable type to a tuple-like pattern (var req, var descriptor) by using LINQ’s Select to carry the descriptor through, or we keep the TryGetValue inside the loop but remove the presence check. To minimize logic changes, we can keep _descriptorMap.TryGetValue inside the Where call and project (req, descriptor) for use in the loop.
  • Apply the same transformation to the foreach (var rec in recommendedAttrs) loop.

Concretely, within AnalyzeInvocation, replace:

  • Lines 70–78 with a foreach over requiredAttrs.Where(...) that produces (req, descriptor) and calls ReportDiagnostic with descriptor.
  • Lines 86–94 similarly for recommendedAttrs.

No new methods or types are required; only System.Linq as an import.

Suggested changeset 1
Abies.Analyzers/MissingAttributeAnalyzer.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/Abies.Analyzers/MissingAttributeAnalyzer.cs b/Abies.Analyzers/MissingAttributeAnalyzer.cs
--- a/Abies.Analyzers/MissingAttributeAnalyzer.cs
+++ b/Abies.Analyzers/MissingAttributeAnalyzer.cs
@@ -3,6 +3,7 @@
 using Microsoft.CodeAnalysis.CSharp;
 using Microsoft.CodeAnalysis.CSharp.Syntax;
 using Microsoft.CodeAnalysis.Diagnostics;
+using System.Linq;
 
 namespace Abies.Analyzers;
 
@@ -67,14 +68,13 @@
         {
             var presentAttrs = AnalysisHelpers.GetAttributeNames(invocation, context.SemanticModel);
 
-            foreach (var req in requiredAttrs)
+            foreach (var (req, descriptor) in requiredAttrs
+                .Where(req => !presentAttrs.Contains(req.AttributeName) &&
+                              _descriptorMap.TryGetValue(req.DiagnosticId, out _))
+                .Select(req => (req, _descriptorMap[req.DiagnosticId])))
             {
-                if (!presentAttrs.Contains(req.AttributeName) &&
-                    _descriptorMap.TryGetValue(req.DiagnosticId, out var descriptor))
-                {
-                    context.ReportDiagnostic(
-                        Diagnostic.Create(descriptor, invocation.GetLocation()));
-                }
+                context.ReportDiagnostic(
+                    Diagnostic.Create(descriptor, invocation.GetLocation()));
             }
         }
 
@@ -83,14 +82,13 @@
         {
             var presentAttrs = AnalysisHelpers.GetAttributeNames(invocation, context.SemanticModel);
 
-            foreach (var rec in recommendedAttrs)
+            foreach (var (rec, descriptor) in recommendedAttrs
+                .Where(rec => !presentAttrs.Contains(rec.AttributeName) &&
+                              _descriptorMap.TryGetValue(rec.DiagnosticId, out _))
+                .Select(rec => (rec, _descriptorMap[rec.DiagnosticId])))
             {
-                if (!presentAttrs.Contains(rec.AttributeName) &&
-                    _descriptorMap.TryGetValue(rec.DiagnosticId, out var descriptor))
-                {
-                    context.ReportDiagnostic(
-                        Diagnostic.Create(descriptor, invocation.GetLocation()));
-                }
+                context.ReportDiagnostic(
+                    Diagnostic.Create(descriptor, invocation.GetLocation()));
             }
         }
     }
EOF
@@ -3,6 +3,7 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Linq;

namespace Abies.Analyzers;

@@ -67,14 +68,13 @@
{
var presentAttrs = AnalysisHelpers.GetAttributeNames(invocation, context.SemanticModel);

foreach (var req in requiredAttrs)
foreach (var (req, descriptor) in requiredAttrs
.Where(req => !presentAttrs.Contains(req.AttributeName) &&
_descriptorMap.TryGetValue(req.DiagnosticId, out _))
.Select(req => (req, _descriptorMap[req.DiagnosticId])))
{
if (!presentAttrs.Contains(req.AttributeName) &&
_descriptorMap.TryGetValue(req.DiagnosticId, out var descriptor))
{
context.ReportDiagnostic(
Diagnostic.Create(descriptor, invocation.GetLocation()));
}
context.ReportDiagnostic(
Diagnostic.Create(descriptor, invocation.GetLocation()));
}
}

@@ -83,14 +82,13 @@
{
var presentAttrs = AnalysisHelpers.GetAttributeNames(invocation, context.SemanticModel);

foreach (var rec in recommendedAttrs)
foreach (var (rec, descriptor) in recommendedAttrs
.Where(rec => !presentAttrs.Contains(rec.AttributeName) &&
_descriptorMap.TryGetValue(rec.DiagnosticId, out _))
.Select(rec => (rec, _descriptorMap[rec.DiagnosticId])))
{
if (!presentAttrs.Contains(rec.AttributeName) &&
_descriptorMap.TryGetValue(rec.DiagnosticId, out var descriptor))
{
context.ReportDiagnostic(
Diagnostic.Create(descriptor, invocation.GetLocation()));
}
context.ReportDiagnostic(
Diagnostic.Create(descriptor, invocation.GetLocation()));
}
}
}
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +86 to +94
foreach (var rec in recommendedAttrs)
{
if (!presentAttrs.Contains(rec.AttributeName) &&
_descriptorMap.TryGetValue(rec.DiagnosticId, out var descriptor))
{
context.ReportDiagnostic(
Diagnostic.Create(descriptor, invocation.GetLocation()));
}
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

Copilot Autofix

AI 3 months ago

In general, to fix this pattern you move the filtering logic out of the loop body into a LINQ .Where(...) call on the source sequence, so the foreach iterates only those elements that pass the predicate. This removes the initial if/continue pattern and makes the code’s intent explicit.

Here, we should update the foreach (var rec in recommendedAttrs) loop so it iterates only over those rec for which presentAttrs does not already contain rec.AttributeName. The _descriptorMap.TryGetValue part is not a pure filter on recommendedAttrs—it’s a lookup into another dictionary—so it should remain inside the loop body. Concretely, in Abies.Analyzers/MissingAttributeAnalyzer.cs, within the AnalyzeInvocation method, replace:

foreach (var rec in recommendedAttrs)
{
    if (!presentAttrs.Contains(rec.AttributeName) &&
        _descriptorMap.TryGetValue(rec.DiagnosticId, out var descriptor))
    {
        context.ReportDiagnostic(
            Diagnostic.Create(descriptor, invocation.GetLocation()));
    }
}

with:

foreach (var rec in recommendedAttrs.Where(rec => !presentAttrs.Contains(rec.AttributeName)))
{
    if (_descriptorMap.TryGetValue(rec.DiagnosticId, out var descriptor))
    {
        context.ReportDiagnostic(
            Diagnostic.Create(descriptor, invocation.GetLocation()));
    }
}

This preserves behavior: we still report only when the attribute is missing and the diagnostic descriptor exists, but the loop now explicitly filters on the missing-attribute condition. No new methods or imports are needed; System.Linq must already be available for other LINQ uses, and if not, it is a standard BCL namespace we can safely import.

Suggested changeset 1
Abies.Analyzers/MissingAttributeAnalyzer.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/Abies.Analyzers/MissingAttributeAnalyzer.cs b/Abies.Analyzers/MissingAttributeAnalyzer.cs
--- a/Abies.Analyzers/MissingAttributeAnalyzer.cs
+++ b/Abies.Analyzers/MissingAttributeAnalyzer.cs
@@ -83,10 +83,9 @@
         {
             var presentAttrs = AnalysisHelpers.GetAttributeNames(invocation, context.SemanticModel);
 
-            foreach (var rec in recommendedAttrs)
+            foreach (var rec in recommendedAttrs.Where(rec => !presentAttrs.Contains(rec.AttributeName)))
             {
-                if (!presentAttrs.Contains(rec.AttributeName) &&
-                    _descriptorMap.TryGetValue(rec.DiagnosticId, out var descriptor))
+                if (_descriptorMap.TryGetValue(rec.DiagnosticId, out var descriptor))
                 {
                     context.ReportDiagnostic(
                         Diagnostic.Create(descriptor, invocation.GetLocation()));
EOF
@@ -83,10 +83,9 @@
{
var presentAttrs = AnalysisHelpers.GetAttributeNames(invocation, context.SemanticModel);

foreach (var rec in recommendedAttrs)
foreach (var rec in recommendedAttrs.Where(rec => !presentAttrs.Contains(rec.AttributeName)))
{
if (!presentAttrs.Contains(rec.AttributeName) &&
_descriptorMap.TryGetValue(rec.DiagnosticId, out var descriptor))
if (_descriptorMap.TryGetValue(rec.DiagnosticId, out var descriptor))
{
context.ReportDiagnostic(
Diagnostic.Create(descriptor, invocation.GetLocation()));
Copilot is powered by AI and may make mistakes. Always verify output.
@MCGPPeters MCGPPeters changed the title feat: add Roslyn analyzers for HTML validation (ABIES001-005) feat: Add Roslyn analyzers for HTML validation (ABIES001-005) Feb 19, 2026
@MCGPPeters MCGPPeters merged commit 566e64b into main Feb 19, 2026
22 of 23 checks passed
@MCGPPeters MCGPPeters deleted the feat/typed-html-dsl-86 branch February 19, 2026 13:57
MCGPPeters added a commit that referenced this pull request Apr 13, 2026
Replace the abandoned type-safe HTML DSL approach with lightweight
Roslyn analyzers that validate HTML correctness at compile time while
keeping the existing stringly-typed DSL unchanged.

Analyzers:
- ABIES001: img() missing alt attribute (Warning)
- ABIES002: Flow content inside phrasing-only parents (Warning)
- ABIES003: a() missing href attribute (Info)
- ABIES004: button() missing type attribute (Info)
- ABIES005: input() missing type attribute (Info)

Distribution:
- Bundled in Abies NuGet package (analyzers/dotnet/cs/)
- Automatic for all PackageReference consumers
- Explicit ProjectReference needed for solution consumers

Includes:
- Abies.Analyzers project (netstandard2.0, Roslyn 4.8.0)
- Abies.Analyzers.Tests project (17 tests)
- ADR-021 documenting the decision

Resolves #86
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Type-safe HTML DSL — make illegal states unrepresentable

3 participants