packages feed

sydtest-mutation-plugin 0.4.5.0 → 0.4.6.0

raw patch · 3 files changed

+125/−6 lines, 3 files

Files

CHANGELOG.md view
@@ -1,5 +1,28 @@ # Changelog +## [0.4.6.0] - 2026-07-29++### Added++* A `skip-calls-to` config key on `ConstConstructor`, the same key `ElideCall`+  and `SwitchFunctionArguments` already take.  A function whose result is+  always the same constructor -- a delegating `sqlType Proxy = sqlType (Proxy+  :: Proxy Text)`, say -- makes every call to it an equivalent, unkillable+  mutant.  Whether a function is like that is a semantic property the plugin+  cannot detect, so listing its name suppresses the operator at every call to+  it.  The definition itself is still mutated: the key skips calls.++### Fixed++* `ConstConstructor` no longer replaces a binding that is defined as a nullary+  constructor with that same constructor.  `Data.Map.empty` is `Tip`, so+  rewriting it to `Tip` produced an unkillable mutant at every use of it (and+  of `Set.empty`, `Seq.empty`, and any `emptyFoo = NoFoo` of your own).  The+  operator already declined this no-op where the constructor is written out;+  it now also recognises it through a name, by reading the unfolding GHC+  recorded for the binding.  A binding in the module being compiled has no+  unfolding yet, so an alias defined locally is still mutated to itself.+ ## [0.4.5.0] - 2026-07-29  ### Added
src/Test/Syd/Mutation/Plugin/Operator/ConstConstructor.hs view
@@ -5,17 +5,23 @@ module Test.Syd.Mutation.Plugin.Operator.ConstConstructor (theOperator) where  import Control.Monad.Reader (asks)+import qualified Data.Map as Map+import Data.Text (Text) import qualified Data.Text as T import GHC import GHC.Builtin.Types (boolTyCon, listTyCon, maybeTyCon)+import GHC.Core (CoreExpr, Expr (..), isTyCoArg, maybeUnfoldingTemplate) import GHC.Core.ConLike (ConLike (RealDataCon)) import GHC.Core.DataCon (dataConFullSig, dataConWrapId) import GHC.Core.TyCon (tyConDataCons_maybe) import GHC.Core.Type (splitTyConApp_maybe)-import GHC.Types.Id (isDataConId_maybe)+import GHC.Types.Id (isDataConId_maybe, realIdUnfolding)+import GHC.Types.Name (getOccString) import GHC.Types.Name.Occurrence (isSymOcc, occNameString)+import GHC.Types.Var (isTyVar) import Test.Syd.Mutation.Plugin.Instrument (InstrM, InstrumentEnv (..), MutationAlt (..), MutationOperator (..), MutationOperatorKind (..), OpAppCtx (..), SrcSpanDelta (..))-import Test.Syd.Mutation.Plugin.Operator.Util (ConstFnMatch (..), ConstructorHeads (..), arrowTy, mkConstLambda, prefixFormPreview, viewConstFnResultBy)+import Test.Syd.Mutation.Plugin.Operator.Util (ConstFnMatch (..), ConstructorHeads (..), arrowTy, collectApp, headFunctionName, mkConstLambda, nameMatchCandidates, prefixFormPreview, viewConstFnResultBy)+import Test.Syd.Mutation.Plugin.OptParse (OperatorConfig (..), operatorExtraStrings)  -- | Replace an expression whose type is @arg1 -> ... -> argN -> T tys@ (with -- @N >= 0@) with a constant function returning a nullary constructor of @T@,@@ -55,6 +61,25 @@ -- An arity-\>=1 firing is suppressed when 'instrumentEnvAppDepth' >= arity; -- see 'ConstNothing' for that dominance rule. --+-- A function whose result is always the same constructor produces an+-- /equivalent/ mutant at every call to it: replacing the call with that+-- constructor cannot change anything, so no test can kill it.  A delegating+-- @sqlType Proxy = sqlType (Proxy :: Proxy Text)@ is the shape that keeps+-- coming up -- @sqlType@ answers with a constant of its type by definition.+-- Which functions those are is a semantic property the plugin cannot detect,+-- so calls to them are suppressed by listing the function's name under the+-- operator's @skip-calls-to@ config key:+--+-- > operators:+-- >   ConstConstructor:+-- >     skip-calls-to:+-- >       - sqlType+--+-- A name matches either bare (@sqlType@, matching any module) or fully+-- qualified (@Database.Persist.Sql.sqlType@), by the defining module or by a+-- module it is imported through.  This is the same matching the @ignore@ key+-- and the other operators' @skip-calls-to@ keys use.+-- -- The manifest preview names the constructor unqualified even when the -- mutated module does not have it in scope.  The mutant itself is built from -- the constructor's 'Id' and compiles regardless of scope, so this only@@ -78,9 +103,19 @@ action le ConstFnMatch {cfnArgTys, cfnResTy, cfnTyConArgs} cons = do   opAppCtx <- asks instrumentEnvOpAppCtx   appDepth <- asks instrumentEnvAppDepth+  -- Suppress calls to functions the user has marked constant-valued (this+  -- operator's mutants for them are equivalent and unkillable).  See this+  -- module's haddock.+  opsConfig <- asks instrumentEnvOperatorsConfig+  rdrEnv <- asks instrumentEnvRdrEnv+  let extra = maybe Map.empty operatorConfigExtra (Map.lookup "ConstConstructor" opsConfig)+  let skipCallsTo = operatorExtraStrings "skip-calls-to" extra+  let skipThisCall = case headFunctionName (fst (collectApp le)) of+        Just n -> any (`elem` skipCallsTo) (nameMatchCandidates rdrEnv n)+        Nothing -> False   let arity = length cfnArgTys   -- See 'ConstNothing' for the dominance rule.-  if arity >= 1 && appDepth >= arity+  if (arity >= 1 && appDepth >= arity) || skipThisCall     then pure []     else       let wholeTy = arrowTy cfnArgTys cfnResTy@@ -122,10 +157,31 @@                     mutAltOriginal = origLabel,                     mutAltReplacement = replLabel,                     mutAltDelta = delta,-                    mutAltMitigation = Nothing+                    mutAltMitigation = mitigationFor le headCon                   }        in pure [mkAlt dc | dc <- cons, Just dc /= headCon] +-- | Hint shown for a surviving mutation: if the called function always+-- answers with the same constructor the mutant is equivalent (unkillable),+-- and listing the function under @skip-calls-to@ suppresses it.  'Nothing'+-- when the expression is already a constructor, or its head is not a named+-- function, so there is nothing to suggest.+mitigationFor :: LHsExpr GhcTc -> Maybe DataCon -> Maybe Text+mitigationFor _ (Just _) = Nothing+mitigationFor le Nothing = do+  n <- headFunctionName (fst (collectApp le))+  let fn = getOccString n+  pure $+    T.pack $+      concat+        [ "If `",+          fn,+          "` always returns the same constructor this is an equivalent mutant ",+          "that no test can kill; add `",+          fn,+          "` to this operator's `skip-calls-to` config to suppress it."+        ]+ -- | How the constructor is written in an expression: a symbolic constructor -- like @(:<)@ needs its parentheses to be one. conSourceName :: DataCon -> String@@ -175,14 +231,54 @@ -- application.  Peels the wrappers the typechecker leaves around a -- constructor occurrence, mirroring -- 'Test.Syd.Mutation.Plugin.Operator.Util.nonConstructorHead'.+--+-- A variable counts when it is a constructor itself and also when it is a+-- binding defined as one; see 'aliasedConstructor'. constructorHead :: LHsExpr GhcTc -> Maybe DataCon constructorHead = \case   L _ (XExpr (ConLikeTc (RealDataCon dc) _ _)) -> Just dc-  L _ (HsVar _ (L _ v)) -> isDataConId_maybe v+  L _ (HsVar _ (L _ v)) -> case isDataConId_maybe v of+    Just dc -> Just dc+    Nothing -> aliasedConstructor v   L _ (HsApp _ f _) -> constructorHead f   L _ (HsAppType _ f _) -> constructorHead f   L _ (HsPar _ e) -> constructorHead e   L _ (ExprWithTySig _ e _) -> constructorHead e   L _ (XExpr (WrapExpr (HsWrap _ e))) -> constructorHead (noLocA e)   L _ (XExpr (ExpandedThingTc _ e)) -> constructorHead (noLocA e)+  _ -> Nothing++-- | The nullary constructor a binding is an alias for, read off the unfolding+-- GHC recorded for it.+--+-- @Data.Map.Strict.empty@ is @Tip@: an ordinary function whose entire+-- definition is a nullary constructor.  Replacing an occurrence of it with+-- that constructor is exactly the no-op this operator already declines to+-- offer when the constructor is written out, only reached through a name, so+-- it has to be recognised here or every @Map.empty@ in a codebase becomes an+-- unkillable mutant.  The same holds for @Set.empty@, @Seq.empty@, and any+-- @emptyFoo = NoFoo@ of the user's own.+--+-- Best-effort: only an imported binding has an unfolding at this stage, and+-- only when its defining module was compiled with enough optimisation to+-- record one.  A miss costs a no-op mutant, which is what would be produced+-- without this check at all.+aliasedConstructor :: Id -> Maybe DataCon+aliasedConstructor v = do+  template <- maybeUnfoldingTemplate (realIdUnfolding v)+  coreConstructorHead template++-- | The constructor a Core expression is a bare occurrence of.+--+-- Type abstractions and type applications are peeled because a constructor of+-- a parameterised type reaches its use site under them (@empty@ unfolds to+-- @\\\@k \\\@a -> Tip \@k \@a@).  A value argument is not peeled: it means the+-- constructor is applied to something and so is not a constant of its type.+coreConstructorHead :: CoreExpr -> Maybe DataCon+coreConstructorHead = \case+  Var i -> isDataConId_maybe i+  App f a | isTyCoArg a -> coreConstructorHead f+  Lam b e | isTyVar b -> coreConstructorHead e+  Cast e _ -> coreConstructorHead e+  Tick _ e -> coreConstructorHead e   _ -> Nothing
sydtest-mutation-plugin.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:           sydtest-mutation-plugin-version:        0.4.5.0+version:        0.4.6.0 synopsis:       GHC plugin that instruments code for sydtest's mutation testing description:    A GHC source plugin that instruments code under test with the coverage and mutation hooks that sydtest's mutation testing infrastructure needs. See https://github.com/NorfairKing/sydtest#readme for more information. category:       Testing