canontra-0.1.0.0: test/Canontra/WholeRepoGraphSpec.hs
{- |
Module : Canontra.WholeRepoGraphSpec
Description : Test specification for whole-repository inter-module call graph and data-flow synthesis.
Validates cross-module symbol resolution, inter-module call graph edges (F_WCG),
cycle-collapsed SCCs via Tarjan's algorithm, inter-procedural data-flow (F_WDF),
dead symbol identification, and multi-tier whole-repository fingerprint determinism.
-}
{-# LANGUAGE OverloadedStrings #-}
module Canontra.WholeRepoGraphSpec (spec) where
import qualified Data.Text as T
import Test.Hspec
import Canontra.Analysis.WholeRepoGraph
import Canontra.Fingerprint.WholeRepoCallGraph (computeFWCG)
import Canontra.Fingerprint.WholeRepoDataFlow (computeFWDF)
import Canontra.Parser.Polyglot (parsePolyglotSource)
import Canontra.Repository.Repository (computeWholeRepoBundle)
import Canontra.Types
spec :: Spec
spec = do
describe "Module Path Canonicalization" $ do
it "canonicalizes relative file paths into dotted module names" $ do
filePathToModuleName "auth/jwt.py" `shouldBe` "auth.jwt"
filePathToModuleName "src/core/math.rs" `shouldBe` "src.core.math"
filePathToModuleName "api/v1/handler.go" `shouldBe` "api.v1.handler"
filePathToModuleName "pkg/subpkg/__init__.py" `shouldBe` "pkg.subpkg"
filePathToModuleName "app\\service\\worker.py" `shouldBe` "app.service.worker"
describe "Cross-Module Call Graph Synthesis (F_WCG)" $ do
let authJwtSrc = T.unlines
[ "def verify_token(token: str) -> bool:"
, " return len(token) > 10"
, ""
, "def decode_token(token: str):"
, " return token"
, ""
, "def unused_helper():"
, " return 42"
]
let appServiceSrc = T.unlines
[ "import auth.jwt as jwt"
, ""
, "def handle_request(req):"
, " valid = jwt.verify_token(req)"
, " return valid"
]
it "extracts global symbols with correct module namespaces and kinds" $ do
case (parsePolyglotSource "auth/jwt.py" authJwtSrc, parsePolyglotSource "app/service.py" appServiceSrc) of
(Right progJwt, Right progSvc) -> do
let modules = [("auth/jwt.py", progJwt), ("app/service.py", progSvc)]
wcg = buildWholeRepoCallGraph modules
symNames = map symDeclName (wcgNodes wcg)
"verify_token" `elem` symNames `shouldBe` True
"decode_token" `elem` symNames `shouldBe` True
"unused_helper" `elem` symNames `shouldBe` True
"handle_request" `elem` symNames `shouldBe` True
(Left e1, _) -> expectationFailure (show e1)
(_, Left e2) -> expectationFailure (show e2)
it "resolves cross-module caller-to-callee edges" $ do
case (parsePolyglotSource "auth/jwt.py" authJwtSrc, parsePolyglotSource "app/service.py" appServiceSrc) of
(Right progJwt, Right progSvc) -> do
let modules = [("auth/jwt.py", progJwt), ("app/service.py", progSvc)]
wcg = buildWholeRepoCallGraph modules
crossEdges = findCrossModuleEdges wcg
length crossEdges `shouldSatisfy` (> 0)
let hasSvcToJwt = any (\e ->
symDeclName (wceCaller e) == "handle_request" &&
symModule (wceCaller e) == "app.service" &&
symDeclName (wceCallee e) == "verify_token" &&
symModule (wceCallee e) == "auth.jwt" &&
wceIsCrossMod e
) crossEdges
hasSvcToJwt `shouldBe` True
(Left e1, _) -> expectationFailure (show e1)
(_, Left e2) -> expectationFailure (show e2)
it "identifies dead symbols with zero callers across the entire repository" $ do
case (parsePolyglotSource "auth/jwt.py" authJwtSrc, parsePolyglotSource "app/service.py" appServiceSrc) of
(Right progJwt, Right progSvc) -> do
let modules = [("auth/jwt.py", progJwt), ("app/service.py", progSvc)]
wcg = buildWholeRepoCallGraph modules
deadSyms = findDeadSymbols wcg
deadNames = map symDeclName deadSyms
"unused_helper" `elem` deadNames `shouldBe` True
"decode_token" `elem` deadNames `shouldBe` True
"verify_token" `elem` deadNames `shouldBe` False
(Left e1, _) -> expectationFailure (show e1)
(_, Left e2) -> expectationFailure (show e2)
describe "Cross-Module Recursion & Cycle Detection (Tarjan SCC)" $ do
let svcA = T.unlines
[ "import service_b as b"
, ""
, "def func_a(n):"
, " if n <= 0: return 0"
, " return b.func_b(n - 1)"
]
let svcB = T.unlines
[ "import service_a as a"
, ""
, "def func_b(n):"
, " if n <= 0: return 0"
, " return a.func_a(n - 1)"
]
it "detects cross-module circular call cycles via Tarjan's SCC" $ do
case (parsePolyglotSource "service_a.py" svcA, parsePolyglotSource "service_b.py" svcB) of
(Right pA, Right pB) -> do
let modules = [("service_a.py", pA), ("service_b.py", pB)]
wcg = buildWholeRepoCallGraph modules
sccs = findWholeRepoSCCs wcg
cycles = filter (\c -> length c > 1) sccs
length cycles `shouldSatisfy` (>= 1)
let cycleNames = map symDeclName (head cycles)
"func_a" `elem` cycleNames `shouldBe` True
"func_b" `elem` cycleNames `shouldBe` True
(Left e1, _) -> expectationFailure (show e1)
(_, Left e2) -> expectationFailure (show e2)
describe "Inter-Procedural Data-Flow Synthesis (F_WDF)" $ do
let srcCalc = T.unlines
[ "def compute(x: int, y: int) -> int:"
, " return x + y"
]
let srcMain = T.unlines
[ "import calc"
, ""
, "def run_calc(val):"
, " res = calc.compute(val, 10)"
, " return res"
]
it "synthesizes cross-module argument bindings and return-flow edges" $ do
case (parsePolyglotSource "calc.py" srcCalc, parsePolyglotSource "main.py" srcMain) of
(Right pCalc, Right pMain) -> do
let modules = [("calc.py", pCalc), ("main.py", pMain)]
wdf = buildWholeRepoDataFlow modules
edges = wdfEdges wdf
length edges `shouldSatisfy` (> 0)
let hasParam0 = any (\e ->
symDeclName (ipdfSourceSymbol e) == "run_calc" &&
symDeclName (ipdfTargetSymbol e) == "compute" &&
ipdfParamIndex e == 0 &&
not (ipdfIsReturnFlow e)
) edges
let hasReturn = any (\e ->
symDeclName (ipdfSourceSymbol e) == "compute" &&
symDeclName (ipdfTargetSymbol e) == "run_calc" &&
ipdfIsReturnFlow e
) edges
hasParam0 `shouldBe` True
hasReturn `shouldBe` True
(Left e1, _) -> expectationFailure (show e1)
(_, Left e2) -> expectationFailure (show e2)
describe "Deterministic Hashing & Invariance Theorems" $ do
let authSrc = T.unlines
[ "def login(user, pwd):"
, " return user == 'admin'"
]
let authMutated = T.unlines
[ "# Formatted version with comment churn"
, "def login( user , pwd ) :"
, " # Verify credentials"
, " \"\"\"Docstring comment\"\"\""
, " return user == 'admin'"
]
let appSrc = T.unlines
[ "import auth"
, ""
, "def auth_handler(u, p):"
, " return auth.login(u, p)"
]
it "guarantees F_WCG and F_WDF invariance under whitespace, comments, and trivia" $ do
case ( parsePolyglotSource "auth.py" authSrc
, parsePolyglotSource "auth.py" authMutated
, parsePolyglotSource "app.py" appSrc
) of
(Right pAuth1, Right pAuth2, Right pApp) -> do
let mods1 = [("auth.py", pAuth1), ("app.py", pApp)]
mods2 = [("auth.py", pAuth2), ("app.py", pApp)]
fwcg1 = computeFWCG mods1
fwcg2 = computeFWCG mods2
fwdf1 = computeFWDF mods1
fwdf2 = computeFWDF mods2
fwcg1 `shouldBe` fwcg2
fwdf1 `shouldBe` fwdf2
_ -> expectationFailure "Parse failure in invariance test"
it "sensitively mutates F_WCG when a cross-module target function is changed" $ do
let appSrcMutated = T.unlines
[ "import auth"
, ""
, "def auth_handler(u, p):"
, " return auth.other_func(u, p)"
]
case ( parsePolyglotSource "auth.py" authSrc
, parsePolyglotSource "app.py" appSrc
, parsePolyglotSource "app.py" appSrcMutated
) of
(Right pAuth, Right pApp1, Right pApp2) -> do
let mods1 = [("auth.py", pAuth), ("app.py", pApp1)]
mods2 = [("auth.py", pAuth), ("app.py", pApp2)]
fwcg1 = computeFWCG mods1
fwcg2 = computeFWCG mods2
fwcg1 `shouldNotBe` fwcg2
_ -> expectationFailure "Parse failure in sensitivity test"
describe "WholeRepoBundle Composition" $ do
it "constructs a valid WholeRepoBundle with distinct orthogonal hashes" $ do
let srcM1 = "def f(): return 1"
srcM2 = "import m1\ndef g(): return m1.f()"
case (parsePolyglotSource "m1.py" srcM1, parsePolyglotSource "m2.py" srcM2) of
(Right p1, Right p2) -> do
let progs = [("m1.py", p1), ("m2.py", p2)]
entries =
[ FileEntry "m1.py" (FingerprintBundle (Fingerprint "s1") (Fingerprint "st1") (Fingerprint "d1") (Fingerprint "dp1") (Fingerprint "cg1") (Fingerprint "cf1") (Fingerprint "df1") (Fingerprint "t1") (Fingerprint "c1"))
, FileEntry "m2.py" (FingerprintBundle (Fingerprint "s2") (Fingerprint "st2") (Fingerprint "d2") (Fingerprint "dp2") (Fingerprint "cg2") (Fingerprint "cf2") (Fingerprint "df2") (Fingerprint "t2") (Fingerprint "c2"))
]
wrb = computeWholeRepoBundle progs entries
unFingerprint (wrbRepositoryHash wrb) `shouldNotBe` ""
unFingerprint (wrbCallGraph wrb) `shouldNotBe` ""
unFingerprint (wrbDataFlow wrb) `shouldNotBe` ""
unFingerprint (wrbComposite wrb) `shouldNotBe` ""
wrbCallGraph wrb `shouldNotBe` wrbDataFlow wrb
_ -> expectationFailure "Parse failure in WholeRepoBundle test"
describe "Polyglot Cross-Module Integration" $ do
let tsMath = T.unlines
[ "export function add(a: number, b: number): number {"
, " return a + b;"
, "}"
]
let tsMain = T.unlines
[ "import { add } from './math';"
, "export function run(): number {"
, " return add(5, 10);"
, "}"
]
it "resolves cross-module calls in TypeScript" $ do
case (parsePolyglotSource "math.ts" tsMath, parsePolyglotSource "main.ts" tsMain) of
(Right pMath, Right pMain) -> do
let modules = [("math.ts", pMath), ("main.ts", pMain)]
wcg = buildWholeRepoCallGraph modules
edges = wcgEdges wcg
let hasTsCall = any (\e ->
symDeclName (wceCaller e) == "run" &&
symDeclName (wceCallee e) == "add"
) edges
hasTsCall `shouldBe` True
(Left e1, _) -> expectationFailure (show e1)
(_, Left e2) -> expectationFailure (show e2)
describe "Extended Whole-Repository Topologies & Determinism" $ do
it "resolves a 3-hop linear transitive call chain (A -> B -> C)" $ do
let srcC = "def leaf(): return 100\n"
srcB = "import mod_c\ndef middle(): return mod_c.leaf()\n"
srcA = "import mod_b\ndef top(): return mod_b.middle()\n"
case (parsePolyglotSource "mod_c.py" srcC, parsePolyglotSource "mod_b.py" srcB, parsePolyglotSource "mod_a.py" srcA) of
(Right pC, Right pB, Right pA) -> do
let wcg = buildWholeRepoCallGraph [("mod_c.py", pC), ("mod_b.py", pB), ("mod_a.py", pA)]
edges = wcgEdges wcg
length edges `shouldSatisfy` (>= 2)
let hasAB = any (\e -> symDeclName (wceCaller e) == "top" && symDeclName (wceCallee e) == "middle") edges
hasBC = any (\e -> symDeclName (wceCaller e) == "middle" && symDeclName (wceCallee e) == "leaf") edges
hasAB `shouldBe` True
hasBC `shouldBe` True
_ -> expectationFailure "Parse failed"
it "resolves diamond dependency calling topology (A -> B, A -> C, B -> D, C -> D)" $ do
let srcD = "def base(): return 1\n"
srcB = "import d\ndef left(): return d.base()\n"
srcC = "import d\ndef right(): return d.base()\n"
srcA = "import b\nimport c\ndef root(): return b.left() + c.right()\n"
case (parsePolyglotSource "d.py" srcD, parsePolyglotSource "b.py" srcB, parsePolyglotSource "c.py" srcC, parsePolyglotSource "a.py" srcA) of
(Right pD, Right pB, Right pC, Right pA) -> do
let wcg = buildWholeRepoCallGraph [("d.py", pD), ("b.py", pB), ("c.py", pC), ("a.py", pA)]
crossEdges = findCrossModuleEdges wcg
length crossEdges `shouldSatisfy` (>= 4)
_ -> expectationFailure "Parse failed"
it "detects 3-node cyclic recursion across modules via Tarjan SCC" $ do
let srcA = "import b\ndef loop_a(n): return b.loop_b(n - 1) if n > 0 else 0\n"
srcB = "import c\ndef loop_b(n): return c.loop_c(n - 1) if n > 0 else 0\n"
srcC = "import a\ndef loop_c(n): return a.loop_a(n - 1) if n > 0 else 0\n"
case (parsePolyglotSource "a.py" srcA, parsePolyglotSource "b.py" srcB, parsePolyglotSource "c.py" srcC) of
(Right pA, Right pB, Right pC) -> do
let wcg = buildWholeRepoCallGraph [("a.py", pA), ("b.py", pB), ("c.py", pC)]
sccs = wcgSCCs wcg
cycleSCCs = filter (\s -> length s >= 3) sccs
length cycleSCCs `shouldBe` 1
_ -> expectationFailure "Parse failed"
it "handles empty repository gracefully returning empty WCG" $ do
let wcg = buildWholeRepoCallGraph []
wcgNodes wcg `shouldBe` []
wcgEdges wcg `shouldBe` []
wcgSCCs wcg `shouldBe` []
it "handles single-file repository without cross-module edges" $ do
let src = "def hello(): return 1\ndef world(): return hello()\n"
case parsePolyglotSource "single.py" src of
Right p -> do
let wcg = buildWholeRepoCallGraph [("single.py", p)]
length (wcgNodes wcg) `shouldBe` 2
findCrossModuleEdges wcg `shouldBe` []
Left err -> expectationFailure (show err)
it "handles disconnected independent modules with 0 cross edges" $ do
let src1 = "def worker1(): return 1\n"
src2 = "def worker2(): return 2\n"
case (parsePolyglotSource "w1.py" src1, parsePolyglotSource "w2.py" src2) of
(Right p1, Right p2) -> do
let wcg = buildWholeRepoCallGraph [("w1.py", p1), ("w2.py", p2)]
length (wcgNodes wcg) `shouldBe` 2
findCrossModuleEdges wcg `shouldBe` []
_ -> expectationFailure "Parse failed"
it "tolerates external standard library imports without creating broken nodes" $ do
let src = "import os\nimport sys\nimport json\ndef run(): return os.path.exists('file')\n"
case parsePolyglotSource "ext.py" src of
Right p -> do
let wcg = buildWholeRepoCallGraph [("ext.py", p)]
let localNames = map symDeclName (wcgNodes wcg)
"run" `elem` localNames `shouldBe` True
Left err -> expectationFailure (show err)
it "resolves multiple internal callers to the same imported callee" $ do
let srcLib = "def common(): return 42\n"
srcApp = "import lib\ndef caller1(): return lib.common()\ndef caller2(): return lib.common()\n"
case (parsePolyglotSource "lib.py" srcLib, parsePolyglotSource "app.py" srcApp) of
(Right pLib, Right pApp) -> do
let wcg = buildWholeRepoCallGraph [("lib.py", pLib), ("app.py", pApp)]
edges = wcgEdges wcg
commonCalls = filter (\e -> symDeclName (wceCallee e) == "common") edges
length commonCalls `shouldBe` 2
_ -> expectationFailure "Parse failed"
it "resolves a single caller invoking multiple distinct imported callees" $ do
let srcA = "def get_x(): return 1\ndef get_y(): return 2\n"
srcB = "import a\ndef combine(): return a.get_x() + a.get_y()\n"
case (parsePolyglotSource "a.py" srcA, parsePolyglotSource "b.py" srcB) of
(Right pA, Right pB) -> do
let wcg = buildWholeRepoCallGraph [("a.py", pA), ("b.py", pB)]
edges = wcgEdges wcg
fromCombine = filter (\e -> symDeclName (wceCaller e) == "combine") edges
length fromCombine `shouldBe` 2
_ -> expectationFailure "Parse failed"
it "guarantees order invariance: permuting module input order yields identical F_WCG" $ do
let s1 = "def f1(): return 1\n"
s2 = "import m1\ndef f2(): return m1.f1()\n"
s3 = "import m2\ndef f3(): return m2.f2()\n"
case (parsePolyglotSource "m1.py" s1, parsePolyglotSource "m2.py" s2, parsePolyglotSource "m3.py" s3) of
(Right p1, Right p2, Right p3) -> do
let modsForward = [("m1.py", p1), ("m2.py", p2), ("m3.py", p3)]
modsReverse = [("m3.py", p3), ("m2.py", p2), ("m1.py", p1)]
fwcg1 = computeFWCG modsForward
fwcg2 = computeFWCG modsReverse
fwcg1 `shouldBe` fwcg2
_ -> expectationFailure "Parse failed"
it "guarantees order invariance: permuting module input order yields identical F_WDF" $ do
let s1 = "def f1(x): return x\n"
s2 = "import m1\ndef f2(y): return m1.f1(y)\n"
case (parsePolyglotSource "m1.py" s1, parsePolyglotSource "m2.py" s2) of
(Right p1, Right p2) -> do
let modsA = [("m1.py", p1), ("m2.py", p2)]
modsB = [("m2.py", p2), ("m1.py", p1)]
fwdfA = computeFWDF modsA
fwdfB = computeFWDF modsB
fwdfA `shouldBe` fwdfB
_ -> expectationFailure "Parse failed"
it "Go: resolves package-level functions across separate files" $ do
let goUtil = "package main\nfunc Helper() int { return 99 }\n"
goMain = "package main\nfunc Start() int { return Helper() }\n"
case (parsePolyglotSource "util.go" goUtil, parsePolyglotSource "main.go" goMain) of
(Right pUtil, Right pMain) -> do
let wcg = buildWholeRepoCallGraph [("util.go", pUtil), ("main.go", pMain)]
syms = map symDeclName (wcgNodes wcg)
"Helper" `elem` syms `shouldBe` True
"Start" `elem` syms `shouldBe` True
_ -> expectationFailure "Parse failed"
it "Rust: resolves functions across multi-file crates" $ do
let rsMath = "pub fn add(a: i32, b: i32) -> i32 { a + b }\n"
rsMain = "mod math;\nfn run() -> i32 { math::add(1, 2) }\n"
case (parsePolyglotSource "math.rs" rsMath, parsePolyglotSource "main.rs" rsMain) of
(Right pMath, Right pMain) -> do
let wcg = buildWholeRepoCallGraph [("math.rs", pMath), ("main.rs", pMain)]
syms = map symDeclName (wcgNodes wcg)
"add" `elem` syms `shouldBe` True
"run" `elem` syms `shouldBe` True
_ -> expectationFailure "Parse failed"
it "sensitively alters F_WDF when an inter-procedural argument expression changes" $ do
let sA = "def compute(x): return x * 2\n"
sB1 = "import a\ndef run(arg): return a.compute(arg)\n"
sB2 = "import a\ndef run(): return a.compute(10)\n"
case (parsePolyglotSource "a.py" sA, parsePolyglotSource "b.py" sB1, parsePolyglotSource "b.py" sB2) of
(Right pA, Right pB1, Right pB2) -> do
let fwdf1 = computeFWDF [("a.py", pA), ("b.py", pB1)]
fwdf2 = computeFWDF [("a.py", pA), ("b.py", pB2)]
fwdf1 `shouldNotBe` fwdf2
_ -> expectationFailure "Parse failed"