Coverage for lintro / ai / output / sarif.py: 100%

92 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""SARIF v2.1.0 output for AI findings. 

2 

3Generates SARIF (Static Analysis Results Interchange Format) output 

4from AI fix suggestions and summaries. Compatible with GitHub Code 

5Scanning, VS Code SARIF Viewer, and other SARIF-consuming tools. 

6 

7Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/ 

8""" 

9 

10from __future__ import annotations 

11 

12import json 

13from collections.abc import Sequence 

14from pathlib import Path 

15from typing import Any 

16 

17from lintro.ai.enums import ConfidenceLevel, RiskLevel 

18from lintro.ai.models import AIFixSuggestion, AISummary 

19 

20SARIF_SCHEMA = ( 

21 "https://raw.githubusercontent.com/oasis-tcs/sarif-spec" 

22 "/main/sarif-2.1/schema/sarif-schema-2.1.0.json" 

23) 

24SARIF_VERSION = "2.1.0" 

25 

26_CONFIDENCE_SCORE = { 

27 ConfidenceLevel.HIGH: 0.9, 

28 ConfidenceLevel.MEDIUM: 0.6, 

29 ConfidenceLevel.LOW: 0.3, 

30} 

31 

32 

33def _risk_to_sarif_level(risk_level: RiskLevel | str) -> str: 

34 """Map AI risk level to SARIF result level. 

35 

36 Args: 

37 risk_level: Risk classification (e.g. ``"behavioral-risk"``). 

38 

39 Returns: 

40 One of ``"error"``, ``"warning"``, or ``"note"``. 

41 """ 

42 normalized = str(risk_level).lower().strip() if risk_level else "" 

43 try: 

44 return RiskLevel(normalized).to_severity_label(sarif=True) 

45 except ValueError: 

46 pass 

47 if normalized in {"high", "critical"}: 

48 return "error" 

49 if normalized in {"medium"}: 

50 return "warning" 

51 if normalized in {"low"}: 

52 return "note" 

53 return "warning" 

54 

55 

56def _confidence_to_score(confidence: ConfidenceLevel | str) -> float: 

57 """Map confidence label to a numeric score. 

58 

59 Args: 

60 confidence: Confidence level string or enum. 

61 

62 Returns: 

63 Score between 0.0 and 1.0. 

64 """ 

65 normalized = str(confidence).lower().strip() if confidence else "" 

66 try: 

67 return _CONFIDENCE_SCORE[ConfidenceLevel(normalized)] 

68 except (ValueError, KeyError): 

69 return 0.5 

70 

71 

72def to_sarif( 

73 suggestions: Sequence[AIFixSuggestion], 

74 summary: AISummary | None = None, 

75 *, 

76 tool_name: str = "lintro-ai", 

77 tool_version: str = "", 

78 doc_urls: dict[str, str] | None = None, 

79) -> dict[str, Any]: 

80 """Convert AI findings to a SARIF v2.1.0 document. 

81 

82 Args: 

83 suggestions: AI fix suggestions to include as results. 

84 summary: Optional AI summary to attach as run properties. 

85 tool_name: Name for the SARIF tool driver. 

86 tool_version: Version string for the tool driver. 

87 doc_urls: Optional mapping of rule codes to documentation URLs. 

88 When provided, matching rules get a ``helpUri`` property. 

89 

90 Returns: 

91 SARIF document as a dictionary. 

92 """ 

93 rules_map: dict[str, dict[str, Any]] = {} 

94 results: list[dict[str, Any]] = [] 

95 

96 for s in suggestions: 

97 if s.tool_name and s.code: 

98 rule_id = f"{s.tool_name}/{s.code}" 

99 else: 

100 rule_id = s.tool_name or s.code or "unknown" 

101 

102 if rule_id not in rules_map: 

103 rule: dict[str, Any] = {"id": rule_id} 

104 short_desc = s.code or "AI finding" 

105 rule["shortDescription"] = {"text": short_desc} 

106 if s.explanation: 

107 rule["fullDescription"] = {"text": s.explanation} 

108 # Attach documentation URL when available 

109 if doc_urls: 

110 help_uri = doc_urls.get(s.code, "") or doc_urls.get(rule_id, "") 

111 if help_uri: 

112 rule["helpUri"] = help_uri 

113 if s.tool_name: 

114 rule["properties"] = {"tool": s.tool_name} 

115 rules_map[rule_id] = rule 

116 

117 result: dict[str, Any] = { 

118 "ruleId": rule_id, 

119 "level": _risk_to_sarif_level(s.risk_level), 

120 "message": {"text": s.explanation or "AI fix available"}, 

121 } 

122 

123 # Location 

124 if s.file: 

125 location: dict[str, Any] = { 

126 "physicalLocation": { 

127 "artifactLocation": {"uri": s.file}, 

128 }, 

129 } 

130 if s.line > 0: 

131 location["physicalLocation"]["region"] = { 

132 "startLine": s.line, 

133 } 

134 result["locations"] = [location] 

135 

136 # Fix suggestion — only emit when there is a real replacement target 

137 if s.suggested_code and s.file and s.line > 0: 

138 deleted_region: dict[str, int] = {"startLine": s.line} 

139 if s.original_code: 

140 trimmed = s.original_code.rstrip("\n") 

141 line_count = max(1, trimmed.count("\n") + 1) 

142 deleted_region["endLine"] = s.line + line_count - 1 

143 fix: dict[str, Any] = { 

144 "description": {"text": s.explanation or "AI suggestion"}, 

145 "artifactChanges": [ 

146 { 

147 "artifactLocation": {"uri": s.file}, 

148 "replacements": [ 

149 { 

150 "deletedRegion": deleted_region, 

151 "insertedContent": { 

152 "text": s.suggested_code, 

153 }, 

154 }, 

155 ], 

156 }, 

157 ], 

158 } 

159 result["fixes"] = [fix] 

160 

161 # Properties 

162 props: dict[str, Any] = {} 

163 if s.confidence: 

164 props["confidence"] = s.confidence 

165 props["confidenceScore"] = _confidence_to_score(s.confidence) 

166 if s.risk_level: 

167 props["riskLevel"] = s.risk_level 

168 if s.tool_name: 

169 props["tool"] = s.tool_name 

170 if s.cost_estimate: 

171 props["costEstimate"] = s.cost_estimate 

172 if props: 

173 result["properties"] = props 

174 

175 results.append(result) 

176 

177 # Build driver 

178 driver: dict[str, Any] = {"name": tool_name} 

179 if tool_version: 

180 driver["version"] = tool_version 

181 if rules_map: 

182 driver["rules"] = list(rules_map.values()) 

183 

184 run: dict[str, Any] = { 

185 "tool": {"driver": driver}, 

186 "results": results, 

187 } 

188 

189 # Attach summary as run properties when any field is populated 

190 if summary and ( 

191 summary.overview 

192 or summary.key_patterns 

193 or summary.priority_actions 

194 or summary.triage_suggestions 

195 or summary.estimated_effort 

196 ): 

197 run["properties"] = { 

198 "aiSummary": { 

199 "overview": summary.overview, 

200 "keyPatterns": summary.key_patterns, 

201 "priorityActions": summary.priority_actions, 

202 "triageSuggestions": summary.triage_suggestions, 

203 "estimatedEffort": summary.estimated_effort, 

204 }, 

205 } 

206 

207 return { 

208 "$schema": SARIF_SCHEMA, 

209 "version": SARIF_VERSION, 

210 "runs": [run], 

211 } 

212 

213 

214def render_fixes_sarif( 

215 suggestions: Sequence[AIFixSuggestion], 

216 summary: AISummary | None = None, 

217 *, 

218 tool_name: str = "lintro-ai", 

219 tool_version: str = "", 

220) -> str: 

221 """Render AI findings as a SARIF JSON string. 

222 

223 Args: 

224 suggestions: AI fix suggestions. 

225 summary: Optional AI summary. 

226 tool_name: Name for the SARIF tool driver. 

227 tool_version: Version string for the tool driver. 

228 

229 Returns: 

230 Pretty-printed SARIF JSON string. 

231 """ 

232 sarif = to_sarif( 

233 suggestions, 

234 summary, 

235 tool_name=tool_name, 

236 tool_version=tool_version, 

237 ) 

238 return json.dumps(sarif, indent=2) 

239 

240 

241def write_sarif( 

242 suggestions: Sequence[AIFixSuggestion], 

243 summary: AISummary | None = None, 

244 *, 

245 output_path: Path, 

246 tool_name: str = "lintro-ai", 

247 tool_version: str = "", 

248 doc_urls: dict[str, str] | None = None, 

249) -> None: 

250 """Write AI findings as a SARIF file. 

251 

252 Args: 

253 suggestions: AI fix suggestions. 

254 summary: Optional AI summary. 

255 output_path: Path to write the SARIF file. 

256 tool_name: Name for the SARIF tool driver. 

257 tool_version: Version string for the tool driver. 

258 doc_urls: Optional mapping of rule codes to documentation URLs. 

259 """ 

260 sarif = to_sarif( 

261 suggestions, 

262 summary, 

263 tool_name=tool_name, 

264 tool_version=tool_version, 

265 doc_urls=doc_urls, 

266 ) 

267 output_path.parent.mkdir(parents=True, exist_ok=True) 

268 output_path.write_text( 

269 json.dumps(sarif, indent=2) + "\n", 

270 encoding="utf-8", 

271 )