canontra-0.1.0.0: test/Canontra/ImpactAnalysisSpec.hs
{- |
Module : Canontra.ImpactAnalysisSpec
Description : Test specification for semantic change impact analysis and invalidation slicing.
Validates 3-tier severity classification, minimal transitive invalidation slicing,
circular call graph termination, and JSON manifest generation for CI/CD test runners.
-}
{-# LANGUAGE OverloadedStrings #-}
module Canontra.ImpactAnalysisSpec (spec) where
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy as BL
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Test.Hspec
import Canontra.Analysis.Impact
import Canontra.Analysis.WholeRepoGraph (buildWholeRepoCallGraph)
import Canontra.Fingerprint.Bundle (computeBundle)
import Canontra.Parser.Polyglot (parsePolyglotSource)
spec :: Spec
spec = do
describe "Semantic Change Severity Classification" $ do
let authBase = T.unlines
[ "def verify(token: str) -> bool:"
, " return len(token) > 10"
]
let authTrivia = T.unlines
[ "# Formatted version with comment"
, "def verify( token: str ) -> bool:"
, " \"\"\"Docstring\"\"\""
, " return len(token) > 10"
]
let authLogic = T.unlines
[ "def verify(token: str) -> bool:"
, " return len(token) > 20"
]
let authInterface = T.unlines
[ "def verify(token: str, secret: str) -> bool:"
, " return len(token) > 10 and secret == 'admin'"
]
let authDep = T.unlines
[ "import hashlib"
, ""
, "def verify(token: str) -> bool:"
, " return len(token) > 10"
]
it "classifies formatting and comment edits as SeverityTrivia" $ do
case ( computeBundle "auth.py" (TE.encodeUtf8 authBase) authBase
, computeBundle "auth.py" (TE.encodeUtf8 authTrivia) authTrivia
) of
(Right b1, Right b2) -> classifySeverity b1 b2 `shouldBe` SeverityTrivia
_ -> expectationFailure "Bundle computation failed"
it "classifies internal function body mutations as SeverityInternalLogic" $ do
case ( computeBundle "auth.py" (TE.encodeUtf8 authBase) authBase
, computeBundle "auth.py" (TE.encodeUtf8 authLogic) authLogic
) of
(Right b1, Right b2) -> classifySeverity b1 b2 `shouldBe` SeverityInternalLogic
_ -> expectationFailure "Bundle computation failed"
it "classifies parameter and signature mutations as SeverityInterface" $ do
case ( computeBundle "auth.py" (TE.encodeUtf8 authBase) authBase
, computeBundle "auth.py" (TE.encodeUtf8 authInterface) authInterface
) of
(Right b1, Right b2) -> classifySeverity b1 b2 `shouldBe` SeverityInterface
_ -> expectationFailure "Bundle computation failed"
it "classifies import additions as SeverityDependency" $ do
case ( computeBundle "auth.py" (TE.encodeUtf8 authBase) authBase
, computeBundle "auth.py" (TE.encodeUtf8 authDep) authDep
) of
(Right b1, Right b2) -> classifySeverity b1 b2 `shouldBe` SeverityDependency
_ -> expectationFailure "Bundle computation failed"
describe "Minimal Invalidation Slicing (CIA)" $ do
let authSrc = T.unlines
[ "def authenticate(user, key):"
, " return user == 'root'"
]
let authSrcTrivia = T.unlines
[ "# whitespace change"
, "def authenticate( user , key ) :"
, " return user == 'root'"
]
let authSrcLogic = T.unlines
[ "def authenticate(user, key):"
, " return user == 'admin' or user == 'root'"
]
let authSrcInterface = T.unlines
[ "def authenticate(user, key, realm):"
, " return user == 'root'"
]
let appSrc = T.unlines
[ "import auth"
, ""
, "def handle(u, k):"
, " return auth.authenticate(u, k)"
]
let gatewaySrc = T.unlines
[ "import app"
, ""
, "def dispatch(u, k):"
, " return app.handle(u, k)"
]
let utilsSrc = T.unlines
[ "def helper():"
, " return 42"
]
let allFiles =
[ "auth.py"
, "app.py"
, "gateway.py"
, "utils.py"
, "tests/test_auth.py"
, "tests/test_app.py"
, "tests/test_gateway.py"
, "tests/test_utils.py"
]
it "yields 0 downstream invalidations and 100% saved compute for trivia edits" $ do
case ( parsePolyglotSource "auth.py" authSrc
, parsePolyglotSource "app.py" appSrc
, parsePolyglotSource "gateway.py" gatewaySrc
, parsePolyglotSource "utils.py" utilsSrc
, computeBundle "auth.py" (TE.encodeUtf8 authSrc) authSrc
, computeBundle "auth.py" (TE.encodeUtf8 authSrcTrivia) authSrcTrivia
) of
(Right pAuth, Right pApp, Right pGw, Right pUt, Right bOld, Right bNew) -> do
let modules = [("auth.py", pAuth), ("app.py", pApp), ("gateway.py", pGw), ("utils.py", pUt)]
wcg = buildWholeRepoCallGraph modules
slice = computeImpactSlice "auth.py" bOld bNew wcg allFiles
impactSeverity slice `shouldBe` SeverityTrivia
impactTransitiveFiles slice `shouldBe` []
impactInvalidatedTests slice `shouldBe` []
impactSavedComputePct slice `shouldBe` 100.0
_ -> expectationFailure "Setup failed in trivia test"
it "invalidates only local unit tests when internal body logic changes" $ do
case ( parsePolyglotSource "auth.py" authSrc
, parsePolyglotSource "app.py" appSrc
, parsePolyglotSource "gateway.py" gatewaySrc
, parsePolyglotSource "utils.py" utilsSrc
, computeBundle "auth.py" (TE.encodeUtf8 authSrc) authSrc
, computeBundle "auth.py" (TE.encodeUtf8 authSrcLogic) authSrcLogic
) of
(Right pAuth, Right pApp, Right pGw, Right pUt, Right bOld, Right bNew) -> do
let modules = [("auth.py", pAuth), ("app.py", pApp), ("gateway.py", pGw), ("utils.py", pUt)]
wcg = buildWholeRepoCallGraph modules
slice = computeImpactSlice "auth.py" bOld bNew wcg allFiles
impactSeverity slice `shouldBe` SeverityInternalLogic
impactTransitiveFiles slice `shouldBe` ["auth.py"]
impactInvalidatedTests slice `shouldBe` ["tests/test_auth.py"]
"tests/test_app.py" `elem` impactInvalidatedTests slice `shouldBe` False
impactSavedComputePct slice `shouldSatisfy` (> 80.0)
_ -> expectationFailure "Setup failed in internal logic test"
it "transitively invalidates all downstream callers when public interface changes" $ do
case ( parsePolyglotSource "auth.py" authSrc
, parsePolyglotSource "app.py" appSrc
, parsePolyglotSource "gateway.py" gatewaySrc
, parsePolyglotSource "utils.py" utilsSrc
, computeBundle "auth.py" (TE.encodeUtf8 authSrc) authSrc
, computeBundle "auth.py" (TE.encodeUtf8 authSrcInterface) authSrcInterface
) of
(Right pAuth, Right pApp, Right pGw, Right pUt, Right bOld, Right bNew) -> do
let modules = [("auth.py", pAuth), ("app.py", pApp), ("gateway.py", pGw), ("utils.py", pUt)]
wcg = buildWholeRepoCallGraph modules
slice = computeImpactSlice "auth.py" bOld bNew wcg allFiles
impactSeverity slice `shouldBe` SeverityInterface
"auth.py" `elem` impactTransitiveFiles slice `shouldBe` True
"app.py" `elem` impactTransitiveFiles slice `shouldBe` True
"gateway.py" `elem` impactTransitiveFiles slice `shouldBe` True
"utils.py" `elem` impactTransitiveFiles slice `shouldBe` False
"tests/test_utils.py" `elem` impactInvalidatedTests slice `shouldBe` False
_ -> expectationFailure "Setup failed in interface test"
describe "Circular Dependency Invalidation Termination" $ do
let srvA = T.unlines
[ "import srv_b"
, "def call_a(x):"
, " return srv_b.call_b(x)"
]
let srvB = T.unlines
[ "import srv_a"
, "def call_b(x):"
, " return srv_a.call_a(x)"
]
let srvAMutated = T.unlines
[ "import srv_b"
, "def call_a(x, y):"
, " return srv_b.call_b(x)"
]
it "terminates cleanly without cycle loop when traversing Tarjan SCC cycles" $ do
case ( parsePolyglotSource "srv_a.py" srvA
, parsePolyglotSource "srv_b.py" srvB
, computeBundle "srv_a.py" (TE.encodeUtf8 srvA) srvA
, computeBundle "srv_a.py" (TE.encodeUtf8 srvAMutated) srvAMutated
) of
(Right pA, Right pB, Right bOld, Right bNew) -> do
let modules = [("srv_a.py", pA), ("srv_b.py", pB)]
wcg = buildWholeRepoCallGraph modules
slice = computeImpactSlice "srv_a.py" bOld bNew wcg ["srv_a.py", "srv_b.py"]
impactSeverity slice `shouldBe` SeverityInterface
"srv_a.py" `elem` impactTransitiveFiles slice `shouldBe` True
"srv_b.py" `elem` impactTransitiveFiles slice `shouldBe` True
_ -> expectationFailure "Setup failed in circular test"
describe "JSON Impact Manifest Format" $ do
let slice = ImpactSlice
{ impactTargetFile = "core/auth.py"
, impactSeverity = SeverityInterface
, impactDirectCallers = []
, impactTransitiveFiles = ["core/auth.py", "api/handler.py"]
, impactInvalidatedTests = ["tests/test_auth.py", "tests/test_handler.py"]
, impactSavedComputePct = 85.5
}
it "serializes and roundtrips to valid JSON" $ do
let jsonText = formatImpactSliceJson slice
mDecoded = Aeson.decode (BL.fromStrict (TE.encodeUtf8 jsonText)) :: Maybe ImpactSlice
mDecoded `shouldBe` Just slice
describe "Test Discovery & Compute Savings Calculations" $ do
it "discovers test files matching affected source basenames" $ do
let affected = ["src/auth/jwt.py", "src/payment/stripe.py"]
allFiles =
[ "src/auth/jwt.py"
, "src/payment/stripe.py"
, "tests/test_jwt.py"
, "tests/test_stripe.py"
, "tests/test_database.py"
, "src/database/sql.py"
]
tests = findMatchingTests affected allFiles
"tests/test_jwt.py" `elem` tests `shouldBe` True
"tests/test_stripe.py" `elem` tests `shouldBe` True
"tests/test_database.py" `elem` tests `shouldBe` False
it "discovers TypeScript spec files matching .spec.ts" $ do
let affected = ["src/userService.ts"]
allFiles = ["src/userService.ts", "test/userService.spec.ts", "test/authService.spec.ts"]
tests = findMatchingTests affected allFiles
tests `shouldBe` ["test/userService.spec.ts"]
it "discovers Go test files matching _test.go" $ do
let affected = ["pkg/math/calc.go"]
allFiles = ["pkg/math/calc.go", "pkg/math/calc_test.go", "pkg/net/http_test.go"]
tests = findMatchingTests affected allFiles
tests `shouldBe` ["pkg/math/calc_test.go"]
it "computes 100% saved compute when 0 files are invalidated" $ do
let allFiles = ["a.py", "b.py", "c.py", "d.py"]
saved = computeSavedPct [] allFiles
saved `shouldBe` 100.0
it "computes 0% saved compute when all files are invalidated" $ do
let allFiles = ["a.py", "b.py"]
saved = computeSavedPct allFiles allFiles
saved `shouldBe` 0.0
it "computes 75% saved compute when 1 of 4 files is affected" $ do
let allFiles = ["a.py", "b.py", "c.py", "d.py"]
saved = computeSavedPct ["a.py"] allFiles
saved `shouldBe` 75.0
it "formats human-readable diagnostic report for SeverityTrivia" $ do
let sliceTrivia = ImpactSlice "util.py" SeverityTrivia [] [] [] 100.0
report = formatImpactSlice sliceTrivia
T.isInfixOf "CANONTRA SEMANTIC CHANGE IMPACT ANALYSIS" report `shouldBe` True
T.isInfixOf "LEVEL 1: TRIVIA" report `shouldBe` True
T.isInfixOf "Safe to skip CI build" report `shouldBe` True
it "formats human-readable diagnostic report for SeverityInternalLogic" $ do
let sliceLogic = ImpactSlice "util.py" SeverityInternalLogic [] ["util.py"] ["test_util.py"] 80.0
report = formatImpactSlice sliceLogic
T.isInfixOf "LEVEL 2: INTERNAL LOGIC" report `shouldBe` True
T.isInfixOf "Run targeted local unit tests only" report `shouldBe` True
it "formats human-readable diagnostic report for SeverityInterface" $ do
let sliceInterface = ImpactSlice "api.py" SeverityInterface [] ["api.py", "client.py"] ["test_api.py", "test_client.py"] 50.0
report = formatImpactSlice sliceInterface
T.isInfixOf "LEVEL 3: PUBLIC INTERFACE" report `shouldBe` True
T.isInfixOf "Run transitive test slice" report `shouldBe` True
it "formats human-readable diagnostic report for SeverityDependency" $ do
let sliceDep = ImpactSlice "lib.py" SeverityDependency [] ["lib.py"] ["test_lib.py"] 90.0
report = formatImpactSlice sliceDep
T.isInfixOf "LEVEL 3: DEPENDENCY" report `shouldBe` True
T.isInfixOf "Run transitive dependency slice" report `shouldBe` True
it "does not invalidate unrelated disconnected modules" $ do
let modA = "def a(): return 1\n"
modB = "def b(): return 2\n"
modAMut = "def a(x): return x\n"
case ( parsePolyglotSource "a.py" modA
, parsePolyglotSource "b.py" modB
, computeBundle "a.py" (TE.encodeUtf8 modA) modA
, computeBundle "a.py" (TE.encodeUtf8 modAMut) modAMut
) of
(Right pA, Right pB, Right bOld, Right bNew) -> do
let wcg = buildWholeRepoCallGraph [("a.py", pA), ("b.py", pB)]
slice = computeImpactSlice "a.py" bOld bNew wcg ["a.py", "b.py"]
"b.py" `elem` impactTransitiveFiles slice `shouldBe` False
_ -> expectationFailure "Setup failed"
it "invalidates all callers when a shared dependency interface changes" $ do
let shared = "def helper(): return 1\n"
sharedMut = "def helper(arg): return arg\n"
caller1 = "import shared\ndef call1(): return shared.helper()\n"
caller2 = "import shared\ndef call2(): return shared.helper()\n"
case ( parsePolyglotSource "shared.py" shared
, parsePolyglotSource "c1.py" caller1
, parsePolyglotSource "c2.py" caller2
, computeBundle "shared.py" (TE.encodeUtf8 shared) shared
, computeBundle "shared.py" (TE.encodeUtf8 sharedMut) sharedMut
) of
(Right pS, Right pC1, Right pC2, Right bOld, Right bNew) -> do
let wcg = buildWholeRepoCallGraph [("shared.py", pS), ("c1.py", pC1), ("c2.py", pC2)]
slice = computeImpactSlice "shared.py" bOld bNew wcg ["shared.py", "c1.py", "c2.py"]
"c1.py" `elem` impactTransitiveFiles slice `shouldBe` True
"c2.py" `elem` impactTransitiveFiles slice `shouldBe` True
_ -> expectationFailure "Setup failed"