# SPDX-FileCopyrightText: 2025 SPDX contributors
# SPDX-FileType: SOURCE
# SPDX-License-Identifier: Apache-2.0
"""
Report generation functionality.
Some of the code here was originally in the BaseChecker class.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from .constants import (
SUPPORTED_COMPLIANCE_STANDARDS,
SUPPORTED_COMPLIANCE_STANDARDS_DESC,
)
if TYPE_CHECKING:
from spdx_tools.spdx.validation.validation_message import ValidationMessage
from .base_checker import BaseChecker
# pylint: disable=too-many-instance-attributes
[docs]
@dataclass
class ReportContext:
"""Context for generating conformance reports."""
sbom_spec: str = ""
compliance_standard: str = ""
compliant: bool = False
requirement_results: list[tuple[str, bool]] | None = None
components_without_info: list[tuple[str, list[tuple[str, str]]]] | None = None
validation_messages: list[ValidationMessage] | None = None
conformance_messages: list[ValidationMessage] | None = None
parsing_errors: list[str] | None = None
unknown_pointer_edges: dict[str, list[str]] | None = None
floating_component_ids: set[str] | None = None
components_evaluated: bool = True
_NOT_EVALUATED = (
"No components are reachable from the SBOM root element; "
"component requirements were not evaluated."
)
def _safe_attr(obj: object, name: str) -> str:
val = getattr(obj, name, None)
return str(val) if val not in (None, "") else "N/A"
[docs]
def print_validation_messages(
validation_messages: list[ValidationMessage], verbose: bool = False
) -> None:
"""Prints validation messages and optional context details.
Args:
validation_messages (list[ValidationMessage]): List of validation messages.
verbose (bool): If True, include detailed validation context.
Returns:
None
"""
print(get_validation_messages_text(validation_messages, verbose))
[docs]
def get_validation_messages_text(
validation_messages: list[ValidationMessage], verbose: bool = False
) -> str:
"""Generates validation messages and optional context details.
Args:
validation_messages (list[ValidationMessage]): List of validation messages.
verbose (bool): If True, include detailed validation context.
Returns:
str: Plain-text representation of the validation messages.
"""
report: list[str] = []
for msg in validation_messages:
if not msg.validation_message:
continue
report.append(msg.validation_message)
if verbose and getattr(msg, "context", None):
ctx = msg.context
report.append(f"- SPDX ID: {_safe_attr(ctx, 'spdx_id')}")
report.append(f"- Parent ID: {_safe_attr(ctx, 'parent_id')}")
report.append(f"- Element type: {_safe_attr(ctx, 'element_type')}")
report.append("")
return "\n".join(report)
[docs]
def get_validation_messages_html(
validation_messages: list[ValidationMessage], verbose: bool = False
) -> str:
"""Generates HTML for validation messages and context details.
Args:
validation_messages (list[ValidationMessage]): List of validation messages.
verbose (bool): If True, include detailed validation context.
Returns:
str: HTML representation of the validation messages.
"""
if not validation_messages:
return ""
html = "<ul class='conformance-val-list'>\n"
for msg in validation_messages:
if not getattr(msg, "validation_message", None):
continue
html += "<li>\n"
html += "<p class='conformance-val-msg-label'>Validation message:</p>\n"
html += f"<p class='conformance-val-msg'>{msg.validation_message}</p>\n"
if verbose and getattr(msg, "context", None):
ctx = msg.context
if ctx:
html += "<p class='conformance-val-ctx-label'>Validation context:</p>\n"
html += "<ul class='conformance-val-ctx'>\n"
html += f"<li>SPDX ID: {_safe_attr(ctx, 'spdx_id')}</li>\n"
html += f"<li>Parent ID: {_safe_attr(ctx, 'parent_id')}</li>\n"
html += f"<li>Element type: {_safe_attr(ctx, 'element_type')}</li>\n"
html += "</ul>\n"
html += "</li>\n"
html += "</ul>"
return html
[docs]
def get_validation_messages_json(
validation_messages: list[ValidationMessage],
) -> list[dict[str, str]]:
"""Generates JSON-serializable list for validation messages and context details.
Args:
validation_messages (list[ValidationMessage]): List of validation messages.
Returns:
list[dict[str, str]]: JSON-serializable representation of the validation messages.
"""
json_output: list[dict[str, str]] = []
for msg in validation_messages:
if not getattr(msg, "validation_message", None):
continue
val_msg = {"message": msg.validation_message}
if getattr(msg, "context", None):
ctx = msg.context
val_msg["spdxId"] = str(getattr(ctx, "spdx_id", ""))
val_msg["parentId"] = str(getattr(ctx, "parent_id", ""))
val_msg["elementType"] = str(getattr(ctx, "element_type", ""))
json_output.append(val_msg)
return json_output
def _generate_graph_text_report(rc: ReportContext) -> list[str]:
"""Generates a text report for graph structural issues."""
graph_issues_report: list[str] = []
if not rc.unknown_pointer_edges and not rc.floating_component_ids:
return graph_issues_report
graph_issues_report.append("\n" + "=" * 55)
graph_issues_report.append("Structural Graph Issues ".center(55))
graph_issues_report.append("=" * 55)
if rc.unknown_pointer_edges:
graph_issues_report.append("\n************* ERROR *************")
graph_issues_report.append(
"Unknown components detected! A relationship points to a missing element."
)
graph_issues_report.append(" -> Broken dependency linkages found:")
for source, targets in rc.unknown_pointer_edges.items():
for target in targets:
graph_issues_report.append(
f" * Component '{source}' links to missing element '{target}'"
)
if rc.floating_component_ids:
floating_ids = sorted(rc.floating_component_ids)
graph_issues_report.append("\n************ WARNING ************")
graph_issues_report.append(
f"Found {len(floating_ids)} disconnected 'floating' elements."
)
graph_issues_report.append(
" -> These elements are not attached to the primary software tree"
)
graph_issues_report.append(
" and were ignored during the compliance check.\n"
)
for spdx_id in floating_ids[:10]:
graph_issues_report.append(f" * {spdx_id}")
if len(floating_ids) > 10:
graph_issues_report.append(
f" * ... and {len(floating_ids) - 10} more items."
)
graph_issues_report.append("\n" + "-" * 55)
return graph_issues_report
def _requirements_text(rc: ReportContext) -> list[str]:
"""Requirement table and not-evaluated note, in plain text."""
lines: list[str] = []
if rc.requirement_results:
lines.append("Requirement | Status")
lines.append("-------------------------------------------------------")
for label, value in rc.requirement_results:
lines.append(f"{label:<46} | {value}")
lines.append("")
if not rc.components_evaluated:
lines.append(_NOT_EVALUATED + "\n")
return lines
def _requirements_html(rc: ReportContext) -> list[str]:
"""Requirement table and not-evaluated note, in HTML."""
lines: list[str] = []
if rc.requirement_results:
lines.append("<table class='conformance-res-tab'>")
lines.append("<thead><tr><th>Requirement</th><th>Conformant</th></tr></thead>")
lines.append("<tbody>")
for info_name, val in rc.requirement_results:
lines.append(
"<tr>"
"<td class='conformance-res-tab-r'>"
f"{info_name}</td>"
"<td class='conformance-res-tab-v'>"
f"{val}</td>"
"</tr>"
)
lines.append("</tbody>")
lines.append("</table>")
if not rc.components_evaluated:
lines.append(f"<p class='conformance-res-note'>{_NOT_EVALUATED}</p>")
return lines
[docs]
def report_text(
rc: ReportContext,
verbose: bool = False,
) -> str:
"""Generates element-by-element result table in plain-text.
Args:
rc (ReportContext): Information for generating the report.
verbose (bool): If True, include detailed validation messages.
Returns:
str: Plain-text representation of the results.
"""
report: list[str] = []
# Parsing error
if rc.parsing_errors:
report.append("The document couldn't be parsed; check couldn't be performed.\n")
if rc.parsing_errors:
report.append("The following parsing error(s) were raised:\n")
for error in rc.parsing_errors:
report.append(error)
return "\n".join(report)
# Unsupported compliance standard
if rc.compliance_standard not in SUPPORTED_COMPLIANCE_STANDARDS:
report.append(f"Unsupported compliance standard {rc.compliance_standard!r}")
return "\n".join(report)
# Compliance results
report.append(
f"{SUPPORTED_COMPLIANCE_STANDARDS_DESC[rc.compliance_standard]}"
" Conformance Results\n"
)
report.append(f"Conformant: {rc.compliant}\n")
report.extend(_requirements_text(rc))
if rc.validation_messages:
report.append(
"The document is not valid according to the SBOM "
f'specification ("{rc.sbom_spec}"). '
"The following violations were found:\n"
)
report.append(get_validation_messages_text(rc.validation_messages, verbose))
if rc.conformance_messages:
report.append("The following conformance issues were found:\n")
report.append(get_validation_messages_text(rc.conformance_messages, verbose))
# structural graph issues
graph_issues_report = _generate_graph_text_report(rc)
if graph_issues_report:
report.extend(graph_issues_report)
return "\n".join(report)
def _generate_graph_html_report(rc: ReportContext) -> list[str]:
"""Generates an HTML report for graph structural issues."""
graph_issues_report: list[str] = []
if not rc.unknown_pointer_edges and not rc.floating_component_ids:
return graph_issues_report
graph_issues_report.append("<div class='conformance-graph'>")
graph_issues_report.append(
"<h2 class='conformance-res-title'>Structural Graph Issues</h2>"
)
if rc.unknown_pointer_edges:
graph_issues_report.append(
"<p class='conformance-err-label'><strong>ERROR:</strong>"
" Unknown components detected! A relationship points to a missing element.</p>"
)
graph_issues_report.append("<ul class='conformance-err-list'>")
for source, targets in rc.unknown_pointer_edges.items():
for target in targets:
graph_issues_report.append(
f"<li>Component '<b>{source}</b>' links to missing element "
f"'<b>{target}</b>'</li>"
)
graph_issues_report.append("</ul>")
if rc.floating_component_ids:
floating_ids = sorted(rc.floating_component_ids)
graph_issues_report.append(
"<p class='conformance-msg-label' style='margin-top: 15px;'>"
f"<strong>WARNING:</strong> Found {len(floating_ids)} disconnected "
"'floating' elements ignored during compliance.</p>"
)
graph_issues_report.append("<ul class='conformance-msg-list'>")
for spdx_id in floating_ids[:10]:
graph_issues_report.append(f"<li>{spdx_id}</li>")
if len(floating_ids) > 10:
graph_issues_report.append(
f"<li>... and {len(floating_ids) - 10} more items.</li>"
)
graph_issues_report.append("</ul>")
graph_issues_report.append("</div>")
return graph_issues_report
[docs]
def report_html(
rc: ReportContext,
verbose: bool = False,
) -> str:
"""Generates element-by-element result table in HTML.
Args:
rc (ReportContext): Information for generating the report.
verbose (bool): If True, include detailed validation messages.
Returns:
str: HTML representation of the results.
"""
report: list[str] = []
# Parsing error
if rc.parsing_errors:
report.append("<div class='conformance-err'>")
report.append(
"<p class='conformance-err-label'>"
"The document couldn't be parsed; check couldn't be performed.<br />"
"The following parsing errors were raised:"
"</p>"
)
report.append("<ul class='conformance-err-list'>")
for err in rc.parsing_errors:
report.append(f"<li>{err}</li>")
report.append("</ul>")
report.append("</div>")
return "\n".join(report)
# Unsupported compliance standard
if rc.compliance_standard not in SUPPORTED_COMPLIANCE_STANDARDS:
report.append(
"<div class='conformance-err'>"
"<p class='conformance-err-label'>"
f"Unsupported compliance standard {rc.compliance_standard!r}"
"</p>"
"</div>"
)
return "\n".join(report)
# Compliance results
report.append("<div class='conformance-res'>")
report.append(
"<h2 class='conformance-res-title'>"
f"{SUPPORTED_COMPLIANCE_STANDARDS_DESC[rc.compliance_standard]}"
" Conformance Results</h2>"
)
report.append(f"<h3 class='conformance-res-status'>Conformant: {rc.compliant}</h3>")
report.extend(_requirements_html(rc))
report.append("</div>") # End of conformance-res
# Components without required information
if rc.components_without_info:
report.append("<div class='conformance-mis'>")
report.append(
"<p class='conformance-mis-label'>"
"Missing required information in these components:"
"</p>"
)
report.append("<ul class='conformance-mis-list'>")
for info_name, components in rc.components_without_info:
component_names = [
name if name not in (None, "") else id for name, id in components
]
report.append(
f"<li>{info_name} ({len(components)}): "
f"{', '.join(component_names)}</li>"
)
report.append("</ul>")
report.append("</div>")
# Validation messages
if rc.validation_messages:
report.append("<div class='conformance-val'>")
report.append(
"<p class='conformance-val-label'>"
"The document is not valid according to the SBOM specification"
f' ("{rc.sbom_spec}").<br />'
"The following violations were found:"
"</p>"
)
report.append(
get_validation_messages_html(rc.validation_messages, verbose=verbose)
)
report.append("</div>")
# Conformance messages
if rc.conformance_messages:
report.append("<div class='conformance-msg'>")
report.append(
"<p class='conformance-msg-label'>"
"The following conformance issues were found:"
"</p>"
)
report.append(
get_validation_messages_html(rc.conformance_messages, verbose=verbose)
)
report.append("</div>")
# structural graph issues
graph_issues_report = _generate_graph_html_report(rc)
if graph_issues_report:
report.extend(graph_issues_report)
return "\n".join(report)
[docs]
def report_json(checker_instance: "BaseChecker") -> dict[str, Any]:
"""Build the JSON output dictionary."""
result: dict[str, Any] = {
"isConformant": getattr(checker_instance, "compliant", False),
"isNtiaConformant": getattr(
checker_instance, "compliant", False
), # backward compatibility
"complianceStandard": getattr(checker_instance, "compliance_standard", ""),
"sbomSpec": getattr(checker_instance, "sbom_spec", ""),
"validationMessages": get_validation_messages_json(
checker_instance.validation_messages
),
"conformanceMessages": get_validation_messages_json(
checker_instance.conformance_messages
),
"parsingError": checker_instance.parsing_errors,
"sbomName": getattr(checker_instance, "sbom_name", ""),
"specVersionProvided": getattr(checker_instance, "doc_version", False),
"authorNameProvided": getattr(checker_instance, "doc_author", False),
"timestampProvided": getattr(checker_instance, "doc_timestamp", False),
"dependencyRelationshipsProvided": getattr(
checker_instance, "dependency_relationships", False
),
"totalNumberComponents": checker_instance.get_total_number_components(),
"componentsEvaluated": getattr(checker_instance, "components_evaluated", False),
"graphValidation": {
"unknownPointers": {
"hasUnknownPointers": getattr(
checker_instance, "has_unknown_pointers", False
),
"unknownPointerEdges": getattr(
checker_instance, "unknown_pointer_edges", {}
),
},
"floatingComponents": {
"floatingComponentCount": len(
getattr(checker_instance, "floating_component_ids", set())
),
"floatingComponentIds": list(
getattr(checker_instance, "floating_component_ids", set())
),
},
},
}
_groups = {
"componentNames": "components_without_names",
"componentVersions": "components_without_versions",
"componentIdentifiers": "components_without_identifiers",
"componentSuppliers": "components_without_suppliers",
"componentConcludedLicenses": "components_without_concluded_licenses",
"componentCopyrightTexts": "components_without_copyright_texts",
}
for key_, attr in _groups.items():
components_without_info = getattr(checker_instance, attr, [])
# components_without_info is a list[tuple[name, spdx_id]];
# prefer the human-readable name and fall back to SPDX ID.
nonconformant = [
(name if name not in (None, "") else spdx_id)
for name, spdx_id in components_without_info
]
result[key_] = {
"nonconformantComponents": nonconformant,
"allProvided": result["componentsEvaluated"] and not nonconformant,
}
return result