Source code for pyfcstm.diagnostics.inspect_render

"""Presentation renderers for inspect diagnostics.

This module converts :class:`pyfcstm.diagnostics.inspect.ModelInspect`
instances into human-readable or LLM-oriented text without changing the
structured inspect JSON contract. The renderers are intentionally small and
side-effect free so the CLI can offer multiple output formats while
:func:`pyfcstm.diagnostics.inspect_model` and
:meth:`pyfcstm.diagnostics.inspect.ModelInspect.to_json` remain the single
source of truth for machine-readable model data.

The module contains:

.. list-table:: Inspect presentation helpers
   :header-rows: 1

   * - Helper
     - Purpose
   * - :func:`render_inspect_human`
     - Render a checker-style terminal report for humans.
   * - :class:`HumanRenderOptions`
     - Carry human-renderer presentation toggles such as ANSI color.
   * - :func:`render_inspect_llm_json`
     - Render the stable compact JSON packet for LLM repair loops.
   * - :func:`render_inspect_llm_markdown`
     - Render the stable Markdown packet for prompt composition.
   * - :func:`inspect_output_suffix_warning`
     - Detect suspicious ``--output`` suffix and format combinations.

Example::

    >>> from pyfcstm.diagnostics.inspect_render import inspect_output_suffix_warning
    >>> inspect_output_suffix_warning("report.json", "human")
    "output file 'report.json' looks like JSON, but inspect format is 'human'. Use '--format json' if you intended machine-readable JSON output."
"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple

from .codes import CODE_REGISTRY
from ..utils.validate import ModelDiagnostic, Span

INSPECT_LLM_SCHEMA_VERSION = "pyfcstm.inspect.llm.v1"
_INSPECT_OUTPUT_FORMATS = ("human", "json", "llm-json", "llm-md")
_SEVERITY_ORDER = ("error", "warning", "info")
_SEVERITY_HUMAN_LABELS = {
    "error": "ERROR",
    "warning": "WARN",
    "info": "INFO",
    "ok": "OK",
}
_STATUS_BY_SEVERITY = {
    "error": "error",
    "warning": "warning",
    "info": "info",
}
_ANSI_STYLE_BY_SEVERITY = {
    "error": "1;31",
    "warning": "33",
    "info": "36",
    "ok": "32",
}
_ANSI_BOLD = "1"
_ANSI_RESET = "\033[0m"
_SOURCE_CONTEXT_RADIUS = 1
_GLOBAL_REPAIR_RULES = (
    "Make the smallest source edit that preserves the modeler's apparent intent.",
    "Use diagnostic source/provenance before choosing a fix; inspect-static warnings are not solver proofs.",
    "Do not mechanically stack all suggested actions when multiple diagnostics refer to the same region.",
    "Do not delete states or transitions unless the report explicitly says the element is unused and the design intent supports deletion.",
    "A repair should clear all error and warning diagnostics; info diagnostics may remain when the model intent supports them.",
    "If changing a guard into an event-only transition makes a variable declaration unused, remove that variable or keep a real guard-affecting data-flow path.",
)
_REPAIR_NOTES_BY_CODE = {
    "W_COMBO_DUPLICATE_EVENT": (
        "Repeated identical event terms are presence-based and are usually redundant.",
        "If there is no evidence that the explicit two-hop pseudo chain is intentional, reducing the duplicated term to one event is the smallest semantic repair.",
    ),
    "W_UNREFERENCED_VAR": (
        "A declaration-only variable may be speculative scaffolding.",
        "Remove it only when no guard, assignment, abstract action, or external integration intent needs it.",
    ),
    "W_GUARD_VARS_NEVER_CHANGE": (
        "This is a static dataflow warning, not a proof that the guard is satisfiable.",
        "Adding a write is useful only when the variable should genuinely evolve at runtime.",
    ),
    "W_UNWRITTEN_READ_VAR": (
        "This is a static dataflow warning about missing writes after initialization.",
        "Do not add a self-assignment or dummy update only to silence the warning.",
    ),
    "W_DEAD_GUARD": (
        "This is verify-backed: SMT proved the guard unsatisfiable under model constraints.",
        "Prefer correcting the contradictory guard over making the transition unconditional.",
        "If the same variable also has static dataflow warnings, the repair must address both the contradiction and the missing guard-affecting data flow.",
    ),
    "W_GUARD_TAUTOLOGY": (
        "This is verify-backed: SMT proved the guard always true under model constraints.",
        "A tautological guard may document intent, so preserve that intent when simplifying.",
    ),
    "W_TOPOLOGICAL_NOEXIT": (
        "This is verify-backed topology feedback, not an instruction to add an unconditional exit blindly.",
    ),
    "I_TOPOLOGICAL_NON_TERMINATING": (
        "Long-running control loops can be intentional; decide intent before adding progress edges.",
    ),
    "W_EFFECT_SELF_ASSIGN": (
        "A self-assignment is a no-op and does not model real progress.",
        "If the variable exists only for that no-op effect, remove the variable and no-op effect instead of inventing a write that still does not affect a guard.",
    ),
    "W_REDUNDANT_TRANSITION": (
        "Remove only the duplicate transition or merge duplicate no-op effects.",
        "Keep the state structure and one intentional transition unless the report proves the element itself is unused.",
    ),
}


[docs] @dataclass(frozen=True) class SourceExcerptLine: """One source line included in a diagnostic source window. :param line_number: One-based source line number. :type line_number: int :param text: Source line text without its trailing newline. :type text: str :param caret: Optional caret marker for the diagnostic anchor line. :type caret: Optional[str], optional Example:: >>> SourceExcerptLine(3, "state Idle;", None).text 'state Idle;' """ line_number: int text: str caret: Optional[str] = None
[docs] @dataclass(frozen=True) class SourceExcerpt: """Source excerpt window anchored by a diagnostic span. :param line_number: One-based line number for the diagnostic anchor. :type line_number: int :param text: Anchor source line text without its trailing newline. :type text: str :param caret: Caret marker aligned to the diagnostic span on the anchor line. :type caret: str :param context_lines: Source window around the anchor line. The window usually contains one line before and after the anchor when available. :type context_lines: Tuple[SourceExcerptLine, ...], optional Example:: >>> SourceExcerpt(2, "def int x = 0;", "^^^^").line_number 2 """ line_number: int text: str caret: str context_lines: Tuple[SourceExcerptLine, ...] = ()
[docs] @dataclass(frozen=True) class HumanRenderOptions: """Options controlling human inspect presentation details. The options intentionally describe terminal presentation only. They do not change :meth:`pyfcstm.diagnostics.inspect.ModelInspect.to_json` or any LLM renderer, which keeps machine-readable inspect output independent of ANSI styling decisions. :param color_enabled: Whether ANSI color should be emitted for the human renderer. Defaults to ``False`` so programmatic calls are plain ASCII unless the CLI explicitly enables color for an interactive terminal. :type color_enabled: bool, optional Example:: >>> HumanRenderOptions(color_enabled=True).color_enabled True """ color_enabled: bool = False
[docs] def inspect_output_suffix_warning( output_file: Optional[str], output_format: str ) -> Optional[str]: """Return a warning for suspicious output suffix and format pairs. The function never changes the requested format. It only reports cases where a filename extension strongly suggests that the user may have meant a different ``--format`` value. :param output_file: Output path supplied to the CLI, or ``None`` for stdout. :type output_file: Optional[str] :param output_format: Requested inspect output format. :type output_format: str :return: Human-readable warning text, or ``None`` when the suffix is not suspicious. :rtype: Optional[str] :raises ValueError: If ``output_format`` is not a supported format. Example:: >>> inspect_output_suffix_warning("report.json", "human") is not None True >>> inspect_output_suffix_warning("report.json", "json") is None True """ _validate_output_format(output_format) if output_file is None: return None suffixes = tuple(s.lower() for s in os.path.basename(output_file).split(".")[1:]) if not suffixes: return None last_suffix = suffixes[-1] if output_format == "human" and last_suffix in {"json", "md"}: return ( "output file {path!r} looks like {kind}, but inspect format is 'human'. " "Use '--format {suggested}' if you intended {purpose}." ).format( path=output_file, kind="JSON" if last_suffix == "json" else "Markdown", suggested="json" if last_suffix == "json" else "llm-md", purpose=( "machine-readable JSON output" if last_suffix == "json" else "Markdown output" ), ) if output_format in {"json", "llm-json"} and last_suffix == "md": return ( "output file {path!r} looks like Markdown, but inspect format is {fmt!r}. " "Use '--format llm-md' if you intended Markdown output." ).format(path=output_file, fmt=output_format) if output_format == "llm-md" and last_suffix == "json": return ( "output file {path!r} looks like JSON, but inspect format is 'llm-md'. " "Use '--format llm-json' or '--format json' if you intended JSON output." ).format(path=output_file) return None
[docs] def render_inspect_human( report: Any, source_text: Optional[str] = None, *, input_path: Optional[str] = None, options: Optional[HumanRenderOptions] = None, ) -> str: """Render an inspect report as checker-style human-readable text. :param report: Inspect report returned by :func:`pyfcstm.diagnostics.inspect_model`. :type report: pyfcstm.diagnostics.inspect.ModelInspect :param source_text: Optional FCSTM source text used to render source excerpts for diagnostics with spans. :type source_text: Optional[str], optional :param input_path: Optional path shown in the report heading and locations. :type input_path: Optional[str], optional :param options: Optional presentation controls such as ANSI color. :type options: pyfcstm.diagnostics.inspect_render.HumanRenderOptions, optional :return: Human-readable report ending with a newline. :rtype: str Example:: >>> from pyfcstm.model import load_state_machine_from_text >>> from pyfcstm.diagnostics import inspect_model >>> model = load_state_machine_from_text('state Root { state Idle; [*] -> Idle; }') >>> text = render_inspect_human(inspect_model(model), 'state Root { state Idle; [*] -> Idle; }') >>> '[WARN] FCSTM Inspect Report' in text True """ options = options or HumanRenderOptions() counts = _severity_counts(report.diagnostics) status = _status_from_counts(counts) lines: List[str] = [] label = _format_human_severity_label(status, options=options) heading = "FCSTM Inspect Report" if input_path: heading = f"{heading}: {input_path}" lines.append(f"{label} {heading}") lines.append("") lines.append("Summary") lines.append(f" status: {status}") lines.append(f" root: {report.root_state_path}") lines.append( " states: {total} total / {leaf} leaf".format( total=len(report.states), leaf=report.metrics.n_states_leaf, ) ) lines.append(f" transitions: {len(report.transitions)}") lines.append(f" variables: {report.metrics.n_variables}") lines.append( " diagnostics: {error} errors / {warning} warnings / {info} infos".format( error=counts["error"], warning=counts["warning"], info=counts["info"], ) ) lines.append("") if not report.diagnostics: lines.append("No diagnostics.") return "\n".join(lines) + "\n" for diagnostic in report.diagnostics: lines.extend( _render_human_diagnostic( diagnostic, source_text=source_text, input_path=input_path, options=options, ) ) lines.append("") return "\n".join(lines).rstrip() + "\n"
[docs] def render_inspect_llm_json( report: Any, source_text: Optional[str] = None, *, input_path: Optional[str] = None ) -> str: """Render an inspect report as a stable compact JSON packet for LLMs. The packet is marked with :data:`INSPECT_LLM_SCHEMA_VERSION`. This versioned contract is intended for downstream repair loops that need a compact, source-located, and provenance-aware diagnostic report. :param report: Inspect report returned by :func:`pyfcstm.diagnostics.inspect_model`. :type report: pyfcstm.diagnostics.inspect.ModelInspect :param source_text: Optional source text for diagnostic excerpts. :type source_text: Optional[str], optional :param input_path: Optional input path attached to the packet. :type input_path: Optional[str], optional :return: Pretty-printed JSON packet ending with a newline. :rtype: str Example:: >>> from pyfcstm.model import load_state_machine_from_text >>> from pyfcstm.diagnostics import inspect_model >>> model = load_state_machine_from_text('state Root { state Idle; [*] -> Idle; }') >>> text = render_inspect_llm_json(inspect_model(model), 'state Root { state Idle; [*] -> Idle; }') >>> INSPECT_LLM_SCHEMA_VERSION in text True """ packet = _llm_packet(report, source_text, input_path=input_path) return json.dumps(packet, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
[docs] def render_inspect_llm_markdown( report: Any, source_text: Optional[str] = None, *, input_path: Optional[str] = None ) -> str: """Render an inspect report as stable Markdown for LLM prompts. :param report: Inspect report returned by :func:`pyfcstm.diagnostics.inspect_model`. :type report: pyfcstm.diagnostics.inspect.ModelInspect :param source_text: Optional source text for diagnostic excerpts. :type source_text: Optional[str], optional :param input_path: Optional path shown in the heading and locations. :type input_path: Optional[str], optional :return: Markdown report ending with a newline. :rtype: str Example:: >>> from pyfcstm.model import load_state_machine_from_text >>> from pyfcstm.diagnostics import inspect_model >>> model = load_state_machine_from_text('state Root { state Idle; [*] -> Idle; }') >>> text = render_inspect_llm_markdown(inspect_model(model), 'state Root { state Idle; [*] -> Idle; }') >>> '# FCSTM Inspect Report' in text True """ counts = _severity_counts(report.diagnostics) lines = ["# FCSTM Inspect Report", ""] lines.append(f"- Schema: `{INSPECT_LLM_SCHEMA_VERSION}`") lines.append("- Schema status: `stable`") lines.append(f"- Status: `{_status_from_counts(counts)}`") if input_path: lines.append(f"- Input: `{input_path}`") lines.append( "- Diagnostics: {error} errors / {warning} warnings / {info} infos".format( error=counts["error"], warning=counts["warning"], info=counts["info"], ) ) lines.append("") lines.append("## Repair protocol") lines.append("") for rule in _GLOBAL_REPAIR_RULES: lines.append(f"- {rule}") lines.append("") if not report.diagnostics: lines.append("No diagnostics.") return "\n".join(lines) + "\n" for diagnostic in report.diagnostics: detail = _diagnostic_llm_dict(diagnostic, source_text, input_path=input_path) lines.append(f"## {diagnostic.code}") lines.append("") lines.append(f"- Severity: `{diagnostic.severity}`") if detail.get("location"): loc = detail["location"] location_text = f"line {loc['line']}, column {loc['column']}" if input_path: location_text = f"{input_path}:{loc['line']}:{loc['column']}" lines.append(f"- Location: `{location_text}`") lines.append(f"- Message: {diagnostic.message}") if detail.get("source"): lines.append(f"- Source: {detail['source']}") if detail.get("summary"): lines.append(f"- Why it matters: {detail['summary']}") if detail.get("source_excerpt"): excerpt = detail["source_excerpt"] context = excerpt.get("context", []) gutter_width = _markdown_gutter_width(context, excerpt) lines.append("- Source:") lines.append(" ```fcstm") for source_line in context: lines.append( _format_source_line( source_line["line"], gutter_width, source_line["text"], prefix=" ", ) ) if source_line.get("caret"): lines.append( " {space}| {caret}".format( space=" " * (gutter_width + 1), caret=source_line["caret"], ) ) lines.append(" ```") if detail["recommended_actions"]: lines.append("- Recommended actions:") for action in detail["recommended_actions"]: lines.append(f" - {_format_action_for_markdown(action)}") if detail["do_not"]: lines.append("- Do not:") for item in detail["do_not"]: lines.append(f" - {item}") if detail["repair_guidance"]: lines.append("- Repair notes:") for item in detail["repair_guidance"]: lines.append(f" - {item}") lines.append("") return "\n".join(lines).rstrip() + "\n"
def _validate_output_format(output_format: str) -> None: if output_format not in _INSPECT_OUTPUT_FORMATS: raise ValueError(f"unsupported inspect output format: {output_format!r}") def _severity_counts(diagnostics: Iterable[ModelDiagnostic]) -> Dict[str, int]: counts = {severity: 0 for severity in _SEVERITY_ORDER} for diagnostic in diagnostics: counts[diagnostic.severity] += 1 return counts def _status_from_counts(counts: Mapping[str, int]) -> str: for severity in _SEVERITY_ORDER: if counts.get(severity, 0): return _STATUS_BY_SEVERITY[severity] return "ok" def _format_human_severity_label(severity: str, *, options: HumanRenderOptions) -> str: text = "[{label}]".format(label=_SEVERITY_HUMAN_LABELS[severity]) return _style_text(text, _severity_style(severity), options=options) def _severity_style(severity: str) -> str: return _ANSI_STYLE_BY_SEVERITY[severity] def _style_text(text: str, style: str, *, options: HumanRenderOptions) -> str: if not options.color_enabled: return text return "\033[{style}m{text}{reset}".format( style=style, text=text, reset=_ANSI_RESET, ) def _render_human_diagnostic( diagnostic: ModelDiagnostic, *, source_text: Optional[str], input_path: Optional[str], options: HumanRenderOptions, ) -> List[str]: label = _format_human_severity_label(diagnostic.severity, options=options) code = _style_text(diagnostic.code, _ANSI_BOLD, options=options) lines = [f"{label} {code}", f" {diagnostic.message}"] if diagnostic.span is not None: lines.append(f" --> {_format_span(diagnostic.span, input_path=input_path)}") excerpt = _source_excerpt(source_text, diagnostic.span) if excerpt is not None: gutter_width = _source_gutter_width(excerpt) gutter = " " * (gutter_width + 2) + "|" lines.append(gutter) for source_line in excerpt.context_lines: lines.append( _format_source_line( source_line.line_number, gutter_width, source_line.text, prefix=" ", ) ) if source_line.caret is not None: caret = _style_text( source_line.caret, _severity_style(diagnostic.severity), options=options, ) lines.append(f"{gutter} {caret}") lines.append(gutter) spec = CODE_REGISTRY.get(diagnostic.code) lines.append(f" = source: {_diagnostic_source(spec)}") if spec is not None and spec.for_llm is not None: lines.append(f" = why: {spec.for_llm.summary}") if spec.for_llm.recommended_actions: for action in spec.for_llm.recommended_actions: lines.append(f" = fix: {_format_action_for_human(action)}") if spec.for_llm.do_not: for item in spec.for_llm.do_not: lines.append(f" = do-not: {item}") return lines def _llm_packet( report: Any, source_text: Optional[str], *, input_path: Optional[str] ) -> Dict[str, Any]: counts = _severity_counts(report.diagnostics) return { "schema_version": INSPECT_LLM_SCHEMA_VERSION, "schema_status": "stable", "status": _status_from_counts(counts), "input": input_path, "repair_protocol": { "goal": "repair the FCSTM model with the smallest semantic source change", "rules": list(_GLOBAL_REPAIR_RULES), }, "summary": { "errors": counts["error"], "warnings": counts["warning"], "infos": counts["info"], "root_state_path": report.root_state_path, "states": len(report.states), "leaf_states": report.metrics.n_states_leaf, "transitions": len(report.transitions), "variables": report.metrics.n_variables, }, "diagnostics": [ _diagnostic_llm_dict(diagnostic, source_text, input_path=input_path) for diagnostic in report.diagnostics ], } def _diagnostic_llm_dict( diagnostic: ModelDiagnostic, source_text: Optional[str], *, input_path: Optional[str], ) -> Dict[str, Any]: spec = CODE_REGISTRY.get(diagnostic.code) excerpt = _source_excerpt(source_text, diagnostic.span) return { "code": diagnostic.code, "severity": diagnostic.severity, "message": diagnostic.message, "location": _span_dict(diagnostic.span, input_path=input_path), "source_excerpt": _excerpt_dict(excerpt), "refs": _jsonable(diagnostic.refs), "source": _diagnostic_source(spec), "provenance": { "kind": _diagnostic_source(spec), "verify_required": _diagnostic_source(spec) == "verify-backed", }, "summary": spec.for_llm.summary if spec is not None and spec.for_llm is not None else None, "recommended_actions": ( [_jsonable(item) for item in spec.for_llm.recommended_actions] if spec is not None and spec.for_llm is not None else [] ), "do_not": ( list(spec.for_llm.do_not) if spec is not None and spec.for_llm is not None else [] ), "repair_guidance": _repair_guidance_for_code(diagnostic.code), } def _diagnostic_source(spec: Optional[Any]) -> str: if spec is None: return "unknown" if getattr(spec, "emit_tier", None) == "verify_pipeline": return "verify-backed" return "inspect-static" def _repair_guidance_for_code(code: str) -> List[str]: return list(_REPAIR_NOTES_BY_CODE.get(code, ())) def _source_excerpt( source_text: Optional[str], span: Optional[Span] ) -> Optional[SourceExcerpt]: if source_text is None or span is None: return None lines = source_text.splitlines() if span.line < 1 or span.line > len(lines): return None text = lines[span.line - 1] context_start = max(1, span.line - _SOURCE_CONTEXT_RADIUS) context_end = min(len(lines), span.line + _SOURCE_CONTEXT_RADIUS) start_column = max(span.column, 1) end_column = span.end_column if span.end_line in (None, span.line) else None if end_column is None or end_column <= start_column: end_column = start_column + 1 caret_len = max(1, end_column - start_column) caret = " " * (start_column - 1) + "^" * caret_len context_lines = [] for line_number in range(context_start, context_end + 1): context_lines.append( SourceExcerptLine( line_number, lines[line_number - 1], caret if line_number == span.line else None, ) ) return SourceExcerpt(span.line, text, caret, tuple(context_lines)) def _source_gutter_width(excerpt: SourceExcerpt) -> int: if not excerpt.context_lines: return len(str(excerpt.line_number)) return max(len(str(item.line_number)) for item in excerpt.context_lines) def _markdown_gutter_width( context: List[Dict[str, Any]], excerpt: Dict[str, Any] ) -> int: if context: return max(len(str(item["line"])) for item in context) return len(str(excerpt["line"])) def _format_source_line( line_number: int, gutter_width: int, text: str, *, prefix: str, ) -> str: base = "{prefix}{line:>{width}} |".format( prefix=prefix, line=line_number, width=gutter_width, ) if text: return f"{base} {text}" return base def _format_span(span: Span, *, input_path: Optional[str]) -> str: prefix = f"{input_path}:" if input_path else "" return f"{prefix}{span.line}:{span.column}" def _span_dict( span: Optional[Span], *, input_path: Optional[str] ) -> Optional[Dict[str, Any]]: if span is None: return None data: Dict[str, Any] = { "line": span.line, "column": span.column, } if input_path is not None: data["path"] = input_path if span.end_line is not None: data["end_line"] = span.end_line if span.end_column is not None: data["end_column"] = span.end_column return data def _excerpt_dict(excerpt: Optional[SourceExcerpt]) -> Optional[Dict[str, Any]]: if excerpt is None: return None context = [ { "line": item.line_number, "text": item.text, "caret": item.caret, "is_anchor": item.line_number == excerpt.line_number, } for item in excerpt.context_lines ] return { "line": excerpt.line_number, "text": excerpt.text, "caret": excerpt.caret, "context": context, } def _format_action_for_human(action: Mapping[str, Any]) -> str: pieces = [] for key in ("kind", "target", "rationale"): if key in action: pieces.append(f"{key}: {action[key]}") if pieces: return "; ".join(pieces) return json.dumps(_jsonable(action), ensure_ascii=False, sort_keys=True) def _format_action_for_markdown(action: Mapping[str, Any]) -> str: if "rationale" in action: prefix = [] if "kind" in action: prefix.append(f"`{action['kind']}`") if "target" in action: prefix.append(f"target `{action['target']}`") if prefix: return f"{' / '.join(prefix)}: {action['rationale']}" return str(action["rationale"]) return json.dumps(_jsonable(action), ensure_ascii=False, sort_keys=True) def _jsonable(value: Any) -> Any: if hasattr(value, "__dataclass_fields__"): return { name: _jsonable(getattr(value, name)) for name in value.__dataclass_fields__ } if isinstance(value, Mapping): return {str(k): _jsonable(v) for k, v in value.items()} if isinstance(value, tuple): return [_jsonable(item) for item in value] if isinstance(value, list): return [_jsonable(item) for item in value] return value