Skip to content

Commit 2fdc452

Browse files
committed
feat: add dead code static analysis pass with fixtures
Add a new static analysis pass that detects: - Unused variables (assigned but never read) - Unused function/method parameters - Unreachable code (after return, throw, break, continue) Includes PHP fixture files for testing and HSpec test suite.
1 parent 2ace549 commit 2fdc452

7 files changed

Lines changed: 708 additions & 0 deletions

File tree

sanctify-php.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ library
3232
Sanctify.Analysis.Security
3333
Sanctify.Analysis.Types
3434
Sanctify.Analysis.Taint
35+
Sanctify.Analysis.DeadCode
3536
Sanctify.Transform.StrictTypes
3637
Sanctify.Transform.TypeHints
3738
Sanctify.Transform.Sanitize

src/Sanctify/Analysis/DeadCode.hs

Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
-- | Dead code analysis for PHP
2+
-- Detects unused variables and unreachable code
3+
-- SPDX-License-Identifier: AGPL-3.0-or-later
4+
module Sanctify.Analysis.DeadCode
5+
( -- * Main analysis
6+
analyzeDeadCode
7+
, DeadCodeIssue(..)
8+
, DeadCodeType(..)
9+
10+
-- * Specific checks
11+
, findUnusedVariables
12+
, findUnreachableCode
13+
) where
14+
15+
import Data.Text (Text)
16+
import qualified Data.Text as T
17+
import Data.Set (Set)
18+
import qualified Data.Set as Set
19+
import Data.Map.Strict (Map)
20+
import qualified Data.Map.Strict as Map
21+
import Control.Monad.State.Strict
22+
import GHC.Generics (Generic)
23+
import Data.Aeson (ToJSON)
24+
25+
import Sanctify.AST
26+
27+
-- | Types of dead code issues
28+
data DeadCodeType
29+
= UnusedVariable -- ^ Variable declared but never used
30+
| UnreachableCode -- ^ Code after return/throw/exit
31+
| UnusedParameter -- ^ Function parameter never used
32+
| UnusedImport -- ^ Use statement never referenced
33+
deriving stock (Eq, Show, Generic)
34+
deriving anyclass (ToJSON)
35+
36+
-- | A detected dead code issue
37+
data DeadCodeIssue = DeadCodeIssue
38+
{ dcType :: DeadCodeType
39+
, dcLocation :: SourcePos
40+
, dcDescription :: Text
41+
, dcIdentifier :: Text -- The name of the unused variable/etc.
42+
}
43+
deriving stock (Eq, Show, Generic)
44+
deriving anyclass (ToJSON)
45+
46+
-- | Analysis state for tracking variable usage
47+
data AnalysisState = AnalysisState
48+
{ asDeclared :: Map Text SourcePos -- ^ Variables that have been declared/assigned
49+
, asUsed :: Set Text -- ^ Variables that have been read
50+
, asIssues :: [DeadCodeIssue] -- ^ Accumulated issues
51+
}
52+
deriving stock (Eq, Show)
53+
54+
type AnalysisM = State AnalysisState
55+
56+
-- | Initial analysis state
57+
initialState :: AnalysisState
58+
initialState = AnalysisState Map.empty Set.empty []
59+
60+
-- | Analyze a PHP file for dead code issues
61+
analyzeDeadCode :: PhpFile -> [DeadCodeIssue]
62+
analyzeDeadCode file =
63+
let finalState = execState (analyzeStatements (phpStatements file)) initialState
64+
unusedVars = findUnusedFromState finalState
65+
in unusedVars ++ asIssues finalState
66+
67+
-- | Find unused variables from the final state
68+
findUnusedFromState :: AnalysisState -> [DeadCodeIssue]
69+
findUnusedFromState state =
70+
let declared = asDeclared state
71+
used = asUsed state
72+
unused = Map.filterWithKey (\k _ -> not (Set.member k used)) declared
73+
in map mkUnusedIssue (Map.toList unused)
74+
where
75+
mkUnusedIssue (name, pos) = DeadCodeIssue
76+
{ dcType = UnusedVariable
77+
, dcLocation = pos
78+
, dcDescription = "Variable '$" <> name <> "' is assigned but never used"
79+
, dcIdentifier = name
80+
}
81+
82+
-- | Analyze a list of statements, detecting unreachable code
83+
analyzeStatements :: [Located Statement] -> AnalysisM ()
84+
analyzeStatements [] = pure ()
85+
analyzeStatements (stmt:rest) = do
86+
analyzeStatement stmt
87+
-- Check if current statement is a terminator
88+
when (isTerminator (locNode stmt)) $ do
89+
-- Mark remaining statements as unreachable
90+
forM_ rest $ \(Located pos s) ->
91+
unless (isNoop s) $
92+
addIssue DeadCodeIssue
93+
{ dcType = UnreachableCode
94+
, dcLocation = pos
95+
, dcDescription = "Unreachable code after " <> terminatorName (locNode stmt)
96+
, dcIdentifier = ""
97+
}
98+
-- Continue analyzing rest only if not a terminator
99+
unless (isTerminator (locNode stmt)) $
100+
analyzeStatements rest
101+
102+
-- | Check if a statement is a control flow terminator
103+
isTerminator :: Statement -> Bool
104+
isTerminator = \case
105+
StmtReturn _ -> True
106+
StmtThrow _ -> True
107+
StmtBreak _ -> True
108+
StmtContinue _ -> True
109+
_ -> False
110+
111+
-- | Get the name of the terminator for error messages
112+
terminatorName :: Statement -> Text
113+
terminatorName = \case
114+
StmtReturn _ -> "return statement"
115+
StmtThrow _ -> "throw statement"
116+
StmtBreak _ -> "break statement"
117+
StmtContinue _ -> "continue statement"
118+
_ -> "terminating statement"
119+
120+
-- | Check if a statement is a no-op (empty statement)
121+
isNoop :: Statement -> Bool
122+
isNoop StmtNoop = True
123+
isNoop _ = False
124+
125+
-- | Add an issue to the state
126+
addIssue :: DeadCodeIssue -> AnalysisM ()
127+
addIssue issue = modify' $ \s -> s { asIssues = issue : asIssues s }
128+
129+
-- | Record that a variable was declared/assigned
130+
declareVar :: Text -> SourcePos -> AnalysisM ()
131+
declareVar name pos = modify' $ \s ->
132+
s { asDeclared = Map.insert name pos (asDeclared s) }
133+
134+
-- | Record that a variable was used/read
135+
useVar :: Text -> AnalysisM ()
136+
useVar name = modify' $ \s ->
137+
s { asUsed = Set.insert name (asUsed s) }
138+
139+
-- | Analyze a single statement
140+
analyzeStatement :: Located Statement -> AnalysisM ()
141+
analyzeStatement (Located pos stmt) = case stmt of
142+
StmtExpr expr -> analyzeExpr expr
143+
144+
StmtIf cond thenStmts elseStmts -> do
145+
analyzeExpr cond
146+
-- Analyze branches in isolated scopes for unreachable code
147+
analyzeStatements thenStmts
148+
maybe (pure ()) analyzeStatements elseStmts
149+
150+
StmtWhile cond body -> do
151+
analyzeExpr cond
152+
analyzeStatements body
153+
154+
StmtFor init cond update body -> do
155+
maybe (pure ()) analyzeExpr init
156+
maybe (pure ()) analyzeExpr cond
157+
maybe (pure ()) analyzeExpr update
158+
analyzeStatements body
159+
160+
StmtForeach expr keyVar valVar body -> do
161+
analyzeExpr expr
162+
-- The foreach variables are declared
163+
declareVar (varName keyVar) pos
164+
maybe (pure ()) (\v -> declareVar (varName v) pos) valVar
165+
analyzeStatements body
166+
167+
StmtSwitch expr cases -> do
168+
analyzeExpr expr
169+
forM_ cases $ \c -> do
170+
maybe (pure ()) analyzeExpr (caseExpr c)
171+
analyzeStatements (caseBody c)
172+
173+
StmtMatch expr arms -> do
174+
analyzeExpr expr
175+
forM_ arms $ \arm -> do
176+
mapM_ analyzeExpr (matchConditions arm)
177+
analyzeExpr (matchResult arm)
178+
179+
StmtTry tryBody catches finally -> do
180+
analyzeStatements tryBody
181+
forM_ catches $ \c -> do
182+
-- Catch variable is declared
183+
maybe (pure ()) (\v -> declareVar (varName v) pos) (catchVar c)
184+
analyzeStatements (catchBody c)
185+
maybe (pure ()) analyzeStatements finally
186+
187+
StmtReturn mexpr -> maybe (pure ()) analyzeExpr mexpr
188+
189+
StmtThrow expr -> analyzeExpr expr
190+
191+
StmtEcho exprs -> mapM_ analyzeExpr exprs
192+
193+
StmtGlobal vars -> forM_ vars $ \v -> useVar (varName v)
194+
195+
StmtStatic pairs -> forM_ pairs $ \(v, mexpr) -> do
196+
declareVar (varName v) pos
197+
maybe (pure ()) analyzeExpr mexpr
198+
199+
StmtUnset exprs -> mapM_ analyzeExpr exprs
200+
201+
StmtDecl decl -> analyzeDeclaration pos decl
202+
203+
StmtDeclare _ body -> analyzeStatements body
204+
205+
_ -> pure ()
206+
207+
-- | Analyze a declaration
208+
analyzeDeclaration :: SourcePos -> Declaration -> AnalysisM ()
209+
analyzeDeclaration pos decl = case decl of
210+
DeclFunction{fnParams = params, fnBody = body} -> do
211+
-- Track parameters
212+
let paramState = foldl' (\m p -> Map.insert (varName (paramName p)) pos m) Map.empty params
213+
-- Analyze body with fresh state for unused parameter detection
214+
oldState <- get
215+
put $ initialState { asDeclared = paramState }
216+
analyzeStatements body
217+
newState <- get
218+
-- Report unused parameters
219+
let unusedParams = Map.filterWithKey
220+
(\k _ -> not (Set.member k (asUsed newState)))
221+
paramState
222+
forM_ (Map.toList unusedParams) $ \(name, ppos) ->
223+
addIssue DeadCodeIssue
224+
{ dcType = UnusedParameter
225+
, dcLocation = ppos
226+
, dcDescription = "Parameter '$" <> name <> "' is never used"
227+
, dcIdentifier = name
228+
}
229+
-- Restore outer state, keeping issues
230+
put $ oldState { asIssues = asIssues newState ++ asIssues oldState }
231+
232+
DeclClass{clsMembers = members} ->
233+
forM_ members (analyzeClassMember pos)
234+
235+
_ -> pure ()
236+
237+
-- | Analyze class members
238+
analyzeClassMember :: SourcePos -> ClassMember -> AnalysisM ()
239+
analyzeClassMember pos member = case member of
240+
MemberMethod{methParams = params, methBody = Just body} -> do
241+
let paramState = foldl' (\m p -> Map.insert (varName (paramName p)) pos m) Map.empty params
242+
oldState <- get
243+
put $ initialState { asDeclared = paramState }
244+
analyzeStatements body
245+
newState <- get
246+
-- Report unused parameters
247+
let unusedParams = Map.filterWithKey
248+
(\k _ -> not (Set.member k (asUsed newState)))
249+
paramState
250+
forM_ (Map.toList unusedParams) $ \(name, ppos) ->
251+
addIssue DeadCodeIssue
252+
{ dcType = UnusedParameter
253+
, dcLocation = ppos
254+
, dcDescription = "Parameter '$" <> name <> "' is never used"
255+
, dcIdentifier = name
256+
}
257+
put $ oldState { asIssues = asIssues newState ++ asIssues oldState }
258+
259+
MemberProperty{propDefault = Just expr} -> analyzeExpr expr
260+
261+
_ -> pure ()
262+
263+
-- | Analyze an expression, tracking variable usage
264+
analyzeExpr :: Located Expr -> AnalysisM ()
265+
analyzeExpr (Located pos expr) = case expr of
266+
ExprVariable (Variable name) ->
267+
-- This is a variable read
268+
useVar name
269+
270+
ExprAssign target value -> do
271+
-- Check if target is a simple variable assignment
272+
case locNode target of
273+
ExprVariable (Variable name) -> declareVar name pos
274+
_ -> analyzeExpr target
275+
analyzeExpr value
276+
277+
ExprAssignOp _ target value -> do
278+
-- Compound assignment both reads and writes
279+
analyzeExpr target
280+
analyzeExpr value
281+
282+
ExprBinary _ left right -> do
283+
analyzeExpr left
284+
analyzeExpr right
285+
286+
ExprUnary _ operand -> analyzeExpr operand
287+
288+
ExprTernary cond mtrue false -> do
289+
analyzeExpr cond
290+
maybe (pure ()) analyzeExpr mtrue
291+
analyzeExpr false
292+
293+
ExprCall callee args -> do
294+
analyzeExpr callee
295+
mapM_ (analyzeExpr . argValue) args
296+
297+
ExprMethodCall obj _ args -> do
298+
analyzeExpr obj
299+
mapM_ (analyzeExpr . argValue) args
300+
301+
ExprStaticCall _ _ args ->
302+
mapM_ (analyzeExpr . argValue) args
303+
304+
ExprNullsafeMethodCall obj _ args -> do
305+
analyzeExpr obj
306+
mapM_ (analyzeExpr . argValue) args
307+
308+
ExprPropertyAccess obj _ -> analyzeExpr obj
309+
310+
ExprNullsafePropertyAccess obj _ -> analyzeExpr obj
311+
312+
ExprArrayAccess base mindex -> do
313+
analyzeExpr base
314+
maybe (pure ()) analyzeExpr mindex
315+
316+
ExprNew _ args ->
317+
mapM_ (analyzeExpr . argValue) args
318+
319+
ExprClosure{closureUses = uses, closureBody = body} -> do
320+
-- Variables in use() clause are used in outer scope
321+
forM_ uses $ \(v, _) -> useVar (varName v)
322+
analyzeStatements body
323+
324+
ExprArrowFunction{arrowExpr = e} -> analyzeExpr e
325+
326+
ExprCast _ e -> analyzeExpr e
327+
328+
ExprIsset exprs -> mapM_ analyzeExpr exprs
329+
330+
ExprEmpty e -> analyzeExpr e
331+
332+
ExprEval e -> analyzeExpr e
333+
334+
ExprInclude _ e -> analyzeExpr e
335+
336+
ExprYield mkey mval -> do
337+
maybe (pure ()) analyzeExpr mkey
338+
maybe (pure ()) analyzeExpr mval
339+
340+
ExprYieldFrom e -> analyzeExpr e
341+
342+
ExprThrow e -> analyzeExpr e
343+
344+
ExprLiteral (LitArray pairs) ->
345+
forM_ pairs $ \(mkey, val) -> do
346+
maybe (pure ()) analyzeExpr mkey
347+
analyzeExpr val
348+
349+
ExprList exprs ->
350+
forM_ exprs $ maybe (pure ()) analyzeExpr
351+
352+
_ -> pure ()
353+
354+
-- | Find unused variables in a PHP file (convenience function)
355+
findUnusedVariables :: PhpFile -> [DeadCodeIssue]
356+
findUnusedVariables = filter isUnusedVar . analyzeDeadCode
357+
where
358+
isUnusedVar issue = dcType issue `elem` [UnusedVariable, UnusedParameter]
359+
360+
-- | Find unreachable code in a PHP file (convenience function)
361+
findUnreachableCode :: PhpFile -> [DeadCodeIssue]
362+
findUnreachableCode = filter isUnreachable . analyzeDeadCode
363+
where
364+
isUnreachable issue = dcType issue == UnreachableCode

0 commit comments

Comments
 (0)