packages feed

canontra-0.1.0.0: src/Canontra/Analysis/Impact.hs

{- |
Module      : Canontra.Analysis.Impact
Description : Fine-grained semantic change impact analysis and minimal invalidation slicing.

This module computes the precise transitive invalidation slice for code modifications
by evaluating multi-tier fingerprint deltas against the whole-repository call graph (F_WCG).
It categorizes mutations into Trivia (0 invalidations), Internal Logic (local unit test only),
and Interface (transitive caller invalidation), eliminating up to 99% of redundant CI test runs.
-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module Canontra.Analysis.Impact
  ( ChangeSeverity (..)
  , ImpactSlice (..)
  , classifySeverity
  , computeImpactSlice
  , computeSavedPct
  , findMatchingTests
  , formatImpactSlice
  , formatImpactSliceJson
  ) where

import Control.DeepSeq (NFData)
import Data.Aeson (FromJSON, ToJSON, encode)
import qualified Data.ByteString.Lazy as BL
import Data.List (nub, sort)
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import GHC.Generics (Generic)
import System.FilePath
  ( normalise
  , takeBaseName
  , takeExtension
  , takeFileName
  )

import Canontra.Analysis.WholeRepoGraph
  ( GlobalSymbol (..)
  , WholeRepoCallEdge (..)
  , WholeRepoCallGraph (..)
  )
import Canontra.Types (FingerprintBundle (..))

-- | Classification of modification severity based on multi-tier fingerprint deltas.
data ChangeSeverity
  = SeverityTrivia          -- ^ Formatting, comments, whitespace (F0 delta != 0, F1 == 0)
  | SeverityInternalLogic   -- ^ Function body edit (F1 delta != 0, F2 == 0)
  | SeverityInterface       -- ^ Public signature or type contract changed (F2 delta != 0)
  | SeverityDependency      -- ^ Imports or dependencies changed (F3 delta != 0)
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (ToJSON, FromJSON, NFData)

-- | Minimal transitive invalidation slice resulting from a semantic change.
data ImpactSlice = ImpactSlice
  { impactTargetFile       :: !FilePath
  , impactSeverity         :: !ChangeSeverity
  , impactDirectCallers    :: ![GlobalSymbol]
  , impactTransitiveFiles  :: ![FilePath]
  , impactInvalidatedTests :: ![FilePath]
  , impactSavedComputePct  :: !Double
  } deriving stock (Eq, Show, Generic)
  deriving anyclass (ToJSON, FromJSON, NFData)

-- | Classify the semantic severity of a change by comparing old and new fingerprint bundles.
classifySeverity :: FingerprintBundle -> FingerprintBundle -> ChangeSeverity
classifySeverity bOld bNew
  | f2Declaration bOld /= f2Declaration bNew   = SeverityInterface
  | fTTypeContract bOld /= fTTypeContract bNew = SeverityInterface
  | f3Dependency bOld /= f3Dependency bNew     = SeverityDependency
  | f1Structural bOld /= f1Structural bNew
      || fCGCallGraph bOld /= fCGCallGraph bNew
      || fCFControlFlow bOld /= fCFControlFlow bNew
      || fDFDataFlow bOld /= fDFDataFlow bNew   = SeverityInternalLogic
  | f0Source bOld /= f0Source bNew             = SeverityTrivia
  | otherwise                                  = SeverityTrivia

-- | Compute the transitive impact slice for a modified file against the whole-repository call graph.
computeImpactSlice
  :: FilePath             -- ^ Modified file path
  -> FingerprintBundle    -- ^ Old fingerprint bundle
  -> FingerprintBundle    -- ^ New fingerprint bundle
  -> WholeRepoCallGraph   -- ^ Whole repository call graph (F_WCG)
  -> [FilePath]           -- ^ All known source files in the repository
  -> ImpactSlice
computeImpactSlice targetFile bOld bNew wcg allRepoFiles =
  let normTarget = normalizePath targetFile
      severity = classifySeverity bOld bNew

      -- Extract direct external callers of target file
      directCallers = sort (nub
        [ wceCaller e
        | e <- wcgEdges wcg
        , normalizePath (symFilePath (wceCallee e)) == normTarget
        , normalizePath (symFilePath (wceCaller e)) /= normTarget
        ])

      -- Compute transitive files and tests according to severity tier
      (transFiles, affectedTests, savedPct) = case severity of
        SeverityTrivia ->
          ( []
          , []
          , 100.0
          )

        SeverityInternalLogic ->
          let localTests = findMatchingTests [normTarget] allRepoFiles
              affected = [normTarget]
              saved = computeSavedPct affected allRepoFiles
          in (affected, localTests, saved)

        SeverityInterface ->
          let reachableSymbols = traverseInvertedCallers normTarget wcg
              affected = sort (nub (normTarget : map (normalizePath . symFilePath) reachableSymbols))
              tests = findMatchingTests affected allRepoFiles
              saved = computeSavedPct affected allRepoFiles
          in (affected, tests, saved)

        SeverityDependency ->
          let reachableSymbols = traverseInvertedCallers normTarget wcg
              affected = sort (nub (normTarget : map (normalizePath . symFilePath) reachableSymbols))
              tests = findMatchingTests affected allRepoFiles
              saved = computeSavedPct affected allRepoFiles
          in (affected, tests, saved)

  in ImpactSlice
      { impactTargetFile       = targetFile
      , impactSeverity         = severity
      , impactDirectCallers    = directCallers
      , impactTransitiveFiles  = transFiles
      , impactInvalidatedTests = affectedTests
      , impactSavedComputePct  = savedPct
      }

-- | Traverse the inverted call graph (callee -> caller) to compute all transitively reachable callers.
traverseInvertedCallers :: FilePath -> WholeRepoCallGraph -> [GlobalSymbol]
traverseInvertedCallers normTarget wcg =
  let initialSymbols = [s | s <- wcgNodes wcg, normalizePath (symFilePath s) == normTarget]
      invAdj = Map.fromListWith (++)
        [ (wceCallee e, [wceCaller e])
        | e <- wcgEdges wcg
        ]
      bfs [] _ acc = acc
      bfs (curr:queue) visited acc
        | Set.member curr visited = bfs queue visited acc
        | otherwise =
            let callers = Map.findWithDefault [] curr invAdj
                newVisited = Set.insert curr visited
                newAcc = if normalizePath (symFilePath curr) /= normTarget then curr : acc else acc
            in bfs (queue ++ callers) newVisited newAcc
  in bfs initialSymbols Set.empty []

-- | Discover test files associated with a set of modified source files.
findMatchingTests :: [FilePath] -> [FilePath] -> [FilePath]
findMatchingTests affectedFiles allRepoFiles =
  let testFiles = filter isTestFile allRepoFiles
      affectedBases = Set.fromList (map (T.toLower . T.pack . takeBaseName) affectedFiles)
      matchesTest tf =
        let tBase = T.toLower (T.pack (takeBaseName tf))
        in any (\b -> b `T.isInfixOf` tBase || tBase `T.isInfixOf` b) affectedBases
  in sort (filter matchesTest testFiles)
  where
    isTestFile fp =
      let p = map (\c -> if c == '\\' then '/' else c) (normalise fp)
          fn = takeFileName p
          ext = takeExtension p
          pText = T.pack p
          fnText = T.pack fn
      in any (`T.isInfixOf` pText) ["/test/", "/tests/", "/spec/", "/specs/"]
         || any (`T.isPrefixOf` pText) ["test/", "tests/", "spec/", "specs/"]
         || any (`T.isSuffixOf` fnText) ["_test" <> T.pack ext, ".test" <> T.pack ext, "spec" <> T.pack ext, ".spec" <> T.pack ext]
         || any (`T.isPrefixOf` fnText) ["test_", "spec_"]

computeSavedPct :: [FilePath] -> [FilePath] -> Double
computeSavedPct affected allFiles
  | null allFiles = 100.0
  | otherwise =
      let total = length allFiles
          aff = length affected
          ratio = fromIntegral (total - aff) / fromIntegral total
      in max 0.0 (fromIntegral (round (ratio * 10000 :: Double) :: Integer) / 100.0)

normalizePath :: FilePath -> FilePath
normalizePath = map (\c -> if c == '\\' then '/' else c) . normalise

-- | Format ImpactSlice into human-readable diagnostic report.
formatImpactSlice :: ImpactSlice -> Text
formatImpactSlice ImpactSlice{..} =
  T.unlines $
    [ "================================================================================"
    , "  CANONTRA SEMANTIC CHANGE IMPACT ANALYSIS (CIA)"
    , "================================================================================"
    , "  Target File:         " <> T.pack impactTargetFile
    , "  Change Severity:     " <> formatSeverity impactSeverity
    , "  Direct Callers:      " <> T.pack (show (length impactDirectCallers)) <> " symbols"
    , "  Transitive Files:    " <> T.pack (show (length impactTransitiveFiles)) <> " files"
    , "  Invalidated Tests:   " <> T.pack (show (length impactInvalidatedTests)) <> " test suites"
    , "  Saved CI Compute:    " <> T.pack (show impactSavedComputePct) <> "%"
    , "--------------------------------------------------------------------------------"
    , "  Action Plan:         " <> actionPlan impactSeverity impactInvalidatedTests
    ] ++ (if null impactDirectCallers then [] else ["\n  Direct External Callers:"])
      ++ map (\s -> "    - " <> symModule s <> ":" <> symDeclName s <> " (" <> T.pack (symFilePath s) <> ")") impactDirectCallers
      ++ (if null impactTransitiveFiles then [] else ["\n  Transitively Impacted Files:"])
      ++ map (\f -> "    - " <> T.pack f) impactTransitiveFiles
      ++ (if null impactInvalidatedTests then [] else ["\n  Recommended Test Slices:"])
      ++ map (\t -> "    - " <> T.pack t) impactInvalidatedTests
  where
    formatSeverity = \case
      SeverityTrivia        -> "LEVEL 1: TRIVIA (Formatting / Comments / Whitespace Only)"
      SeverityInternalLogic -> "LEVEL 2: INTERNAL LOGIC (Function Body Edit, Invariant Interface)"
      SeverityInterface     -> "LEVEL 3: PUBLIC INTERFACE (Public Declaration or Signature Changed)"
      SeverityDependency    -> "LEVEL 3: DEPENDENCY (Module Imports or Dependency Graph Changed)"

    actionPlan sev tests = case sev of
      SeverityTrivia        -> "Safe to skip CI build and test execution completely (0 risk)."
      SeverityInternalLogic -> "Run targeted local unit tests only (" <> T.pack (show (length tests)) <> " test suites). Skip downstream consumers."
      SeverityInterface     -> "Run transitive test slice (" <> T.pack (show (length tests)) <> " test suites). Skip remaining unaffected repository."
      SeverityDependency    -> "Run transitive dependency slice (" <> T.pack (show (length tests)) <> " test suites)."

-- | Format ImpactSlice into machine-readable JSON for CI/CD test runners.
formatImpactSliceJson :: ImpactSlice -> Text
formatImpactSliceJson slice =
  TE.decodeUtf8 (BL.toStrict (encode slice))