claude-gate-1.0.0: src/Claude/Gate/Corpus.hs
-- | Assemble the rules corpus the reviewer is checked against.
--
-- The corpus is the global CLAUDE.md, the project CLAUDE.md (if it is a
-- different file), and the SKILL.md of each skill whose rules could apply to
-- the file types touched this turn. Attaching every skill was almost all of the
-- old prompt and pure latency, so only language skills matching the touched
-- extensions are selected; the two CLAUDE.md files are always included.
module Claude.Gate.Corpus
( selectSkills
, buildCorpus
) where
import Data.List (isSuffixOf, nub, sort)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.IO qualified as TextIO
import Claude.Gate.Repo (gitRoot)
import System.Directory (doesFileExist, getHomeDirectory)
import System.FilePath (takeDirectory, (</>))
-- | Skill names whose rules are relevant to the touched file types, sorted and
-- deduplicated. Pure so the mapping can be tested directly.
selectSkills :: [FilePath] -> [Text]
selectSkills = sort . nub . concatMap skillsForPath
skillsForPath :: FilePath -> [Text]
skillsForPath path
| any (`isSuffixOf` path) haskellExtensions = haskellSkills
| ".nix" `isSuffixOf` path = nixSkills
| otherwise = []
haskellExtensions :: [FilePath]
haskellExtensions = [".hs", ".lhs", ".hsig", ".cabal"]
haskellSkills :: [Text]
haskellSkills =
["haskell-project", "haskell-backpack", "unwitch-conversions", "verify-test-fails", "error-messages"]
nixSkills :: [Text]
nixSkills = ["nix", "ci-nix"]
-- | Build the corpus text for the files touched this turn. Reads the two
-- CLAUDE.md files and the selected skills from disk.
buildCorpus :: [FilePath] -> IO Text
buildCorpus touchedPaths = do
home <- getHomeDirectory
let globalClaude = home </> ".claude" </> "CLAUDE.md"
globalSection <- fileSection ("=== " <> Text.pack globalClaude <> " ===") globalClaude
projectSection <- projectClaudeSection globalClaude touchedPaths
skillSections <- mapM (skillSection home) (selectSkills touchedPaths)
pure (Text.concat (globalSection <> projectSection <> concat skillSections))
-- | The project CLAUDE.md, resolved from the git root of the first touched
-- file, included only if it exists and is a different file from the global one.
projectClaudeSection :: FilePath -> [FilePath] -> IO [Text]
projectClaudeSection globalClaude touchedPaths = case touchedPaths of
[] -> pure []
(firstFile : _) -> do
maybeRoot <- gitRoot (takeDirectory firstFile)
case maybeRoot of
Nothing -> pure []
Just root ->
let projectClaude = root </> "CLAUDE.md"
in if projectClaude == globalClaude
then pure []
else fileSection ("=== " <> Text.pack projectClaude <> " ===") projectClaude
skillSection :: FilePath -> Text -> IO [Text]
skillSection home skillName =
fileSection
("=== skill: " <> skillName <> " ===")
(home </> ".claude" </> "skills" </> Text.unpack skillName </> "SKILL.md")
-- | A labelled section for a file, or nothing if the file is absent. Absence is
-- expected (not every skill or project has the file), so it is a legitimate
-- empty result rather than a swallowed error.
fileSection :: Text -> FilePath -> IO [Text]
fileSection heading path = do
present <- doesFileExist path
if present
then do
body <- TextIO.readFile path
pure ["\n" <> heading <> "\n" <> body]
else pure []