Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Working with observation-based tests

This repo has a set of “fixture” modules under `target/Target/*.hs` that are analysed by Stan, and a matching set of Hspec tests under `test/Test/Stan/Analysis/*` that assert observations at specific source locations (line/column spans).

## Add a new test case (end-to-end)

1. Add or edit a fixture in `target/Target/...` (e.g. `target/Target/PlutusTx.hs`).
- Prefer appending new fixtures near the end of the file to minimize line-number churn.
- Keep each fixture small and focused so it yields a single, unambiguous observation.

2. Add a matching spec in `test/Test/Stan/Analysis/...` (e.g. `test/Test/Stan/Analysis/PlutusTx.hs`).
- Use `checkObservation` (via `observationAssert`) when you expect an observation.
- Use `noObservationAssert` when you expect no observation on a given line.

3. Run tests:
- `cabal test all --test-show-details=direct`
- For faster iteration, rerun a single spec:
- `cabal test all --test-show-details=direct --test-options='--match "/Static Analysis/Plutus-Tx/PLU-STAN-08: .../"'`

## Updating line numbers / spans when tests fail

Observation tests are location-sensitive. When you change a fixture file, you often need to update the expected line/column numbers in the corresponding spec.

### Find the correct line numbers

Use `nl` to get stable, 1-based line numbers:

- `nl -ba target/Target/PlutusTx.hs | sed -n 'START,ENDp'`

Update the `line` argument in the test to match the new location.

### Find the correct column span (start/end)

`observationAssert`/`checkObservation` expects:

- line number
- start column
- end column

Typical patterns:

- If the observation is reported on a specific identifier (e.g. an argument name), set the span to the identifier’s columns.
- If the observation is reported on a larger AST node, set the span to the node’s reported span in the failure output.

Tip: when a test fails, the failure message prints the “expected” and the “got”. Use the “got” `SrcSpanOneLine ... line start end` as the new truth.

### Avoid ambiguity on the same line

`observationAssert` intentionally matches observations by an ID prefix that is only line-specific. If multiple observations of the same inspection are emitted on the same line, the test can become flaky (it may match a different observation on that line).

Prefer writing fixtures so each inspection triggers at most once per line, or spread similar triggers across different lines.

## Common gotchas

- Tests are driven by `.hie` files under `.hie/`. `cabal test` will rebuild as needed; if you’re debugging locally, make sure fixture compilation succeeded so fresh `.hie` files exist.
- Many existing specs key off exact line numbers in `target/Target/PlutusTx.hs`; inserting code near the top of that file will cascade updates.
86 changes: 70 additions & 16 deletions app/PluStan.hs
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
module Main (main) where
module Main (main) where

import Colourista (errorMessage, infoMessage, successMessage, warningMessage)
import Control.Exception (SomeException, handle)
import Control.Exception (SomeException, handle, try)
import Control.Monad (forM, when)
import Control.Monad.IO.Class (liftIO)
import Data.List (isPrefixOf)
import Data.Maybe (catMaybes, mapMaybe)
import GHC.IO.Encoding (setLocaleEncoding, utf8)
import System.Directory (doesDirectoryExist, getCurrentDirectory, listDirectory)
import System.Directory (doesDirectoryExist, getCurrentDirectory, listDirectory, setCurrentDirectory)
import System.Directory (findExecutable)
import System.Environment (getArgs)
import System.Exit (exitFailure)
import System.FilePath ((</>), takeExtension, takeFileName)
import System.Process (callProcess)
import System.Info (compilerVersion)
import Data.Version (showVersion)
import qualified Data.ByteString.Char8 as BS8
import Trial (withTag, whenResult_)

import Stan (getAnalysis)
Expand Down Expand Up @@ -61,9 +65,18 @@ runPluStan :: IO ()
runPluStan =
whenResult_ (finaliseConfig pluStanConfig) $ \warnings config -> do
cli <- parsePluStanArgs
case plustanProjectDir cli of
Nothing -> pure ()
Just dir -> do
exists <- doesDirectoryExist dir
if exists
then setCurrentDirectory dir
else do
errorMessage . Text.pack $ "Project directory does not exist: " <> dir
exitFailure
let hieDir = plustanHieDir cli
ensureHieFiles hieDir
hieFiles <- readHieFiles hieDir
hieFiles <- readHieFilesOrRebuild hieDir
when (null hieFiles) $ do
warningMessage "No .hie files found after build. Ensure the project is compiled with -fwrite-ide-info and -hiedir=.hie."
exitFailure
Expand Down Expand Up @@ -124,6 +137,7 @@ generatePluStanReport PluStanArgs{..} config warnings hieFiles analysis = do
data PluStanArgs = PluStanArgs
{ plustanReport :: Bool
, plustanBrowse :: Bool
, plustanProjectDir :: Maybe FilePath
, plustanHieDir :: FilePath
}

Expand All @@ -137,39 +151,67 @@ parsePluStanArgs = do
exitFailure
Right parsed -> pure parsed
where
defaultArgs = PluStanArgs False False ".hie"
defaultArgs = PluStanArgs False False Nothing ".hie"
usage = Text.unlines
[ "Usage: plustan [--report] [--browse] [--hiedir DIR]"
[ "Usage: plustan [--report] [--browse] [--project DIR] [--hiedir DIR] [PROJECT_DIR]"
, " --report Generate stan.html report"
, " --browse Open report in browser (implies --report)"
, " --project DIR Change into project DIR before running (same as positional PROJECT_DIR)"
, " --hiedir DIR Directory with .hie/.hi files (default: .hie)"
]

go :: PluStanArgs -> [String] -> Either String PluStanArgs
go acc [] = Right acc
go acc ("--report":xs) = go acc { plustanReport = True } xs
go acc ("--browse":xs) = go acc { plustanReport = True, plustanBrowse = True } xs
go acc ("--project":dir:xs) = go acc { plustanProjectDir = Just dir } xs
go acc ("--hiedir":dir:xs) = go acc { plustanHieDir = dir } xs
go acc (arg:xs)
| "--project=" `isPrefixOf` arg =
let prefix = "--project=" :: String
in go acc { plustanProjectDir = Just (drop (length prefix) arg) } xs
| "--hiedir=" `isPrefixOf` arg =
let prefix = "--hiedir=" :: String
in go acc { plustanHieDir = drop (length prefix) arg } xs
| otherwise = Left ("Unknown argument: " <> arg)
| "-" `isPrefixOf` arg = Left ("Unknown argument: " <> arg)
| otherwise =
case plustanProjectDir acc of
Nothing -> go acc { plustanProjectDir = Just arg } xs
Just _ -> Left ("Unexpected extra positional argument: " <> arg)

ensureHieFiles :: FilePath -> IO ()
ensureHieFiles hieDir = do
hasHie <- hasFilesWithExt hieDir ".hie"
hasHi <- hasFilesWithExt hieDir ".hi"
when (not (hasHie && hasHi)) $ do
infoMessage "Missing .hie/.hi files. Running cabal build to generate artifacts..."
callProcess "cabal"
[ "build"
, "all"
, "--disable-tests"
, "--ghc-options=-fwrite-ide-info"
, "--ghc-options=-hiedir=" <> hieDir
, "--ghc-options=-hidir=" <> hieDir
]
buildHieFiles hieDir

readHieFilesOrRebuild :: FilePath -> IO [HieFile]
readHieFilesOrRebuild hieDir = do
res <- try (readHieFiles hieDir)
case res of
Right hieFiles -> pure hieFiles
Left (_ :: SomeException) -> do
warningMessage "Failed to read .hie files (possibly built by a different GHC). Rebuilding..."
buildHieFiles hieDir
readHieFiles hieDir

buildHieFiles :: FilePath -> IO ()
buildHieFiles hieDir = do
let ghcVer = "ghc-" <> showVersion compilerVersion
ghc <- maybe "ghc" id <$> findExecutable ghcVer
callProcess "cabal"
[ "build"
, "all"
, "--disable-tests"
, "-w"
, ghc
, "--ghc-options=-fforce-recomp"
, "--ghc-options=-fwrite-ide-info"
, "--ghc-options=-hiedir=" <> hieDir
, "--ghc-options=-hidir=" <> hieDir
]

hasFilesWithExt :: FilePath -> String -> IO Bool
hasFilesWithExt dir ext = do
Expand Down Expand Up @@ -226,7 +268,19 @@ onchainFiles hieDir hieFiles =
pure $ iface >>= \modIface -> if hasOnchainAnnotation modIface
then Just (mi_module modIface)
else Nothing
pure $ Set.fromList $ mapMaybe (`Map.lookup` moduleToFile) annotatedModules
let fromHi = Set.fromList $ mapMaybe (`Map.lookup` moduleToFile) annotatedModules
let fromHie =
Set.fromList
[ hie_hs_file
| HieFile{..} <- hieFiles
, hasOnchainAnnotationInSource hie_hs_src
]
pure $ Set.union fromHi fromHie

hasOnchainAnnotationInSource :: BS8.ByteString -> Bool
hasOnchainAnnotationInSource src =
"ANN module" `BS8.isInfixOf` src
&& "onchain-contract" `BS8.isInfixOf` src

isOnchainObservations :: Set.Set FilePath -> Observation -> Bool
isOnchainObservations files obs = Set.member (observationFile obs) files
Expand Down
Loading