canontra-0.1.0.0: src/Canontra/Export/SARIF.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
{- |
Module : Canontra.Export.SARIF
Description : OASIS SARIF v2.1.0 JSON export engine for CI/CD, scanners, and IDE integration.
Implements standard Static Analysis Results Interchange Format (SARIF) v2.1.0:
- Maps public declaration interface mutations to rule CTR001_InterfaceBreak (level: error).
- Maps dependency and import graph divergences to rule CTR002_DependencyDivergence (level: warning).
- Maps structural AST, control-flow, and data-flow mutations to rule CTR003_StructuralMutation (level: note).
- Converts fine-grained DiffResult and ImpactSlice diagnostics into schema-compliant SARIF runs.
-}
module Canontra.Export.SARIF
( exportDiffSARIF
, exportImpactSARIF
, exportMultiDiffSARIF
, renderSARIF
, sarifSchemaUri
, sarifVersion
, ruleIdInterfaceBreak
, ruleIdDependencyDivergence
, ruleIdStructuralMutation
) where
import qualified Data.Aeson as Aeson
import Data.Aeson (object, (.=))
import qualified Data.Aeson.Encode.Pretty as AesonPretty
import qualified Data.ByteString.Lazy as LBS
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Canontra.Analysis.Impact
( ChangeSeverity (..)
, ImpactSlice (..)
)
import Canontra.Comparison.Diff
( CFGDiff (..)
, CallGraphDiff (..)
, DFGDiff (..)
, DeclDiff (..)
, DepDiff (..)
, DiffResult (..)
, StructuralDiff (..)
)
import Canontra.Normalize.Rules (engineName, engineVersion)
import Canontra.Security.Path (normalizePathUniversal)
-- | Official OASIS SARIF v2.1.0 schema URI.
sarifSchemaUri :: Text
sarifSchemaUri = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"
-- | Standard SARIF version string.
sarifVersion :: Text
sarifVersion = "2.1.0"
ruleIdInterfaceBreak :: Text
ruleIdInterfaceBreak = "CTR001_InterfaceBreak"
ruleIdDependencyDivergence :: Text
ruleIdDependencyDivergence = "CTR002_DependencyDivergence"
ruleIdStructuralMutation :: Text
ruleIdStructuralMutation = "CTR003_StructuralMutation"
-- | Standard rules definitions for Canontra SARIF runs.
standardRules :: [Aeson.Value]
standardRules =
[ object
[ "id" .= ruleIdInterfaceBreak
, "name" .= ("InterfaceContractMutation" :: Text)
, "shortDescription" .= object
[ "text" .= ("Public interface declaration or structural type contract mutated." :: Text) ]
, "fullDescription" .= object
[ "text" .= ("A public function signature, class method, export declaration, or type contract changed, potentially breaking downstream callers." :: Text) ]
, "defaultConfiguration" .= object
[ "level" .= ("error" :: Text) ]
]
, object
[ "id" .= ruleIdDependencyDivergence
, "name" .= ("DependencyGraphDivergence" :: Text)
, "shortDescription" .= object
[ "text" .= ("Module imported dependencies or aliases diverged." :: Text) ]
, "fullDescription" .= object
[ "text" .= ("The module import graph or external library dependencies were modified, which may affect build dependencies and transitive caching." :: Text) ]
, "defaultConfiguration" .= object
[ "level" .= ("warning" :: Text) ]
]
, object
[ "id" .= ruleIdStructuralMutation
, "name" .= ("StructuralASTMutation" :: Text)
, "shortDescription" .= object
[ "text" .= ("Substantive structural AST, control-flow, or data-flow mutation detected." :: Text) ]
, "fullDescription" .= object
[ "text" .= ("An internal algorithmic, control-flow branching, or data-flow def-use structure was modified without altering public interface contracts." :: Text) ]
, "defaultConfiguration" .= object
[ "level" .= ("note" :: Text) ]
]
]
-- | Constructs the standard SARIF tool driver component.
toolDriver :: Aeson.Value
toolDriver = object
[ "name" .= engineName
, "version" .= engineVersion
, "informationUri" .= ("https://github.com/symtrace/canontra" :: Text)
, "rules" .= standardRules
]
-- | Wraps an array of SARIF results into a top-level SARIF v2.1.0 document.
buildSARIFDocument :: [Aeson.Value] -> Aeson.Value
buildSARIFDocument results = object
[ "$schema" .= sarifSchemaUri
, "version" .= sarifVersion
, "runs" .=
[ object
[ "tool" .= object [ "driver" .= toolDriver ]
, "results" .= results
]
]
]
-- | Helper to build a standard physical location descriptor.
makeLocation :: FilePath -> Aeson.Value
makeLocation filePath = object
[ "physicalLocation" .= object
[ "artifactLocation" .= object
[ "uri" .= T.pack (normalizePathUniversal filePath)
]
]
]
-- | Export a DiffResult for a specific target file to a SARIF v2.1.0 document.
exportDiffSARIF :: FilePath -> DiffResult -> Aeson.Value
exportDiffSARIF filePath diffRes =
buildSARIFDocument (diffResultToResults filePath diffRes)
-- | Convert a DiffResult into individual SARIF results.
diffResultToResults :: FilePath -> DiffResult -> [Aeson.Value]
diffResultToResults filePath diffRes =
let loc = makeLocation filePath
-- 1. Interface Breaks (CTR001_InterfaceBreak, error)
declResults =
[ object
[ "ruleId" .= ruleIdInterfaceBreak
, "ruleIndex" .= (0 :: Int)
, "level" .= ("error" :: Text)
, "message" .= object
[ "text" .= ("Public interface declaration " <> ddAction dd <> ": " <> ddTarget dd <> detailSuffix (ddDetails dd))
]
, "locations" .= [loc]
]
| dd <- drDeclarationDiffs diffRes
]
-- 2. Dependency Divergences (CTR002_DependencyDivergence, warning)
depResults =
[ object
[ "ruleId" .= ruleIdDependencyDivergence
, "ruleIndex" .= (1 :: Int)
, "level" .= ("warning" :: Text)
, "message" .= object
[ "text" .= ("Dependency " <> depAction dep <> ": module " <> depModule dep <> symSuffix (depSymbol dep) <> detailSuffix (depUsage dep))
]
, "locations" .= [loc]
]
| dep <- drDependencyDiffs diffRes
]
-- 3. Structural & Graph Mutations (CTR003_StructuralMutation, note)
structResults =
[ object
[ "ruleId" .= ruleIdStructuralMutation
, "ruleIndex" .= (2 :: Int)
, "level" .= ("note" :: Text)
, "message" .= object
[ "text" .= ("Structural mutation in " <> sdTarget sd <> " (" <> sdKind sd <> ")" <> detailSuffix (sdDetail sd))
]
, "locations" .= [loc]
]
| sd <- drStructuralDiffs diffRes
]
cgResults =
[ object
[ "ruleId" .= ruleIdStructuralMutation
, "ruleIndex" .= (2 :: Int)
, "level" .= ("note" :: Text)
, "message" .= object
[ "text" .= ("Call graph divergence: " <> cgdCaller cgd <> " " <> cgdAction cgd <> " " <> cgdCallee cgd)
]
, "locations" .= [loc]
]
| cgd <- drCallGraphDiffs diffRes
]
cfgResults =
[ object
[ "ruleId" .= ruleIdStructuralMutation
, "ruleIndex" .= (2 :: Int)
, "level" .= ("note" :: Text)
, "message" .= object
[ "text" .= ("Control-flow divergence in function " <> cfgdFunction cd <> " (" <> cfgdAction cd <> ")" <> detailSuffix (cfgdDetail cd))
]
, "locations" .= [loc]
]
| cd <- drCFGDiffs diffRes
]
dfgResults =
[ object
[ "ruleId" .= ruleIdStructuralMutation
, "ruleIndex" .= (2 :: Int)
, "level" .= ("note" :: Text)
, "message" .= object
[ "text" .= ("Data-flow divergence in function " <> dfgdFunction dd <> " (" <> dfgdAction dd <> ")" <> detailSuffix (dfgdDetail dd))
]
, "locations" .= [loc]
]
| dd <- drDFGDiffs diffRes
]
in declResults ++ depResults ++ structResults ++ cgResults ++ cfgResults ++ dfgResults
where
detailSuffix txt = if T.null txt then "" else " [" <> txt <> "]"
symSuffix Nothing = ""
symSuffix (Just s) = ":" <> s
-- | Export an ImpactSlice into a SARIF v2.1.0 document.
exportImpactSARIF :: ImpactSlice -> Aeson.Value
exportImpactSARIF slice =
let filePath = impactTargetFile slice
loc = makeLocation filePath
(rId, rIdx, lvl, msg) = case impactSeverity slice of
SeverityInterface ->
( ruleIdInterfaceBreak
, 0 :: Int
, "error" :: Text
, "Public interface mutation in " <> T.pack filePath
<> ": invalidates " <> T.pack (show (length (impactDirectCallers slice))) <> " direct callers and "
<> T.pack (show (length (impactTransitiveFiles slice))) <> " transitive files. "
<> T.pack (show (length (impactInvalidatedTests slice))) <> " tests affected."
)
SeverityDependency ->
( ruleIdDependencyDivergence
, 1 :: Int
, "warning" :: Text
, "Dependency divergence in " <> T.pack filePath
<> ": " <> T.pack (show (length (impactInvalidatedTests slice))) <> " test suites affected."
)
SeverityInternalLogic ->
( ruleIdStructuralMutation
, 2 :: Int
, "note" :: Text
, "Internal logic mutation in " <> T.pack filePath
<> ": internal logic changed without altering public interface. Local tests only."
)
SeverityTrivia ->
( ruleIdStructuralMutation
, 2 :: Int
, "note" :: Text
, "Syntactic / formatting trivia change in " <> T.pack filePath
<> ": zero test invalidations required."
)
res = object
[ "ruleId" .= rId
, "ruleIndex" .= rIdx
, "level" .= lvl
, "message" .= object [ "text" .= msg ]
, "locations" .= [loc]
]
in buildSARIFDocument [res]
-- | Export multiple file diffs across a repository into a unified SARIF v2.1.0 document.
exportMultiDiffSARIF :: [(FilePath, DiffResult)] -> Aeson.Value
exportMultiDiffSARIF fileDiffs =
let allResults = concatMap (\(fp, dr) -> diffResultToResults fp dr) fileDiffs
in buildSARIFDocument allResults
-- | Render a SARIF Value as formatted, pretty-printed JSON Text.
renderSARIF :: Aeson.Value -> Text
renderSARIF val = TE.decodeUtf8 (LBS.toStrict (AesonPretty.encodePretty val))