11import json
22import html as html_module
3+ import importlib .metadata
4+
35from sarif_om import (
46 SarifLog ,
57 Tool ,
68 ToolComponent ,
79 Run ,
810 ReportingDescriptor ,
11+ ReportingConfiguration ,
912 MultiformatMessageString ,
1013 Result ,
1114 ArtifactLocation ,
1518 Message ,
1619)
1720
21+
1822# Maps internal severity levels to SARIF-compliant level strings.
1923_SEVERITY_TO_SARIF_LEVEL = {
2024 "CRITICAL" : "error" ,
21- "HIGH" : "error" ,
22- "MEDIUM" : "warning" ,
23- "LOW" : "note" ,
25+ "HIGH" : "error" ,
26+ "MEDIUM" : "warning" ,
27+ "LOW" : "note" ,
2428}
2529
26- _PYSPECTOR_VERSION = "1.0.0"
30+
31+ def _get_version ():
32+ """Return installed PySpector version dynamically."""
33+ try :
34+ return importlib .metadata .version ("pyspector" )
35+ except importlib .metadata .PackageNotFoundError :
36+ return "dev"
37+
38+
39+ _PYSPECTOR_VERSION = _get_version ()
40+
41+
42+ def _severity_key (issue ) -> str :
43+ """Normalize enum-like severity values."""
44+ return str (issue .severity ).split ("." )[- 1 ].upper ()
45+
2746
2847def _clean (obj ):
29- """
30- Recursively serialize a sarif_om object to a plain dict,
31- dropping any key whose value is None so the output stays lean.
32- sarif_om objects expose their data via __dict__; we walk that
33- structure and strip falsy-None leaves.
34- """
48+
3549 if isinstance (obj , list ):
3650 return [_clean (item ) for item in obj ]
51+
52+ if isinstance (obj , dict ):
53+ return {
54+ k : _clean (v )
55+ for k , v in obj .items ()
56+ if v is not None
57+ }
58+
3759 if hasattr (obj , "__dict__" ):
3860 return {
3961 k : _clean (v )
4062 for k , v in obj .__dict__ .items ()
4163 if v is not None
4264 }
65+
4366 return obj
4467
4568
@@ -57,9 +80,6 @@ def generate(self) -> str:
5780 return self .to_html ()
5881 return self .to_console ()
5982
60- # ------------------------------------------------------------------ #
61- # Console #
62- # ------------------------------------------------------------------ #
6383
6484 def to_console (self ) -> str :
6585 if not self .issues :
@@ -70,7 +90,7 @@ def to_console(self) -> str:
7090
7191 issues_by_severity : dict [str , list ] = {}
7292 for issue in self .issues :
73- severity = str (issue . severity ). split ( "." )[ - 1 ]. upper ( )
93+ severity = _severity_key (issue )
7494 issues_by_severity .setdefault (severity , []).append (issue )
7595
7696 for severity in severity_order :
@@ -81,6 +101,7 @@ def to_console(self) -> str:
81101 issues_by_severity [severity ],
82102 key = lambda i : (i .file_path , i .line_number ),
83103 )
104+
84105 output .append (f"\n { '=' * 60 } " )
85106 output .append (
86107 f" { severity } ({ len (sorted_issues )} issue{ 's' if len (sorted_issues ) != 1 else '' } )"
@@ -98,7 +119,7 @@ def to_console(self) -> str:
98119 return "\n " .join (output )
99120
100121 # ------------------------------------------------------------------ #
101- # JSON #
122+ # JSON #
102123 # ------------------------------------------------------------------ #
103124
104125 def to_json (self ) -> str :
@@ -111,55 +132,37 @@ def to_json(self) -> str:
111132 "file_path" : issue .file_path ,
112133 "line_number" : issue .line_number ,
113134 "code" : issue .code ,
114- "severity" : str (issue . severity ). split ( "." )[ - 1 ] ,
135+ "severity" : _severity_key (issue ) ,
115136 "remediation" : issue .remediation ,
116137 }
117138 for issue in self .issues
118139 ],
119140 }
141+
120142 return json .dumps (report , indent = 2 )
121143
122144 # ------------------------------------------------------------------ #
123- # SARIF #
145+ # SARIF #
124146 # ------------------------------------------------------------------ #
125147
126148 def to_sarif (self ) -> str :
127- """
128- Produces a SARIF 2.1.0 document.
129-
130- Improvements over the previous implementation:
131- - Uses ToolComponent (correct type for Tool.driver).
132- - Builds a deduplicated, ordered rule list and references rules by
133- index in each Result (rule_index), which is required for tooling
134- that doesn't index rules by ID alone.
135- - Maps internal severity levels to the SARIF `level` field
136- (error / warning / note) so consumers can filter by severity
137- without understanding PySpector-specific values.
138- - Surfaces remediation guidance in rule.help so it appears in
139- IDEs and dashboards that consume SARIF.
140- - Uses proper Message / MultiformatMessageString objects instead
141- of raw dicts.
142- - Serialises via a custom _clean() helper that drops None-valued
143- keys, keeping the output compact and spec-compliant.
144- """
145149
146- # ── 1. Build an ordered, deduplicated rule list ──────────────────
147150 rule_index_map : dict [str , int ] = {}
148151 rules : list [ReportingDescriptor ] = []
149152
150153 for issue in self .issues :
154+
151155 if issue .rule_id in rule_index_map :
152156 continue
153157
154- severity_key = str (issue . severity ). split ( "." )[ - 1 ]. upper ( )
158+ severity_key = _severity_key (issue )
155159
156160 rule = ReportingDescriptor (
157161 id = issue .rule_id ,
158- name = issue .rule_id , # human-friendly CamelCase id is conventional
162+ name = issue .rule_id ,
159163 short_description = MultiformatMessageString (
160164 text = issue .description
161165 ),
162- # help surfaces remediation in GitHub Advanced Security, VS Code, etc.
163166 help = MultiformatMessageString (
164167 text = issue .remediation or issue .description ,
165168 markdown = (
@@ -168,40 +171,47 @@ def to_sarif(self) -> str:
168171 else None
169172 ),
170173 ),
171- # default_configuration carries the base severity level for the rule
172- default_configuration = {"level" : _SEVERITY_TO_SARIF_LEVEL .get (severity_key , "warning" )},
174+ default_configuration = ReportingConfiguration (
175+ level = _SEVERITY_TO_SARIF_LEVEL .get (
176+ severity_key ,
177+ "warning" ,
178+ )
179+ ),
173180 )
174181
175182 rule_index_map [issue .rule_id ] = len (rules )
176183 rules .append (rule )
177184
178- # ── 2. Assemble the Tool ─────────────────────────────────────────
179185 driver = ToolComponent (
180186 name = "PySpector" ,
181187 version = _PYSPECTOR_VERSION ,
182188 information_uri = "https://github.com/your-org/pyspector" ,
183189 rules = rules ,
184190 )
191+
185192 tool = Tool (driver = driver )
186193
187- # ── 3. Build Results ─────────────────────────────────────────────
188194 results : list [Result ] = []
189195
190196 for issue in self .issues :
191- severity_key = str (issue .severity ).split ("." )[- 1 ].upper ()
192- level = _SEVERITY_TO_SARIF_LEVEL .get (severity_key , "warning" )
197+
198+ severity_key = _severity_key (issue )
199+ level = _SEVERITY_TO_SARIF_LEVEL .get (
200+ severity_key ,
201+ "warning" ,
202+ )
193203
194204 region = Region (
195205 start_line = issue .line_number ,
196- # Snippet lets viewers show the offending code inline
197- snippet = MultiformatMessageString (text = issue .code .strip ()),
206+ snippet = MultiformatMessageString (
207+ text = issue .code .strip ()
208+ ),
198209 )
199210
200211 location = Location (
201212 physical_location = PhysicalLocation (
202213 artifact_location = ArtifactLocation (
203214 uri = issue .file_path ,
204- # uri_base_id makes paths relative to the repo root,
205215 uri_base_id = "%SRCROOT%" ,
206216 ),
207217 region = region ,
@@ -218,20 +228,23 @@ def to_sarif(self) -> str:
218228
219229 results .append (result )
220230
221- # ── 4. Compose the log ───────────────────────────────────────────
222231 run = Run (tool = tool , results = results )
232+
223233 log = SarifLog (
224234 version = "2.1.0" ,
225235 schema_uri = (
226- "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/ "
227- "master/Schemata/sarif-schema-2.1.0.json"
236+ "https://raw.githubusercontent.com/oasis-tcs/"
237+ "sarif-spec/ master/Schemata/sarif-schema-2.1.0.json"
228238 ),
229239 runs = [run ],
230240 )
231241
232- # ── 5. Serialise, stripping None values ──────────────────────────
233242 return json .dumps (_clean (log ), indent = 2 )
234243
244+ # ------------------------------------------------------------------ #
245+ # HTML #
246+ # ------------------------------------------------------------------ #
247+
235248 def to_html (self ) -> str :
236249 html = f"""
237250 <html>
@@ -241,13 +254,14 @@ def to_html(self) -> str:
241254 <h2>Found { len (self .issues )} issues.</h2>
242255 <table border='1' style='border-collapse: collapse; width: 100%;'>
243256 <tr style='background-color: #f2f2f2;'>
244- <th style='padding: 8px; text-align: left; '>File</th>
245- <th style='padding: 8px; text-align: left; '>Line</th>
246- <th style='padding: 8px; text-align: left; '>Severity</th>
247- <th style='padding: 8px; text-align: left; '>Description</th>
248- <th style='padding: 8px; text-align: left; '>Code</th>
257+ <th style='padding: 8px;'>File</th>
258+ <th style='padding: 8px;'>Line</th>
259+ <th style='padding: 8px;'>Severity</th>
260+ <th style='padding: 8px;'>Description</th>
261+ <th style='padding: 8px;'>Code</th>
249262 </tr>
250263 """
264+
251265 for issue in self .issues :
252266 html += f"""
253267 <tr>
@@ -258,5 +272,7 @@ def to_html(self) -> str:
258272 <td style='padding: 8px;'><pre><code>{ html_module .escape (issue .code )} </code></pre></td>
259273 </tr>
260274 """
275+
261276 html += "</table></body></html>"
277+
262278 return html
0 commit comments