From a527b2653a15d0cc19481ee6ed09a358d95068e7 Mon Sep 17 00:00:00 2001 From: Lawrence Lane Date: Fri, 15 May 2026 21:34:25 -0400 Subject: [PATCH 1/7] feat: add structured undefined diagnostics --- src/kida/exceptions.py | 205 ++++++++++++++++++++++++++--- tests/test_diagnostics_contract.py | 47 ++++++- 2 files changed, 233 insertions(+), 19 deletions(-) diff --git a/src/kida/exceptions.py b/src/kida/exceptions.py index a63693b..9668d21 100644 --- a/src/kida/exceptions.py +++ b/src/kida/exceptions.py @@ -35,6 +35,7 @@ from dataclasses import dataclass from enum import Enum +from html import escape as html_escape from typing import Any, final @@ -295,6 +296,125 @@ def format(self) -> str: return "\n".join(parts) +@final +@dataclass(frozen=True, slots=True) +class DiagnosticLocation: + """Surface-neutral template location for exception renderers.""" + + template: str + line: int | None = None + column: int | None = None + filename: str | None = None + + def format(self) -> str: + """Return ``template[:line[:column]]`` without terminal styling.""" + location = self.template + if self.line: + location += f":{self.line}" + if self.column is not None: + location += f":{self.column}" + return location + + +@final +@dataclass(frozen=True, slots=True) +class DiagnosticFrame: + """Surface-neutral stack frame for template/component diagnostics.""" + + template: str + line: int + name: str | None = None + + def format(self) -> str: + """Return a plain frame label.""" + location = f"{self.template}:{self.line}" + if self.name: + return f"{location} -> {self.name}()" + return location + + +@final +@dataclass(frozen=True, slots=True) +class TemplateDiagnostic: + """Structured diagnostic data for downstream renderers. + + This payload intentionally stores plain strings and immutable tuples only. + HTML, markdown, and terminal escaping/styling belongs to the renderer that + consumes it. + """ + + code: str | None + title: str + message: str + kind: str + location: DiagnosticLocation + source_snippet: SourceSnippet | None = None + hints: tuple[str, ...] = () + docs_url: str | None = None + suggestion: str | None = None + template_stack: tuple[DiagnosticFrame, ...] = () + component_stack: tuple[DiagnosticFrame, ...] = () + metadata: tuple[tuple[str, str], ...] = () + + def metadata_dict(self) -> dict[str, str]: + """Return metadata as a regular dict for consumers that prefer mapping APIs.""" + return dict(self.metadata) + + def format_html_fragment(self) -> str: + """Render a dependency-free escaped HTML diagnostic fragment. + + This is intentionally compact and unstyled so framework debug pages can + wrap it in their own chrome without inheriting terminal formatting. + """ + + code = ( + f'{html_escape(self.code)} ' if self.code else "" + ) + parts = [ + '
', + f"

{code}{html_escape(self.title)}

", + f'

{html_escape(self.message)}

', + '
', + f"
Location
{html_escape(self.location.format())}
", + ] + if self.suggestion: + parts.append(f"
Suggestion
{html_escape(self.suggestion)}
") + parts.append("
") + + if self.source_snippet: + parts.append('
')
+            for lineno, content in self.source_snippet.lines:
+                marker = ">" if lineno == self.source_snippet.error_line else " "
+                parts.append(f"{marker}{lineno:4} | {html_escape(content)}\n")
+            if self.source_snippet.column is not None:
+                caret = " " * self.source_snippet.column + "^"
+                parts.append(f"     | {html_escape(caret)}\n")
+            parts.append("
") + + if self.hints: + parts.append('
    ') + parts.extend(f"
  1. {html_escape(hint)}
  2. " for hint in self.hints) + parts.append("
") + + if self.component_stack: + parts.append("

Component stack

    ") + parts.extend( + f"
  1. {html_escape(frame.format())}
  2. " for frame in self.component_stack + ) + parts.append("
") + if self.template_stack: + parts.append("

Template stack

    ") + parts.extend(f"
  1. {html_escape(frame.format())}
  2. " for frame in self.template_stack) + parts.append("
") + if self.docs_url: + parts.append( + f'

' + "Documentation

" + ) + parts.append("
") + return "".join(parts) + + def build_source_snippet( source: str, error_line: int, @@ -743,18 +863,31 @@ def __init__( self.name = name self.template = template or "