├── CODEOWNERS ├── data ├── hie.yaml ├── only-cabal │ ├── cabal.project │ ├── C │ │ ├── package.yaml │ │ └── C.cabal │ ├── B1 │ │ ├── package.yaml │ │ └── B1.cabal │ ├── A1 │ │ ├── package.yaml │ │ └── A1.cabal │ ├── A2 │ │ ├── package.yaml │ │ └── A2.cabal │ └── dependency-domains.yaml ├── test-only-dependency │ ├── cabal-no-A1.project │ ├── cabal.project │ ├── stack-no-A1.yaml │ ├── stack.yaml │ ├── C │ │ ├── package.yaml │ │ └── C.cabal │ ├── dependency-domains.yaml │ ├── dependency-domains-no-tests-benchmarks.yaml │ ├── B1 │ │ ├── package.yaml │ │ └── B1.cabal │ ├── A1 │ │ ├── package.yaml │ │ └── A1.cabal │ ├── A2 │ │ ├── package.yaml │ │ └── A2.cabal │ ├── dependency-domains-custom-cabal.yaml │ ├── dependency-domains-custom-stack.yaml │ ├── dependency-domains-except-A2-B1.yaml │ ├── dependency-domains-cabal-update-true.yaml │ ├── dependency-domains-cabal-update-index.yaml │ ├── stack.yaml.lock │ ├── stack-no-A1.yaml.lock │ └── dependency-domains-ambiguous.yaml ├── only-stack │ ├── stack.yaml │ ├── C │ │ ├── package.yaml │ │ └── C.cabal │ ├── B1 │ │ ├── package.yaml │ │ └── B1.cabal │ ├── A1 │ │ ├── package.yaml │ │ └── A1.cabal │ ├── A2 │ │ ├── package.yaml │ │ └── A2.cabal │ ├── dependency-domains.yaml │ ├── dependency-domains-stack-dot.yaml │ └── stack.yaml.lock └── only-custom-cabal-plan │ ├── cabal.project │ ├── C │ ├── package.yaml │ └── C.cabal │ ├── decode-cabal-plan.sh │ ├── B1 │ ├── package.yaml │ └── B1.cabal │ ├── A1 │ ├── package.yaml │ └── A1.cabal │ ├── A2 │ ├── package.yaml │ └── A2.cabal │ └── dependency-domains.yaml ├── .allstar └── allstar.yaml ├── Setup.hs ├── test ├── Spec.hs └── Development │ └── Guardian │ ├── Test │ └── Flags.hs │ ├── Graph │ └── Adapter │ │ ├── TestUtils.hs │ │ ├── StackSpec.hs │ │ └── CabalSpec.hs │ ├── GraphSpec.hs │ └── AppSpec.hs ├── cabal.project ├── fourmolu.yaml ├── app └── Main.hs ├── cabal-ubuntu.project ├── cabal-macOS.project ├── src ├── Development │ └── Guardian │ │ ├── Constants.hs │ │ ├── Graph │ │ └── Adapter │ │ │ ├── Cabal.hs │ │ │ ├── Stack.hs │ │ │ ├── Cabal │ │ │ ├── Disabled.hs │ │ │ ├── Types.hs │ │ │ └── Enabled.hs │ │ │ ├── Stack │ │ │ ├── Disabled.hs │ │ │ ├── Types.hs │ │ │ └── Enabled.hs │ │ │ ├── Types.hs │ │ │ ├── Detection.hs │ │ │ └── Custom.hs │ │ ├── Flags.hs │ │ ├── Types.hs │ │ ├── App.hs │ │ └── Graph.hs └── Text │ └── Pattern.hs ├── .vscode ├── settings.shared.json └── extensions.json ├── scripts ├── check-version.sh └── make-release.sh ├── .pre-commit-config.yaml ├── ChangeLog.md ├── dependency-domains-graphmod.yaml ├── .gitignore ├── action ├── action.yml └── download-guardian.sh ├── LICENSE ├── CONTRIBUTING.md ├── package.yaml ├── guardian.cabal ├── .github └── workflows │ └── haskell.yml └── README.md /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @konn 2 | -------------------------------------------------------------------------------- /data/hie.yaml: -------------------------------------------------------------------------------- 1 | cradle: 2 | none: 3 | -------------------------------------------------------------------------------- /.allstar/allstar.yaml: -------------------------------------------------------------------------------- 1 | optConfig: 2 | optOut: true 3 | -------------------------------------------------------------------------------- /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /test/Spec.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -F -pgmF tasty-discover -optF --tree-display #-} 2 | -------------------------------------------------------------------------------- /data/only-cabal/cabal.project: -------------------------------------------------------------------------------- 1 | packages: A1/A1.cabal, A2/A2.cabal, B1/B1.cabal, C/C.cabal 2 | -------------------------------------------------------------------------------- /data/test-only-dependency/cabal-no-A1.project: -------------------------------------------------------------------------------- 1 | packages: A2/A2.cabal, B1/B1.cabal, C/C.cabal 2 | -------------------------------------------------------------------------------- /data/only-stack/stack.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-19.19 2 | packages: 3 | - A1 4 | - A2 5 | - B1 6 | - C 7 | -------------------------------------------------------------------------------- /data/test-only-dependency/cabal.project: -------------------------------------------------------------------------------- 1 | packages: A1/A1.cabal, A2/A2.cabal, B1/B1.cabal, C/C.cabal 2 | -------------------------------------------------------------------------------- /data/only-custom-cabal-plan/cabal.project: -------------------------------------------------------------------------------- 1 | packages: A1/A1.cabal, A2/A2.cabal, B1/B1.cabal, C/C.cabal 2 | -------------------------------------------------------------------------------- /data/test-only-dependency/stack-no-A1.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-19.19 2 | packages: 3 | - A2 4 | - B1 5 | - C 6 | -------------------------------------------------------------------------------- /data/test-only-dependency/stack.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-19.19 2 | packages: 3 | - A1 4 | - A2 5 | - B1 6 | - C 7 | -------------------------------------------------------------------------------- /data/only-cabal/C/package.yaml: -------------------------------------------------------------------------------- 1 | name: C 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | -------------------------------------------------------------------------------- /data/only-stack/C/package.yaml: -------------------------------------------------------------------------------- 1 | name: C 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | -------------------------------------------------------------------------------- /data/test-only-dependency/C/package.yaml: -------------------------------------------------------------------------------- 1 | name: C 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | -------------------------------------------------------------------------------- /data/only-custom-cabal-plan/C/package.yaml: -------------------------------------------------------------------------------- 1 | name: C 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | -------------------------------------------------------------------------------- /data/only-custom-cabal-plan/decode-cabal-plan.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cabal-plan --hide-builtin --hide-global dot 2>/dev/null \ 3 | | sed -r 's/:(test|bench|exe):[^\"]+//g; s/-[0-9]+(\.[0-9]+)*//g' 4 | -------------------------------------------------------------------------------- /cabal.project: -------------------------------------------------------------------------------- 1 | packages: . 2 | static: True 3 | executable-static: True 4 | executable-dynamic: False 5 | 6 | allow-newer: validation-selective:selective 7 | 8 | package cryptonite 9 | flags: -integer-gmp 10 | -------------------------------------------------------------------------------- /fourmolu.yaml: -------------------------------------------------------------------------------- 1 | indentation: 2 2 | comma-style: leading 3 | record-brace-space: true 4 | indent-wheres: true 5 | diff-friendly-import-export: false 6 | respectful: false 7 | haddock-style: multi-line 8 | newlines-between-decls: 1 9 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TemplateHaskell #-} 2 | 3 | module Main (main) where 4 | 5 | import Development.Guardian.App 6 | import Paths_guardian (version) 7 | 8 | main :: IO () 9 | main = defaultMain $$(buildInfoQ version) 10 | -------------------------------------------------------------------------------- /cabal-ubuntu.project: -------------------------------------------------------------------------------- 1 | import: cabal.project.freeze 2 | packages: . 3 | static: True 4 | executable-static: True 5 | optimization: True 6 | shared: True 7 | executable-dynamic: False 8 | allow-newer: stack:Cabal, validation-selective:selective 9 | -------------------------------------------------------------------------------- /data/test-only-dependency/dependency-domains.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: {packages: [A1, A2], depends_on: [C]} 3 | B: {packages: [B1], depends_on: [C]} 4 | C: {packages: [C], depends_on: []} 5 | 6 | components: 7 | tests: true 8 | benchmarks: false 9 | -------------------------------------------------------------------------------- /data/test-only-dependency/dependency-domains-no-tests-benchmarks.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: {packages: [A1, A2], depends_on: [C]} 3 | B: {packages: [B1], depends_on: [C]} 4 | C: {packages: [C], depends_on: []} 5 | 6 | components: 7 | tests: false 8 | benchmarks: false 9 | -------------------------------------------------------------------------------- /cabal-macOS.project: -------------------------------------------------------------------------------- 1 | import: cabal.project.freeze 2 | packages: . 3 | executable-static: False 4 | optimization: True 5 | shared: True 6 | static: False 7 | allow-newer: stack:Cabal, validation-selective:selective 8 | 9 | package guardian 10 | flags: -test-cabal-plan 11 | -------------------------------------------------------------------------------- /data/only-cabal/B1/package.yaml: -------------------------------------------------------------------------------- 1 | name: B1 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | tests: 12 | A1-test: 13 | source-dirs: test 14 | dependencies: 15 | - B1 16 | main: test.hs 17 | -------------------------------------------------------------------------------- /data/only-stack/B1/package.yaml: -------------------------------------------------------------------------------- 1 | name: B1 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | tests: 12 | A1-test: 13 | source-dirs: test 14 | dependencies: 15 | - B1 16 | main: test.hs 17 | -------------------------------------------------------------------------------- /src/Development/Guardian/Constants.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE QuasiQuotes #-} 2 | 3 | module Development.Guardian.Constants (configFileName) where 4 | 5 | import Path (File, Path, Rel, relfile) 6 | 7 | configFileName :: Path Rel File 8 | configFileName = [relfile|dependency-domains.yaml|] 9 | -------------------------------------------------------------------------------- /data/only-cabal/A1/package.yaml: -------------------------------------------------------------------------------- 1 | name: A1 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | benchmarks: 12 | A1-bench: 13 | source-dirs: bench 14 | main: bench.hs 15 | dependencies: 16 | - A2 17 | -------------------------------------------------------------------------------- /data/only-custom-cabal-plan/B1/package.yaml: -------------------------------------------------------------------------------- 1 | name: B1 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | tests: 12 | A1-test: 13 | source-dirs: test 14 | dependencies: 15 | - B1 16 | main: test.hs 17 | -------------------------------------------------------------------------------- /data/only-stack/A1/package.yaml: -------------------------------------------------------------------------------- 1 | name: A1 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | benchmarks: 12 | A1-bench: 13 | source-dirs: bench 14 | main: bench.hs 15 | dependencies: 16 | - A2 17 | -------------------------------------------------------------------------------- /data/test-only-dependency/B1/package.yaml: -------------------------------------------------------------------------------- 1 | name: B1 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | tests: 12 | A1-test: 13 | source-dirs: test 14 | dependencies: 15 | - B1 16 | main: test.hs 17 | -------------------------------------------------------------------------------- /data/only-cabal/A2/package.yaml: -------------------------------------------------------------------------------- 1 | name: A2 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | tests: 12 | A1-test: 13 | source-dirs: test 14 | dependencies: 15 | - A2 16 | - B1 17 | main: test.hs 18 | -------------------------------------------------------------------------------- /data/only-stack/A2/package.yaml: -------------------------------------------------------------------------------- 1 | name: A2 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | tests: 12 | A1-test: 13 | source-dirs: test 14 | dependencies: 15 | - A2 16 | - B1 17 | main: test.hs 18 | -------------------------------------------------------------------------------- /data/only-custom-cabal-plan/A1/package.yaml: -------------------------------------------------------------------------------- 1 | name: A1 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | benchmarks: 12 | A1-bench: 13 | source-dirs: bench 14 | main: bench.hs 15 | dependencies: 16 | - A2 17 | -------------------------------------------------------------------------------- /data/test-only-dependency/A1/package.yaml: -------------------------------------------------------------------------------- 1 | name: A1 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | benchmarks: 12 | A1-bench: 13 | source-dirs: bench 14 | main: bench.hs 15 | dependencies: 16 | - A2 17 | -------------------------------------------------------------------------------- /data/only-custom-cabal-plan/A2/package.yaml: -------------------------------------------------------------------------------- 1 | name: A2 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | tests: 12 | A1-test: 13 | source-dirs: test 14 | dependencies: 15 | - A2 16 | - B1 17 | main: test.hs 18 | -------------------------------------------------------------------------------- /data/test-only-dependency/A2/package.yaml: -------------------------------------------------------------------------------- 1 | name: A2 2 | 3 | dependencies: 4 | - base 5 | 6 | library: 7 | source-dirs: src 8 | dependencies: 9 | - C 10 | 11 | tests: 12 | A1-test: 13 | source-dirs: test 14 | dependencies: 15 | - A2 16 | - B1 17 | main: test.hs 18 | -------------------------------------------------------------------------------- /data/test-only-dependency/dependency-domains-custom-cabal.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: {packages: [A1, A2], depends_on: [C]} 3 | B: {packages: [B1], depends_on: [C]} 4 | C: {packages: [C], depends_on: []} 5 | 6 | components: 7 | tests: false 8 | benchmarks: false 9 | 10 | cabal: 11 | projectFile: cabal-no-A1.project 12 | 13 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph/Adapter/Cabal.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE CPP #-} 2 | 3 | module Development.Guardian.Graph.Adapter.Cabal (module Cabal) where 4 | #if defined(ENABLE_CABAL) 5 | import Development.Guardian.Graph.Adapter.Cabal.Enabled as Cabal 6 | #else 7 | import Development.Guardian.Graph.Adapter.Cabal.Disabled as Cabal 8 | #endif 9 | -------------------------------------------------------------------------------- /data/test-only-dependency/dependency-domains-custom-stack.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: {packages: [A1, A2], depends_on: [C]} 3 | B: {packages: [B1], depends_on: [C]} 4 | C: {packages: [C], depends_on: []} 5 | 6 | components: 7 | tests: false 8 | benchmarks: false 9 | 10 | stack: 11 | options: 12 | - --stack-yaml=stack-no-A1.yaml 13 | 14 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph/Adapter/Stack.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE CPP #-} 2 | 3 | module Development.Guardian.Graph.Adapter.Stack ( 4 | module Stack, 5 | ) where 6 | 7 | #if defined(ENABLE_STACK) 8 | import Development.Guardian.Graph.Adapter.Stack.Enabled as Stack 9 | #else 10 | import Development.Guardian.Graph.Adapter.Stack.Disabled as Stack 11 | #endif 12 | -------------------------------------------------------------------------------- /src/Development/Guardian/Flags.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE CPP #-} 2 | 3 | module Development.Guardian.Flags (cabalEnabled, stackEnabled) where 4 | 5 | cabalEnabled :: Bool 6 | #if defined(ENABLE_CABAL) 7 | cabalEnabled = True 8 | #else 9 | cabalEnabled = False 10 | #endif 11 | 12 | stackEnabled :: Bool 13 | #if defined(ENABLE_STACK) 14 | stackEnabled = True 15 | #else 16 | stackEnabled = False 17 | #endif 18 | -------------------------------------------------------------------------------- /.vscode/settings.shared.json: -------------------------------------------------------------------------------- 1 | { 2 | "emeraldwalk.runonsave": { 3 | "commands": [ 4 | { "match": "package.yaml$", "cmd": "hpack", "isAsync": true }, 5 | { "match": "\\.hs$", "cmd": "hpack", "isAsync": true } 6 | ] 7 | }, 8 | "files.associations": { 9 | "cabal*.project": "cabal", 10 | "cabal*.project.local": "cabal", 11 | "cabal*.project.freeze": "cabal" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/Development/Guardian/Test/Flags.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE CPP #-} 2 | 3 | module Development.Guardian.Test.Flags (testGraphmod, testCabalPlan) where 4 | 5 | testGraphmod :: Bool 6 | #if defined(TEST_GRAPHMOD) 7 | testGraphmod = True 8 | #else 9 | testGraphmod = False 10 | #endif 11 | 12 | testCabalPlan :: Bool 13 | #if defined(TEST_CABAL_PLAN) 14 | testCabalPlan = True 15 | #else 16 | testCabalPlan = False 17 | #endif 18 | -------------------------------------------------------------------------------- /data/only-cabal/dependency-domains.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: 3 | depends_on: [C] 4 | packages: 5 | - A1 6 | - package: A2 7 | exception: # Exception rules for package A2 8 | depends_on: 9 | - package: B1 10 | B: 11 | packages: [B1] 12 | depends_on: [C] 13 | C: 14 | packages: [C] 15 | depends_on: [] 16 | 17 | components: 18 | tests: true 19 | benchmarks: false 20 | -------------------------------------------------------------------------------- /data/only-stack/dependency-domains.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: 3 | depends_on: [C] 4 | packages: 5 | - A1 6 | - package: A2 7 | exception: # Exception rules for package A2 8 | depends_on: 9 | - package: B1 10 | B: 11 | packages: [B1] 12 | depends_on: [C] 13 | C: 14 | packages: [C] 15 | depends_on: [] 16 | 17 | components: 18 | tests: true 19 | benchmarks: false 20 | -------------------------------------------------------------------------------- /data/only-cabal/C/C.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: C 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_C 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | base 18 | default-language: Haskell2010 19 | -------------------------------------------------------------------------------- /data/only-stack/C/C.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: C 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_C 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | base 18 | default-language: Haskell2010 19 | -------------------------------------------------------------------------------- /data/only-custom-cabal-plan/C/C.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: C 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_C 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | base 18 | default-language: Haskell2010 19 | -------------------------------------------------------------------------------- /data/test-only-dependency/C/C.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: C 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_C 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | base 18 | default-language: Haskell2010 19 | -------------------------------------------------------------------------------- /data/test-only-dependency/dependency-domains-except-A2-B1.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: 3 | depends_on: [C] 4 | packages: 5 | - A1 6 | - package: A2 7 | exception: # Exception rules for package A2 8 | depends_on: 9 | - package: B1 10 | B: 11 | packages: [B1] 12 | depends_on: [C] 13 | C: 14 | packages: [C] 15 | depends_on: [] 16 | 17 | components: 18 | tests: true 19 | benchmarks: false 20 | -------------------------------------------------------------------------------- /data/test-only-dependency/dependency-domains-cabal-update-true.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: 3 | depends_on: [C] 4 | packages: 5 | - A1 6 | - package: A2 7 | exception: # Exception rules for package A2 8 | depends_on: 9 | - package: B1 10 | B: 11 | packages: [B1] 12 | depends_on: [C] 13 | C: 14 | packages: [C] 15 | depends_on: [] 16 | 17 | components: 18 | tests: true 19 | benchmarks: false 20 | 21 | cabal: 22 | update: true 23 | -------------------------------------------------------------------------------- /scripts/check-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | RELEASE="${1}" 5 | DOWNLOAD="${2}" 6 | 7 | GUARDIAN="${DOWNLOAD}/bins-Linux/guardian" 8 | chmod +x "${GUARDIAN}" 9 | 10 | VERSION=$("${GUARDIAN}" --numeric-version) 11 | 12 | if [[ "${VERSION}" = "${RELEASE}" ]]; then 13 | echo "Release version ok: ${VERSION}" 14 | else 15 | echo "Release version mismatched!" >&2 16 | echo " expected: ${RELEASE}" >&2 17 | echo " cabal ver: ${VERSION}" >&2 18 | exit 1 19 | fi 20 | -------------------------------------------------------------------------------- /data/only-custom-cabal-plan/dependency-domains.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: 3 | depends_on: [C] 4 | packages: 5 | - A1 6 | - package: A2 7 | exception: # Exception rules for package A2 8 | depends_on: 9 | - package: B1 10 | B: 11 | packages: [B1] 12 | depends_on: [C] 13 | C: 14 | packages: [C] 15 | depends_on: [] 16 | 17 | components: 18 | tests: true 19 | benchmarks: false 20 | 21 | custom: 22 | program: "./decode-cabal-plan.sh" 23 | ignore_loop: true 24 | -------------------------------------------------------------------------------- /data/only-stack/dependency-domains-stack-dot.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: 3 | depends_on: [C] 4 | packages: 5 | - A1 6 | - package: A2 7 | exception: # Exception rules for package A2 8 | depends_on: 9 | - package: B1 10 | B: 11 | packages: [B1] 12 | depends_on: [C] 13 | C: 14 | packages: [C] 15 | depends_on: [] 16 | 17 | components: 18 | tests: true 19 | benchmarks: false 20 | 21 | custom: 22 | shell: | 23 | stack dot --no-external --no-include-base 24 | -------------------------------------------------------------------------------- /data/only-stack/stack.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: [] 7 | snapshots: 8 | - completed: 9 | sha256: a95a93bcb7132bf5f8ad3356998e223947852f57d4da63e0e145870ea90d9d18 10 | size: 619170 11 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/19/19.yaml 12 | original: lts-19.19 13 | -------------------------------------------------------------------------------- /data/test-only-dependency/dependency-domains-cabal-update-index.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: 3 | depends_on: [C] 4 | packages: 5 | - A1 6 | - package: A2 7 | exception: # Exception rules for package A2 8 | depends_on: 9 | - package: B1 10 | B: 11 | packages: [B1] 12 | depends_on: [C] 13 | C: 14 | packages: [C] 15 | depends_on: [] 16 | 17 | components: 18 | tests: true 19 | benchmarks: false 20 | 21 | cabal: 22 | update: hackage.haskell.org,2023-02-03T00:00:00Z 23 | -------------------------------------------------------------------------------- /data/test-only-dependency/stack.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: [] 7 | snapshots: 8 | - completed: 9 | sha256: a95a93bcb7132bf5f8ad3356998e223947852f57d4da63e0e145870ea90d9d18 10 | size: 619170 11 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/19/19.yaml 12 | original: lts-19.19 13 | -------------------------------------------------------------------------------- /data/test-only-dependency/stack-no-A1.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: [] 7 | snapshots: 8 | - completed: 9 | sha256: a95a93bcb7132bf5f8ad3356998e223947852f57d4da63e0e145870ea90d9d18 10 | size: 619170 11 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/19/19.yaml 12 | original: lts-19.19 13 | -------------------------------------------------------------------------------- /data/test-only-dependency/dependency-domains-ambiguous.yaml: -------------------------------------------------------------------------------- 1 | domains: 2 | A: 3 | depends_on: [C] 4 | packages: 5 | - A1 6 | - package: A2 7 | exception: # Exception rules for package A2 8 | depends_on: 9 | - package: B1 10 | B: 11 | packages: [B1] 12 | depends_on: [C] 13 | C: 14 | packages: [C] 15 | depends_on: [] 16 | 17 | components: 18 | tests: false 19 | benchmarks: false 20 | 21 | cabal: 22 | projectFile: cabal.project 23 | 24 | stack: 25 | options: 26 | - --silent 27 | 28 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "haskell.haskell", 8 | "emeraldwalk.RunOnSave", 9 | "Swellaby.workspace-config-plus" 10 | ], 11 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 12 | "unwantedRecommendations": [] 13 | } 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: 2 | - pre-commit 3 | - post-checkout 4 | - pre-commit 5 | repos: 6 | # Prohibits commits to the default branch 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v4.2.0 9 | hooks: 10 | - id: no-commit-to-branch 11 | args: [--branch, main] 12 | - repo: local 13 | hooks: 14 | - id: pre-commit-run-hpack 15 | name: Generates .cabal files with hpack 16 | always_run: true 17 | verbose: true 18 | stages: [commit] 19 | language: system 20 | pass_filenames: false 21 | entry: bash -c "(command -v hpack >/dev/null && hpack .) || echo '[warn] no hpack found' >&2" 22 | -------------------------------------------------------------------------------- /data/only-cabal/B1/B1.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: B1 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_B1 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | test-suite A1-test 22 | type: exitcode-stdio-1.0 23 | main-is: test.hs 24 | other-modules: 25 | Paths_B1 26 | hs-source-dirs: 27 | test 28 | build-depends: 29 | B1 30 | , base 31 | default-language: Haskell2010 32 | -------------------------------------------------------------------------------- /data/only-stack/B1/B1.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: B1 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_B1 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | test-suite A1-test 22 | type: exitcode-stdio-1.0 23 | main-is: test.hs 24 | other-modules: 25 | Paths_B1 26 | hs-source-dirs: 27 | test 28 | build-depends: 29 | B1 30 | , base 31 | default-language: Haskell2010 32 | -------------------------------------------------------------------------------- /data/only-cabal/A1/A1.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: A1 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_A1 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | benchmark A1-bench 22 | type: exitcode-stdio-1.0 23 | main-is: bench.hs 24 | other-modules: 25 | Paths_A1 26 | hs-source-dirs: 27 | bench 28 | build-depends: 29 | A2 30 | , base 31 | default-language: Haskell2010 32 | -------------------------------------------------------------------------------- /data/only-stack/A1/A1.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: A1 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_A1 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | benchmark A1-bench 22 | type: exitcode-stdio-1.0 23 | main-is: bench.hs 24 | other-modules: 25 | Paths_A1 26 | hs-source-dirs: 27 | bench 28 | build-depends: 29 | A2 30 | , base 31 | default-language: Haskell2010 32 | -------------------------------------------------------------------------------- /data/test-only-dependency/B1/B1.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: B1 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_B1 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | test-suite A1-test 22 | type: exitcode-stdio-1.0 23 | main-is: test.hs 24 | other-modules: 25 | Paths_B1 26 | hs-source-dirs: 27 | test 28 | build-depends: 29 | B1 30 | , base 31 | default-language: Haskell2010 32 | -------------------------------------------------------------------------------- /data/only-cabal/A2/A2.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: A2 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_A2 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | test-suite A1-test 22 | type: exitcode-stdio-1.0 23 | main-is: test.hs 24 | other-modules: 25 | Paths_A2 26 | hs-source-dirs: 27 | test 28 | build-depends: 29 | A2 30 | , B1 31 | , base 32 | default-language: Haskell2010 33 | -------------------------------------------------------------------------------- /data/only-custom-cabal-plan/A1/A1.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: A1 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_A1 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | benchmark A1-bench 22 | type: exitcode-stdio-1.0 23 | main-is: bench.hs 24 | other-modules: 25 | Paths_A1 26 | hs-source-dirs: 27 | bench 28 | build-depends: 29 | A2 30 | , base 31 | default-language: Haskell2010 32 | -------------------------------------------------------------------------------- /data/only-custom-cabal-plan/B1/B1.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: B1 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_B1 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | test-suite A1-test 22 | type: exitcode-stdio-1.0 23 | main-is: test.hs 24 | other-modules: 25 | Paths_B1 26 | hs-source-dirs: 27 | test 28 | build-depends: 29 | B1 30 | , base 31 | default-language: Haskell2010 32 | -------------------------------------------------------------------------------- /data/only-stack/A2/A2.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: A2 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_A2 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | test-suite A1-test 22 | type: exitcode-stdio-1.0 23 | main-is: test.hs 24 | other-modules: 25 | Paths_A2 26 | hs-source-dirs: 27 | test 28 | build-depends: 29 | A2 30 | , B1 31 | , base 32 | default-language: Haskell2010 33 | -------------------------------------------------------------------------------- /data/test-only-dependency/A1/A1.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: A1 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_A1 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | benchmark A1-bench 22 | type: exitcode-stdio-1.0 23 | main-is: bench.hs 24 | other-modules: 25 | Paths_A1 26 | hs-source-dirs: 27 | bench 28 | build-depends: 29 | A2 30 | , base 31 | default-language: Haskell2010 32 | -------------------------------------------------------------------------------- /data/only-custom-cabal-plan/A2/A2.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: A2 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_A2 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | test-suite A1-test 22 | type: exitcode-stdio-1.0 23 | main-is: test.hs 24 | other-modules: 25 | Paths_A2 26 | hs-source-dirs: 27 | test 28 | build-depends: 29 | A2 30 | , B1 31 | , base 32 | default-language: Haskell2010 33 | -------------------------------------------------------------------------------- /data/test-only-dependency/A2/A2.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 1.12 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.34.4. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: A2 8 | version: 0.0.0 9 | build-type: Simple 10 | 11 | library 12 | other-modules: 13 | Paths_A2 14 | hs-source-dirs: 15 | src 16 | build-depends: 17 | C 18 | , base 19 | default-language: Haskell2010 20 | 21 | test-suite A1-test 22 | type: exitcode-stdio-1.0 23 | main-is: test.hs 24 | other-modules: 25 | Paths_A2 26 | hs-source-dirs: 27 | test 28 | build-depends: 29 | A2 30 | , B1 31 | , base 32 | default-language: Haskell2010 33 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph/Adapter/Cabal/Disabled.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DerivingStrategies #-} 2 | {-# LANGUAGE FlexibleInstances #-} 3 | {-# LANGUAGE OverloadedStrings #-} 4 | {-# LANGUAGE TypeFamilies #-} 5 | 6 | module Development.Guardian.Graph.Adapter.Cabal.Disabled ( 7 | buildPackageGraph, 8 | CustomPackageOptions (..), 9 | Cabal, 10 | ) where 11 | 12 | import Data.Generics.Labels () 13 | import Development.Guardian.Graph.Adapter.Cabal.Types 14 | import Development.Guardian.Graph.Adapter.Types (PackageGraphOptions (..)) 15 | import Development.Guardian.Types (PackageGraph) 16 | 17 | buildPackageGraph :: PackageGraphOptions Cabal -> IO PackageGraph 18 | buildPackageGraph _ = error "Cabal backend disabled!" 19 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph/Adapter/Stack/Disabled.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE FlexibleInstances #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE TypeFamilies #-} 4 | 5 | module Development.Guardian.Graph.Adapter.Stack.Disabled ( 6 | buildPackageGraphM, 7 | buildPackageGraph, 8 | Stack, 9 | CustomPackageOptions (..), 10 | ) where 11 | 12 | import Development.Guardian.Graph.Adapter.Stack.Types 13 | import Development.Guardian.Graph.Adapter.Types 14 | import Development.Guardian.Types (PackageGraph) 15 | import RIO (RIO) 16 | 17 | buildPackageGraph :: PackageGraphOptions Stack -> IO PackageGraph 18 | buildPackageGraph _ = error "buildPackageGraph: Stack adapter is disabled" 19 | 20 | buildPackageGraphM :: RIO env PackageGraph 21 | buildPackageGraphM = error "buildPackageGraphM: Stack adapter is disabled" 22 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph/Adapter/Stack/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveGeneric #-} 2 | {-# LANGUAGE FlexibleInstances #-} 3 | {-# LANGUAGE OverloadedStrings #-} 4 | {-# LANGUAGE TypeFamilies #-} 5 | 6 | module Development.Guardian.Graph.Adapter.Stack.Types ( 7 | Stack, 8 | CustomPackageOptions (..), 9 | ) where 10 | 11 | import Data.Aeson (FromJSON (parseJSON)) 12 | import qualified Data.Aeson as J 13 | import Data.Text (Text) 14 | import Development.Guardian.Graph.Adapter.Types 15 | import GHC.Generics (Generic) 16 | 17 | data Stack 18 | 19 | newtype instance CustomPackageOptions Stack = StackOptions {stackOptions :: [Text]} 20 | deriving (Show, Eq, Ord, Generic) 21 | 22 | instance FromJSON (CustomPackageOptions Stack) where 23 | parseJSON = J.withObject "{stack: }" $ \obj -> do 24 | stack <- obj J..:? "stack" 25 | case stack of 26 | Nothing -> pure $ StackOptions [] 27 | Just dic -> StackOptions <$> dic J..:? "options" J..!= [] 28 | -------------------------------------------------------------------------------- /scripts/make-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | RELEASE="${1}" 5 | DL_DIR="${2}" 6 | PROJ_ROOT="${GITHUB_WORKSPACE:-../../}" 7 | 8 | DEST_DIR="$(pwd)/dest" 9 | mkdir -p "${DEST_DIR}" 10 | 11 | echo "*** Making Release for ${RELEASE}" 12 | echo "Using Binaries from: ${DL_DIR}" 13 | echo "With project root: ${PROJ_ROOT}" 14 | 15 | set -x 16 | echo "[*] Compress binaries" 17 | 18 | MAC_GZ="guardian-${RELEASE}-x86_64-macOS.tar.gz" 19 | MAC_BIN_DIR="${DL_DIR}/bins-macOS" 20 | pushd "${MAC_BIN_DIR}" 21 | chmod +x ./guardian 22 | tar --use-compress-program="gzip -9" -cf "${DEST_DIR}/${MAC_GZ}" ./guardian 23 | popd 24 | 25 | LINUX_GZ="guardian-${RELEASE}-x86_64-linux.tar.gz" 26 | LINUX_BIN_DIR="${DL_DIR}/bins-Linux" 27 | pushd "${LINUX_BIN_DIR}" 28 | chmod +x ./guardian 29 | tar --use-compress-program="gzip -9" -cf "${DEST_DIR}/${LINUX_GZ}" ./guardian 30 | popd 31 | 32 | cd "${DEST_DIR}" 33 | sha256sum "${LINUX_GZ}" "${MAC_GZ}" >SHA256SUMS 34 | 35 | gh release create --draft -F "${PROJ_ROOT}"/ChangeLog.md -t "${RELEASE}" \ 36 | "v${RELEASE}" SHA256SUMS "${MAC_GZ}" "${LINUX_GZ}" 37 | -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | # Changelog for guardian 2 | 3 | 4 | ## 0.5.0.0 5 | 6 | - Now guaridan can talk with external program to get a dependency graph! 7 | + Currently, it supports Dot language as the only input format. 8 | + This enables you to use guardian to maintain: 9 | - non-Haskell projects 10 | - the dependency between arbitrary entities not limited to packages, e.g. modules. 11 | + See [`./dependency-domains-graphmod.yaml`](./dependency-domains-graphmod.yaml) for the example usage in conjunction with `graphmod` to maintain _module_ dependecy boundaries. 12 | - Implements `wildcards` section: if `wildcards: true` is specified, you can use wildcard `*` in your domain entity definition. 13 | + You cannot use wildcards in exceptional rule targets; this is intentional. 14 | - Supports conditional builds: when you build guardian manually, you can disable unnecessary adapters. 15 | Just turn off `cabal` and/or `stack` flags to disable corresponding adapters. 16 | - Rework on GitHub Actions. 17 | - Bumps up Cabal and Stack version. This should address the error when CABAL_DIR is not set to `~/.cabal`. 18 | 19 | ## 0.4.0.0 20 | 21 | - This is the first official OSS release! :tada: 22 | -------------------------------------------------------------------------------- /dependency-domains-graphmod.yaml: -------------------------------------------------------------------------------- 1 | wildcards: true 2 | 3 | domains: 4 | app: 5 | depends_on: [app_impl] 6 | packages: 7 | - app.Main 8 | infra: 9 | depends_on: [] 10 | packages: 11 | - Development.Guardian.Constants 12 | - Development.Guardian.Types 13 | - Development.Guardian.Flags 14 | - Text.Pattern 15 | adapter-base: 16 | depends_on: [infra] 17 | packages: 18 | - Development.Guardian.Graph.Adapter.Types 19 | ## Adapters 20 | adapter-cabal: 21 | depends_on: [adapter-base] 22 | packages: 23 | - Development.Guardian.Graph.Adapter.Cabal 24 | - Development.Guardian.Graph.Adapter.Cabal.* 25 | adapter-stack: 26 | depends_on: [adapter-base] 27 | packages: 28 | - Development.Guardian.Graph.Adapter.Stack 29 | - Development.Guardian.Graph.Adapter.Stack.* 30 | adapter-custom: 31 | depends_on: [adapter-base] 32 | packages: 33 | - Development.Guardian.Graph.Adapter.Custom 34 | adapters: 35 | depends_on: [adapter-cabal, adapter-stack, adapter-custom] 36 | packages: 37 | - Development.Guardian.Graph.Adapter.Detection 38 | logic: 39 | depends_on: [infra] 40 | packages: 41 | - Development.Guardian.Graph 42 | app_impl: 43 | depends_on: [adapters, logic] 44 | packages: 45 | - Development.Guardian.App 46 | 47 | components: 48 | tests: true 49 | benchmarks: false 50 | 51 | custom: 52 | shell: graphmod --no-cluster 2>/dev/null 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/14f8a8b4c51ecc00b18905a95c117954e6c77b9d/Haskell.gitignore 2 | 3 | dist 4 | dist-* 5 | cabal-dev 6 | *.o 7 | *.hi 8 | *.hie 9 | *.chi 10 | *.chs.h 11 | *.dyn_o 12 | *.dyn_hi 13 | .hpc 14 | .hsenv 15 | .cabal-sandbox/ 16 | cabal.sandbox.config 17 | *.prof 18 | *.aux 19 | *.hp 20 | *.eventlog 21 | .stack-work/ 22 | cabal.project.local 23 | cabal.project.local~ 24 | .HTF/ 25 | .ghc.environment.* 26 | 27 | 28 | ### https://raw.github.com/github/gitignore/14f8a8b4c51ecc00b18905a95c117954e6c77b9d/Global/macOS.gitignore 29 | 30 | # General 31 | .DS_Store 32 | .AppleDouble 33 | .LSOverride 34 | 35 | # Icon must end with two \r 36 | Icon 37 | 38 | 39 | # Thumbnails 40 | ._* 41 | 42 | # Files that might appear in the root of a volume 43 | .DocumentRevisions-V100 44 | .fseventsd 45 | .Spotlight-V100 46 | .TemporaryItems 47 | .Trashes 48 | .VolumeIcon.icns 49 | .com.apple.timemachine.donotpresent 50 | 51 | # Directories potentially created on remote AFP share 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | 58 | 59 | ### https://raw.github.com/github/gitignore/14f8a8b4c51ecc00b18905a95c117954e6c77b9d/Global/VisualStudioCode.gitignore 60 | 61 | .vscode/* 62 | !.vscode/settings.shared.json 63 | !.vscode/*.shared.json 64 | !.vscode/tasks.json 65 | !.vscode/launch.json 66 | !.vscode/extensions.json 67 | *.code-workspace 68 | 69 | # Local History for Visual Studio Code 70 | .history/ 71 | 72 | 73 | -------------------------------------------------------------------------------- /action/action.yml: -------------------------------------------------------------------------------- 1 | name: Lint by Guardian 2 | description: | 3 | Validates dependency boundary constraint by guardian. 4 | 5 | inputs: 6 | version: 7 | description: The version of guardian. 8 | default: 'latest' 9 | required: false 10 | backend: 11 | description: Project backend (stack, cabal, or auto) 12 | default: 'auto' 13 | required: false 14 | config: 15 | description: | 16 | Guardian config file (default: dependency-domains.yaml) 17 | required: false 18 | default: "" 19 | target: 20 | description: "Target directory (default: .)" 21 | required: false 22 | default: "" 23 | 24 | runs: 25 | using: composite 26 | steps: 27 | - name: Setup env 28 | shell: bash 29 | run: | 30 | echo "${GITHUB_ACTION_PATH}" >> "${GITHUB_PATH}" 31 | - name: Download guardian binary 32 | shell: bash 33 | continue-on-error: false 34 | id: download 35 | run: | 36 | download-guardian.sh "${{ inputs.version }}" "${GITHUB_ACTION_PATH}" 37 | - name: Apply guardian 38 | continue-on-error: false 39 | shell: bash 40 | run: | 41 | set -euo pipefail 42 | declare -a CONFIG=() 43 | if [ -n "${{inputs.config}}" ]; then 44 | CONFIG=(--config "${{inputs.config}}") 45 | fi 46 | declare -a TARGET=() 47 | if [ -n "${{inputs.target}}" ]; then 48 | TARGET=("${{inputs.target}}") 49 | fi 50 | guardian ${{inputs.backend}} "${CONFIG[@]}" "${TARGET[@]}" 51 | 52 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph/Adapter/Cabal/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveGeneric #-} 2 | {-# LANGUAGE DerivingStrategies #-} 3 | {-# LANGUAGE FlexibleInstances #-} 4 | {-# LANGUAGE OverloadedStrings #-} 5 | {-# LANGUAGE RecordWildCards #-} 6 | {-# LANGUAGE TypeFamilies #-} 7 | 8 | module Development.Guardian.Graph.Adapter.Cabal.Types ( 9 | CustomPackageOptions (..), 10 | Cabal, 11 | ) where 12 | 13 | import Control.Applicative (optional, (<|>)) 14 | import Data.Aeson (FromJSON, withObject, (.:)) 15 | import qualified Data.Aeson.KeyMap as AKM 16 | import Data.Aeson.Types (FromJSON (..)) 17 | import Data.Generics.Labels () 18 | import Development.Guardian.Graph.Adapter.Types (CustomPackageOptions) 19 | import GHC.Generics (Generic) 20 | import Path 21 | 22 | data Cabal deriving (Generic) 23 | 24 | data instance CustomPackageOptions Cabal = CabalOptions {projectFile :: Maybe (Path Rel File), update :: Maybe (Either Bool String)} 25 | deriving (Show, Eq, Ord, Generic) 26 | 27 | instance FromJSON (CustomPackageOptions Cabal) where 28 | parseJSON = withObject "{cabal: ...}" $ \obj -> 29 | if AKM.member "cabal" obj 30 | then do 31 | dic <- obj .: "cabal" 32 | projectFile <- optional $ dic .: "projectFile" 33 | update <- 34 | if AKM.member "update" dic 35 | then 36 | fmap Just $ 37 | Left <$> (dic .: "update") 38 | <|> Right <$> (dic .: "update") 39 | else pure Nothing 40 | pure CabalOptions {..} 41 | else pure (CabalOptions Nothing Nothing) 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, DeepFlow, Inc. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of DeepFlow, Inc. nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /action/download-guardian.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | log() { 4 | echo >&2 "$1" 5 | } 6 | 7 | pushd () { 8 | command pushd "$@" >&2 9 | } 10 | 11 | popd () { 12 | command popd >&2 13 | } 14 | 15 | 16 | [ -z "${1}" ] && log "Usage: download-guardian.sh (RELEASE) [DOWNLOAD_DIR]" && exit 1 17 | 18 | set -euo pipefail 19 | RELEASE="${1}" 20 | DOWNLOAD_PATH="${2:-guardian-$(date +%Y%m%d-%H%M%S)}" 21 | 22 | UNAME="$(uname -a)" 23 | case "${UNAME}" in 24 | Linux*) 25 | OS="linux";; 26 | Darwin*) 27 | OS="macOS";; 28 | *) 29 | log "Could not determine OS running CI; currently macOS and Linux on x86_64 supported." >&2 30 | exit 1; 31 | esac 32 | 33 | if [ "${RELEASE}" = "latest" ]; then 34 | PAYLOAD=$(curl https://api.github.com/repos/deepflowinc-oss/guardian/releases/latest) 35 | else 36 | PAYLOAD=$(curl "https://api.github.com/repos/deepflowinc-oss/guardian/releases/tags/v${RELEASE}") 37 | fi 38 | 39 | if RELEASE="$(echo "${PAYLOAD}" | jq -r ".name")"; then 40 | log "Downloading guardian version: ${RELEASE}..." 41 | mkdir -p "${DOWNLOAD_PATH}" 42 | TARBALL="guardian-${RELEASE}-x86_64-${OS}.tar.gz" 43 | pushd "${DOWNLOAD_PATH}" 44 | echo "${PAYLOAD}" | jq -r '.assets | map(.browser_download_url) | .[]' | while read -r URL; do 45 | curl --location --remote-name "${URL}" >/dev/null 2>&1 46 | done 47 | log "Downloaded." 48 | # shared runner lacks GPG... 49 | # gpg --verify SHA256SUMS.asc >/dev/null 2>&1 50 | log "Checking SHA256 sums..." 51 | sha256sum --check --ignore-missing SHA256SUMS >/dev/null 2>&1 52 | tar xzf "${TARBALL}" 53 | rm ./*.tar.gz ./SHA256SUMS* 54 | popd 55 | echo "${DOWNLOAD_PATH}/guardian" 56 | else 57 | log "No such release: ${RELEASE}" 58 | exit 1 59 | fi 60 | -------------------------------------------------------------------------------- /test/Development/Guardian/Graph/Adapter/TestUtils.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE FlexibleContexts #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | {-# LANGUAGE RecordWildCards #-} 4 | {-# LANGUAGE StandaloneDeriving #-} 5 | {-# LANGUAGE UndecidableInstances #-} 6 | 7 | module Development.Guardian.Graph.Adapter.TestUtils ( 8 | Case (..), 9 | testProjectWith, 10 | testProjectWithLabel, 11 | ) where 12 | 13 | import Development.Guardian.Graph.Adapter.Types 14 | import Development.Guardian.Types 15 | import Path 16 | import Path.IO 17 | import Test.Tasty (TestTree) 18 | import Test.Tasty.HUnit (testCase, (@?=)) 19 | 20 | data Case adapter = Case 21 | { caseDir :: Path Rel Dir 22 | , expectedGraph :: PackageGraph 23 | , componentOpts :: ComponentsOptions 24 | , customOpts :: CustomPackageOptions adapter 25 | } 26 | 27 | deriving instance Eq (CustomPackageOptions adapter) => Eq (Case adapter) 28 | 29 | deriving instance Ord (CustomPackageOptions adapter) => Ord (Case adapter) 30 | 31 | deriving instance Show (CustomPackageOptions adapter) => Show (Case adapter) 32 | 33 | testProjectWith :: 34 | (PackageGraphOptions adapter -> IO PackageGraph) -> 35 | Case adapter -> 36 | TestTree 37 | testProjectWith build = testProjectWithLabel build <$> fromRelDir . caseDir <*> id 38 | 39 | testProjectWithLabel :: 40 | (PackageGraphOptions adapter -> IO PackageGraph) -> 41 | String -> 42 | Case adapter -> 43 | TestTree 44 | testProjectWithLabel buildGraph label Case {..} = testCase name $ do 45 | targetPath <- canonicalizePath $ [reldir|data|] caseDir 46 | 47 | let components = componentOpts 48 | customOptions = customOpts 49 | graph <- buildGraph PackageGraphOptions {..} 50 | graph @?= expectedGraph 51 | where 52 | ComponentsOptions {..} = componentOpts 53 | sgn True = "+" 54 | sgn False = "-" 55 | name = 56 | label 57 | <> " (" 58 | <> sgn tests 59 | <> "test, " 60 | <> sgn benchmarks 61 | <> "bench" 62 | <> ")" 63 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph/Adapter/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveGeneric #-} 2 | {-# LANGUAGE FlexibleContexts #-} 3 | {-# LANGUAGE OverloadedStrings #-} 4 | {-# LANGUAGE RecordWildCards #-} 5 | {-# LANGUAGE StandaloneDeriving #-} 6 | {-# LANGUAGE TypeFamilies #-} 7 | {-# LANGUAGE UndecidableInstances #-} 8 | 9 | module Development.Guardian.Graph.Adapter.Types ( 10 | PackageBuildParser (..), 11 | CustomPackageOptions, 12 | PackageGraphOptions (..), 13 | ComponentsOptions (..), 14 | StandardAdapters (..), 15 | ) where 16 | 17 | import Data.Aeson (FromJSON (..)) 18 | import qualified Data.Aeson as J 19 | import GHC.Generics (Generic) 20 | import Path (Abs, Dir, Path) 21 | 22 | data StandardAdapters = Stack | Cabal | Custom 23 | deriving (Show, Eq, Ord) 24 | 25 | data family CustomPackageOptions backend 26 | 27 | newtype PackageBuildParser backend = PackageBuildParser 28 | { withTargetPath :: Path Abs Dir -> PackageGraphOptions backend 29 | } 30 | 31 | instance 32 | FromJSON (CustomPackageOptions backend) => 33 | FromJSON (PackageBuildParser backend) 34 | where 35 | parseJSON obj = do 36 | customOptions <- parseJSON obj 37 | components <- parseJSON obj 38 | pure $ PackageBuildParser $ \targetPath -> PackageGraphOptions {..} 39 | 40 | data PackageGraphOptions backend = PackageGraphOptions 41 | { targetPath :: !(Path Abs Dir) 42 | , components :: !ComponentsOptions 43 | , customOptions :: CustomPackageOptions backend 44 | } 45 | deriving (Generic) 46 | 47 | deriving instance 48 | Show (CustomPackageOptions backend) => 49 | Show (PackageGraphOptions backend) 50 | 51 | deriving instance 52 | Eq (CustomPackageOptions backend) => 53 | Eq (PackageGraphOptions backend) 54 | 55 | deriving instance 56 | Ord (CustomPackageOptions backend) => 57 | Ord (PackageGraphOptions backend) 58 | 59 | data ComponentsOptions = ComponentsOptions 60 | { tests :: Bool 61 | , benchmarks :: Bool 62 | } 63 | deriving (Show, Eq, Ord, Generic) 64 | 65 | instance FromJSON ComponentsOptions where 66 | parseJSON = J.withObject "{components: ...}" $ \dic -> do 67 | mcomp <- dic J..:? "components" 68 | case mcomp of 69 | Nothing -> pure ComponentsOptions {tests = True, benchmarks = True} 70 | Just comps -> do 71 | tests <- comps J..: "tests" J..!= True 72 | benchmarks <- comps J..: "benchmarks" J..!= True 73 | pure ComponentsOptions {..} 74 | -------------------------------------------------------------------------------- /test/Development/Guardian/Graph/Adapter/StackSpec.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | {-# LANGUAGE RecordWildCards #-} 4 | 5 | module Development.Guardian.Graph.Adapter.StackSpec where 6 | 7 | import qualified Algebra.Graph as G 8 | import Development.Guardian.Graph.Adapter.Stack 9 | import qualified Development.Guardian.Graph.Adapter.Stack as Stack 10 | import Development.Guardian.Graph.Adapter.TestUtils 11 | import Development.Guardian.Graph.Adapter.Types 12 | import Path 13 | import Test.Tasty 14 | 15 | onlyExes :: ComponentsOptions 16 | onlyExes = ComponentsOptions {tests = False, benchmarks = False} 17 | 18 | test_buildPackageGraph :: TestTree 19 | test_buildPackageGraph = 20 | testGroup 21 | "buildPackageGraph" 22 | $ map 23 | testStackProject 24 | [ Case 25 | { caseDir = [reldir|test-only-dependency|] 26 | , expectedGraph = G.edges [("A1", "C"), ("A2", "C"), ("B1", "C")] 27 | , componentOpts = onlyExes 28 | , customOpts = StackOptions ["--silent"] 29 | } 30 | , Case 31 | { caseDir = [reldir|test-only-dependency|] 32 | , expectedGraph = G.edges [("A1", "C"), ("A2", "C"), ("A2", "B1"), ("B1", "C")] 33 | , componentOpts = onlyExes {tests = True} 34 | , customOpts = StackOptions ["--silent"] 35 | } 36 | , Case 37 | { caseDir = [reldir|test-only-dependency|] 38 | , expectedGraph = G.edges [("A1", "C"), ("A1", "A2"), ("A2", "C"), ("B1", "C")] 39 | , componentOpts = onlyExes {benchmarks = True} 40 | , customOpts = StackOptions ["--silent"] 41 | } 42 | , Case 43 | { caseDir = [reldir|test-only-dependency|] 44 | , expectedGraph = G.edges [("A1", "C"), ("A1", "A2"), ("A2", "C"), ("A2", "B1"), ("B1", "C")] 45 | , componentOpts = onlyExes {tests = True, benchmarks = True} 46 | , customOpts = StackOptions ["--silent"] 47 | } 48 | ] 49 | ++ [ testStackProjectWithLabel 50 | "test-only-dependency (with stack-no-A1.yaml)" 51 | Case 52 | { caseDir = [reldir|test-only-dependency|] 53 | , expectedGraph = G.edges [("A2", "C"), ("B1", "C")] 54 | , componentOpts = onlyExes 55 | , customOpts = StackOptions ["--stack-yaml=stack-no-A1.yaml", "--silent"] 56 | } 57 | ] 58 | 59 | testStackProjectWithLabel :: String -> Case Stack -> TestTree 60 | testStackProjectWithLabel = testProjectWithLabel Stack.buildPackageGraph 61 | 62 | testStackProject :: Case Stack -> TestTree 63 | testStackProject = testProjectWith Stack.buildPackageGraph 64 | -------------------------------------------------------------------------------- /test/Development/Guardian/Graph/Adapter/CabalSpec.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | {-# LANGUAGE RecordWildCards #-} 4 | 5 | module Development.Guardian.Graph.Adapter.CabalSpec (test_buildPackageGraph) where 6 | 7 | import qualified Algebra.Graph as G 8 | import Development.Guardian.Graph.Adapter.Cabal 9 | import qualified Development.Guardian.Graph.Adapter.Cabal as Cabal 10 | import Development.Guardian.Graph.Adapter.TestUtils 11 | import Development.Guardian.Graph.Adapter.Types 12 | import Path 13 | import Test.Tasty 14 | 15 | onlyExes :: ComponentsOptions 16 | onlyExes = ComponentsOptions {tests = False, benchmarks = False} 17 | 18 | test_buildPackageGraph :: TestTree 19 | test_buildPackageGraph = 20 | testGroup 21 | "buildPackageGraph" 22 | $ map 23 | testCabalProject 24 | [ Case 25 | { caseDir = [reldir|test-only-dependency|] 26 | , expectedGraph = G.edges [("A1", "C"), ("A2", "C"), ("B1", "C")] 27 | , componentOpts = onlyExes 28 | , customOpts = CabalOptions Nothing Nothing 29 | } 30 | , Case 31 | { caseDir = [reldir|test-only-dependency|] 32 | , expectedGraph = G.edges [("A1", "C"), ("A2", "C"), ("A2", "B1"), ("B1", "C")] 33 | , componentOpts = onlyExes {tests = True} 34 | , customOpts = CabalOptions Nothing Nothing 35 | } 36 | , Case 37 | { caseDir = [reldir|test-only-dependency|] 38 | , expectedGraph = G.edges [("A1", "C"), ("A1", "A2"), ("A2", "C"), ("B1", "C")] 39 | , componentOpts = onlyExes {benchmarks = True} 40 | , customOpts = CabalOptions Nothing Nothing 41 | } 42 | , Case 43 | { caseDir = [reldir|test-only-dependency|] 44 | , expectedGraph = G.edges [("A1", "C"), ("A1", "A2"), ("A2", "C"), ("A2", "B1"), ("B1", "C")] 45 | , componentOpts = onlyExes {tests = True, benchmarks = True} 46 | , customOpts = CabalOptions Nothing Nothing 47 | } 48 | ] 49 | ++ [ testCabalProjectWithLabel 50 | "test-only-dependency (with cabal-no-A1.yaml)" 51 | Case 52 | { caseDir = [reldir|test-only-dependency|] 53 | , expectedGraph = G.edges [("A2", "C"), ("B1", "C")] 54 | , componentOpts = onlyExes 55 | , customOpts = CabalOptions (Just [relfile|cabal-no-A1.project|]) Nothing 56 | } 57 | ] 58 | 59 | testCabalProjectWithLabel :: String -> Case Cabal -> TestTree 60 | testCabalProjectWithLabel = testProjectWithLabel Cabal.buildPackageGraph 61 | 62 | testCabalProject :: Case Cabal -> TestTree 63 | testCabalProject = testProjectWith Cabal.buildPackageGraph 64 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | If you have any issue, please report it to the [GitHub issue tracker for this project][issue]. 4 | When you open an issue, please check whether there is alredy similar one(s). 5 | 6 | We are also accepting [Pull Requests][pulls] - you can feel free to open pull requests for small changes, e.g. typo correction. 7 | If you are planning a considerably big change, however, please first discuss the change you want to make via an issue. 8 | 9 | ## Development Tooling 10 | 11 | - For simplicity, we use `cabal-install >= 3.8` for build tool. 12 | - For technical reason, we are bound to GHC 9.0.2 for the time being - this restriction will be lifted once GHC can handle I/O in Template Haskell splice without segfaulting on alpine static build; see [ghc#20266][splice-issue] for more details. 13 | - We are using [`hpack`](https://github.com/sol/hpack) to generate `*.cabal` from `package.yaml`. Make sure all the `.cabal` files are up-to-date with `package.yaml`. 14 | - We strongly recommend to use the [pre-commit][pre-commit] tool. 15 | It runs hooks shared across the repository in Git's pre-commit hook. 16 | Currently, the following hooks are enabled: 17 | 18 | + A protection rule for `main` branch preventing making commit to protected branch 19 | + Runs hpack if it's in `$PATH`. 20 | 21 | [pre-commit]: https://pre-commit.com 22 | 23 | There is no obligation about editors - you can use any editor of your choice. 24 | We recommend to use [HLS][HLS] as a Language Server when available. 25 | 26 | If you use VSCode, it is recommended to install the following extensions (as included in `.vscode/extensions.json`): 27 | 28 | - [Haskell](https://marketplace.visualstudio.com/items?itemName=haskell.haskell) extension boosts productivity in writing Haskell. 29 | - [Workspace Config+](https://marketplace.visualstudio.com/items?itemName=swellaby.workspace-config-plus) lets you coexist shared settings and local configs. 30 | - [Run On Save](https://marketplace.visualstudio.com/items?itemName=emeraldwalk.RunOnSave) ensures `*.cabal`s to be up-to-date with `package.yaml`. 31 | 32 | All the source code (except `Setup.hs`) MUST be formatted with [Fourmolu][fourmolu]. 33 | HLS supports fourmolu as a formatter, so we strongly recommend to use HLS and enable `Format on Save` in your editor. 34 | 35 | [issue]: https://github.com/deepflowinc-oss/guardian/issues 36 | [pulls]: https://github.com/deepflowinc-oss/guardian/pulls 37 | [splice-issue]: https://gitlab.haskell.org/ghc/ghc/-/issues/20266 38 | [fourmolu]: https://github.com/fourmolu/fourmolu 39 | [HLS]: https://github.com/haskell/haskell-language-server 40 | 41 | ## Pull Request Requirements 42 | 43 | To get PRs merged, following criteria must be met: 44 | 45 | - We require [commits to be signed][commit-sign] in order to be merged into main branch. 46 | - The following tests must be all green: 47 | + `Fourmolu` - to enforce consistent formatting 48 | + `Build` and `Test` for macOS and Linux - make sure we always have working binaries 49 | - It is strongly recommended to write commit messages obeying [Conventional Commit][convcom] guidline. 50 | 51 | [convcom]: https://www.conventionalcommits.org 52 | [commit-sign]: https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits 53 | -------------------------------------------------------------------------------- /package.yaml: -------------------------------------------------------------------------------- 1 | name: guardian 2 | version: 0.5.0.0 3 | license: BSD-3-Clause 4 | author: "DeepFlow, Inc." 5 | maintainer: "DeepFlow, Inc." 6 | copyright: "(c) 2021-2023, DeepFlow, Inc." 7 | github: deepflowinc-oss/guardian 8 | 9 | extra-source-files: 10 | - README.md 11 | - ChangeLog.md 12 | 13 | # Metadata used when publishing your package 14 | synopsis: The border guardian for your package dependencies 15 | category: Development 16 | 17 | description: 18 | Guardian secures your Haskell monorepo package dependency boundary. 19 | Please read [README.md](https://github.com/deepflowinc-oss/guardian#readme) for more details. 20 | 21 | flags: 22 | cabal: 23 | description: Enables Cabal adapter 24 | manual: true 25 | default: true 26 | stack: 27 | description: Enables Cabal adapter 28 | manual: true 29 | default: true 30 | test-cabal-plan: 31 | description: Enables testcases for custom+cabal-plan 32 | manual: true 33 | default: true 34 | test-graphmod: 35 | description: Enables testcases for custom+cabal-plan 36 | manual: true 37 | default: true 38 | 39 | ghc-options: 40 | - -Wall 41 | - -Wunused-packages 42 | 43 | data-dir: data 44 | 45 | dependencies: 46 | - base >= 4.7 && < 5 47 | 48 | library: 49 | source-dirs: src 50 | dependencies: 51 | - aeson 52 | - algebraic-graphs 53 | - bytestring-trie 54 | - case-insensitive 55 | - containers 56 | - dlist 57 | - generic-lens 58 | - githash 59 | - hashable 60 | - indexed-traversable 61 | - indexed-traversable-instances 62 | - language-dot 63 | - optparse-applicative 64 | - parsec 65 | - path 66 | - path-io 67 | - regex-applicative-text 68 | - rio 69 | - semigroups 70 | - text 71 | - template-haskell 72 | - transformers 73 | - typed-process 74 | - unordered-containers 75 | - validation-selective 76 | - vector 77 | - yaml 78 | when: 79 | - condition: flag(cabal) 80 | then: 81 | dependencies: 82 | - cabal-install 83 | - Cabal-syntax 84 | - Cabal 85 | cpp-options: [-DENABLE_CABAL] 86 | other-modules: 87 | - Development.Guardian.Graph.Adapter.Cabal.Enabled 88 | else: 89 | other-modules: 90 | - Development.Guardian.Graph.Adapter.Cabal.Disabled 91 | - condition: flag(stack) 92 | then: 93 | dependencies: 94 | - stack >= 2.9 95 | - Cabal 96 | cpp-options: [-DENABLE_STACK] 97 | other-modules: 98 | - Development.Guardian.Graph.Adapter.Stack.Enabled 99 | else: 100 | other-modules: 101 | - Development.Guardian.Graph.Adapter.Stack.Disabled 102 | 103 | tests: 104 | guardian-test: 105 | when: 106 | - condition: flag(test-cabal-plan) 107 | cpp-options: ["-DTEST_CABAL_PLAN"] 108 | - condition: flag(test-graphmod) 109 | cpp-options: ["-DTEST_GRAPHMOD"] 110 | main: Spec.hs 111 | source-dirs: test 112 | build-tools: 113 | - tasty-discover 114 | dependencies: 115 | - algebraic-graphs 116 | - bytestring 117 | - containers 118 | - guardian 119 | - path 120 | - path-io 121 | - rio 122 | - tasty 123 | - tasty-hunit 124 | - tasty-expected-failure 125 | - text 126 | - unordered-containers 127 | - validation-selective 128 | 129 | executables: 130 | guardian: 131 | source-dirs: app 132 | main: Main.hs 133 | dependencies: 134 | - guardian 135 | -------------------------------------------------------------------------------- /src/Text/Pattern.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveAnyClass #-} 2 | {-# LANGUAGE DeriveGeneric #-} 3 | {-# LANGUAGE DerivingStrategies #-} 4 | {-# LANGUAGE GeneralizedNewtypeDeriving #-} 5 | {-# LANGUAGE LambdaCase #-} 6 | {-# LANGUAGE OverloadedStrings #-} 7 | 8 | module Text.Pattern ( 9 | Pattern (), 10 | concretePrefix, 11 | parsePattern, 12 | match, 13 | patternRE, 14 | ) where 15 | 16 | import Data.Aeson (FromJSON (..)) 17 | import qualified Data.Aeson as J 18 | import Data.Foldable (fold) 19 | import qualified Data.Foldable as F 20 | import Data.Functor.Compose (Compose (..)) 21 | import Data.Hashable (Hashable) 22 | import Data.Maybe (fromMaybe, isJust) 23 | import Data.Sequence (Seq (..)) 24 | import qualified Data.Sequence as Seq 25 | import Data.String (IsString (..)) 26 | import Data.Text (Text) 27 | import qualified Data.Text as T 28 | import GHC.Generics (Generic) 29 | import Text.Regex.Applicative.Text (RE', (<|>)) 30 | import qualified Text.Regex.Applicative.Text as RE 31 | 32 | data Token = Literal !Text | Wildcard 33 | deriving (Show, Eq, Ord, Generic) 34 | deriving anyclass (Hashable) 35 | 36 | newtype Pattern = Pattern {getTokens :: [Token]} 37 | deriving (Eq, Ord, Generic) 38 | deriving newtype (Hashable) 39 | 40 | instance Show Pattern where 41 | showsPrec _ (Pattern toks) = foldl (.) id $ map go toks 42 | where 43 | go (Literal t) = showString $ T.unpack $ T.replace "*" "\\*" t 44 | go Wildcard = showChar '*' 45 | 46 | -- >>> "hoo\\*bar" :: Pattern 47 | -- /hoo\*bar/ 48 | 49 | concretePrefix :: Pattern -> Text 50 | {-# INLINE concretePrefix #-} 51 | concretePrefix = 52 | fold 53 | . Compose 54 | . takeWhile isJust 55 | . map (\case Literal t -> Just t; _ -> Nothing) 56 | . getTokens 57 | 58 | instance IsString Pattern where 59 | fromString = fromMaybe (error "fromString(Pattern): parse failed!") . parsePattern . T.pack 60 | {-# INLINE fromString #-} 61 | 62 | compilePattern :: Pattern -> RE' [Text] 63 | {-# INLINE compilePattern #-} 64 | compilePattern = traverse go . getTokens 65 | where 66 | go (Literal t) = RE.string t 67 | go Wildcard = textOf $ RE.many RE.anySym 68 | 69 | textOf :: RE' a -> RE' Text 70 | textOf = fmap snd . RE.withMatched 71 | 72 | instance FromJSON Pattern where 73 | parseJSON = J.withText "pattern" $ \text -> 74 | maybe (fail $ "Invalid pattern: " <> show text) pure $ 75 | parsePattern text 76 | {-# INLINE parseJSON #-} 77 | 78 | {- | 79 | Matches against the pattern. 80 | 81 | >>> match "foo*bar" "foobar" 82 | Just "foobar" 83 | 84 | >>> match "foo.bar.*" "foo.bar.buz" 85 | Just "foo.bar.buz" 86 | 87 | >>> match "foo\\*bar" "foobar" 88 | Nothing 89 | -} 90 | match :: Pattern -> Text -> Maybe Text 91 | {-# INLINE match #-} 92 | match = fmap (fmap mconcat) . RE.match . compilePattern 93 | 94 | parsePattern :: Text -> Maybe Pattern 95 | parsePattern = RE.match patternRE 96 | 97 | patternRE :: RE' Pattern 98 | patternRE = 99 | Pattern . F.toList 100 | <$> RE.reFoldl 101 | RE.Greedy 102 | ( \case 103 | Seq.Empty -> Seq.singleton 104 | ls@(_ :|> Wildcard) -> \case 105 | Wildcard -> ls 106 | r -> ls :|> r 107 | ls@(ls0 :|> Literal l) -> \case 108 | Literal r -> ls0 :|> Literal (l <> r) 109 | r -> ls :|> r 110 | ) 111 | Seq.empty 112 | ( Literal <$> textOf (RE.many $ RE.psym (`notElem` ['\\', '*'])) 113 | <|> Wildcard <$ RE.sym '*' 114 | <|> Literal . T.singleton <$ RE.sym '\\' <*> RE.anySym 115 | ) 116 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph/Adapter/Detection.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveGeneric #-} 2 | {-# LANGUAGE LambdaCase #-} 3 | {-# LANGUAGE MultiWayIf #-} 4 | {-# LANGUAGE OverloadedStrings #-} 5 | {-# LANGUAGE QuasiQuotes #-} 6 | 7 | module Development.Guardian.Graph.Adapter.Detection ( 8 | detectAdapter, 9 | detectAdapterThrow, 10 | detectFromDir, 11 | detectFromDomainConfig, 12 | DetectionFailure (..), 13 | ) where 14 | 15 | import Control.Monad.Trans.Except (ExceptT (..), catchE, runExceptT, throwE) 16 | import Data.Aeson (Value) 17 | import qualified Data.Aeson as J 18 | import qualified Data.Aeson.KeyMap as AKM 19 | import Development.Guardian.Graph.Adapter.Types 20 | import Path (Path, relfile, ()) 21 | import Path.IO 22 | import Path.Posix (Dir) 23 | import RIO 24 | 25 | data DetectionFailure 26 | = MultipleAdapterConfigFound 27 | | BothCabalProjectAndStackYamlFound 28 | | NeitherCabalProjectNorStackYamlFound 29 | | NoCustomConfigSpecified 30 | | MalformedConfigYaml Value 31 | deriving (Show, Eq, Ord, Generic) 32 | 33 | instance Exception DetectionFailure where 34 | displayException MultipleAdapterConfigFound = 35 | "Could not determine adapter: dependency-domain.yml contains multiple adapter configs" 36 | displayException BothCabalProjectAndStackYamlFound = 37 | "Could not determine adapter: Both cabal.project and stack.yaml found" 38 | displayException NeitherCabalProjectNorStackYamlFound = 39 | "Could not determine adapter: Neither cabal.project nor stack.yaml found" 40 | displayException NoCustomConfigSpecified = 41 | "Could not determine adapter: config file doesn't include neither cabal or stack" 42 | displayException (MalformedConfigYaml _va) = 43 | let kind = case _va of 44 | J.Object {} -> "an object" 45 | J.Array {} -> "an array" 46 | J.String {} -> "a string" 47 | J.Number {} -> "a number" 48 | J.Bool {} -> "a boolean value" 49 | J.Null {} -> "null" 50 | in "Could not determine adapter: malformed configuration; must be object, but got: " <> kind 51 | 52 | detectAdapterThrow :: MonadIO m => Value -> Path b Dir -> m StandardAdapters 53 | detectAdapterThrow val = either throwIO pure <=< detectAdapter val 54 | 55 | detectAdapter :: MonadIO m => Value -> Path b Dir -> m (Either DetectionFailure StandardAdapters) 56 | detectAdapter config dir = runExceptT $ do 57 | ExceptT (detectFromDir dir) `catchE` \case 58 | NeitherCabalProjectNorStackYamlFound -> 59 | ExceptT $ pure $ detectFromDomainConfig config 60 | BothCabalProjectAndStackYamlFound -> 61 | ExceptT $ pure $ detectFromDomainConfig config 62 | exc -> throwE exc 63 | 64 | {- | 65 | Searching for stack.yaml and cabal.project, chooses that one if exactly one of them is found. 66 | -} 67 | detectFromDir :: 68 | MonadIO m => 69 | Path b Dir -> 70 | m (Either DetectionFailure StandardAdapters) 71 | detectFromDir dir0 = runExceptT $ do 72 | dir <- canonicalizePath dir0 73 | stackThere <- doesFileExist (dir [relfile|stack.yaml|]) 74 | cabalThere <- doesFileExist (dir [relfile|cabal.project|]) 75 | if 76 | | stackThere && cabalThere -> throwE BothCabalProjectAndStackYamlFound 77 | | stackThere -> pure Stack 78 | | cabalThere -> pure Cabal 79 | | otherwise -> throwE NeitherCabalProjectNorStackYamlFound 80 | 81 | {- | 82 | Detects backend from dependency-domains.yml. 83 | If exactly one of `cabal' or `stack' section is present, prefer it. 84 | -} 85 | detectFromDomainConfig :: Value -> Either DetectionFailure StandardAdapters 86 | detectFromDomainConfig val = 87 | case val of 88 | J.Object dic -> do 89 | let cabalPresent = AKM.member "cabal" dic 90 | stackPresent = AKM.member "stack" dic 91 | if 92 | | cabalPresent && stackPresent -> Left MultipleAdapterConfigFound 93 | | cabalPresent -> Right Cabal 94 | | stackPresent -> Right Stack 95 | | otherwise -> Left NoCustomConfigSpecified 96 | _ -> Left $ MalformedConfigYaml val 97 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph/Adapter/Cabal/Enabled.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DerivingStrategies #-} 2 | {-# LANGUAGE FlexibleInstances #-} 3 | {-# LANGUAGE LambdaCase #-} 4 | {-# LANGUAGE OverloadedLabels #-} 5 | {-# LANGUAGE PatternSynonyms #-} 6 | {-# LANGUAGE RecordWildCards #-} 7 | {-# LANGUAGE TypeFamilies #-} 8 | 9 | module Development.Guardian.Graph.Adapter.Cabal.Enabled ( 10 | buildPackageGraph, 11 | CustomPackageOptions (..), 12 | Cabal, 13 | ) where 14 | 15 | import qualified Algebra.Graph as G 16 | import Control.Monad (when) 17 | import Data.Either (fromLeft) 18 | import Data.Function ((&)) 19 | import Data.Generics.Labels () 20 | import qualified Data.Map.Strict as Map 21 | import Data.Maybe 22 | import qualified Data.Set as Set 23 | import Data.String (fromString) 24 | import Development.Guardian.Graph.Adapter.Cabal.Types 25 | import Development.Guardian.Graph.Adapter.Types (ComponentsOptions (..), PackageGraphOptions (..)) 26 | import Development.Guardian.Types (Overlayed (..), PackageGraph) 27 | import qualified Development.Guardian.Types as Guard 28 | import Distribution.Client.CmdUpdate (updateAction) 29 | import Distribution.Client.InstallPlan (GenericPlanPackage (..), depends) 30 | import qualified Distribution.Client.InstallPlan as Plan 31 | import Distribution.Client.NixStyleOptions (defaultNixStyleFlags) 32 | import Distribution.Client.ProjectConfig (ProjectRoot (..)) 33 | import Distribution.Client.ProjectOrchestration (CurrentCommand (..), ProjectBaseContext (..), establishProjectBaseContextWithRoot, withInstallPlan) 34 | import Distribution.Client.ProjectPlanning (ElaboratedConfiguredPackage (..), elabLocalToProject) 35 | import Distribution.Package (Package (..), packageName, unPackageName) 36 | import Distribution.Simple.Flag (pattern Flag) 37 | import Distribution.Verbosity (silent) 38 | import Path 39 | import RIO ((.~)) 40 | 41 | buildPackageGraph :: PackageGraphOptions Cabal -> IO PackageGraph 42 | buildPackageGraph PackageGraphOptions {customOptions = CabalOptions {..}, ..} = do 43 | let target = fromAbsDir targetPath 44 | mproj = fromAbsFile . (targetPath ) <$> projectFile 45 | root = maybe (ProjectRootImplicit target) (ProjectRootExplicit target) mproj 46 | when (maybe False (fromLeft True) update) $ do 47 | let targets 48 | | Just (Right idx) <- update = [idx] 49 | | otherwise = [] 50 | updateAction (defaultNixStyleFlags ()) targets mempty 51 | 52 | ctx0 <- establishProjectBaseContextWithRoot silent mempty root OtherCommand 53 | let pjCfg' = 54 | projectConfig ctx0 55 | & #projectConfigLocalPackages . #packageConfigTests 56 | .~ Flag (tests components) 57 | & #projectConfigLocalPackages . #packageConfigBenchmarks 58 | .~ Flag (benchmarks components) 59 | -- ProjectBaseContext has no Generic instance... 60 | ctx = ctx0 {projectConfig = pjCfg'} 61 | 62 | withInstallPlan silent ctx $ \iplan _scfg -> do 63 | let localPkgDic = 64 | Map.mapMaybe 65 | ( \case 66 | Configured pkg 67 | | elabLocalToProject pkg -> pure pkg 68 | Installed pkg 69 | | elabLocalToProject pkg -> pure pkg 70 | _ -> Nothing 71 | ) 72 | $ Plan.toMap iplan 73 | localUnitIds = Map.keysSet localPkgDic 74 | gr = 75 | getOverlayed $ 76 | foldMap 77 | ( \pkg -> 78 | let srcPkg = packageName' pkg 79 | deps = 80 | filter (/= srcPkg) 81 | $ mapMaybe 82 | (fmap packageName' . (`Map.lookup` localPkgDic)) 83 | $ filter (`Set.member` localUnitIds) 84 | $ depends pkg 85 | in foldMap (Overlayed . G.edge srcPkg) deps 86 | ) 87 | localPkgDic 88 | pure gr 89 | 90 | packageName' :: Package pkg => pkg -> Guard.PackageName 91 | packageName' = fromString . unPackageName . packageName 92 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph/Adapter/Stack/Enabled.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE FlexibleInstances #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE RecordWildCards #-} 4 | {-# LANGUAGE TypeFamilies #-} 5 | {-# LANGUAGE ViewPatterns #-} 6 | 7 | module Development.Guardian.Graph.Adapter.Stack.Enabled ( 8 | buildPackageGraphM, 9 | buildPackageGraph, 10 | Stack, 11 | CustomPackageOptions (..), 12 | ) where 13 | 14 | import qualified Algebra.Graph as G 15 | import Control.Applicative ((<**>)) 16 | import Data.Maybe (fromMaybe) 17 | import Data.Set (Set) 18 | import qualified Data.Set as Set 19 | import Data.String (fromString) 20 | import qualified Data.Text as T 21 | import Development.Guardian.Graph.Adapter.Stack.Types 22 | import Development.Guardian.Graph.Adapter.Types 23 | import Development.Guardian.Types (Overlayed (Overlayed, getOverlayed), PackageGraph) 24 | import qualified Development.Guardian.Types as Guard 25 | import Distribution.Simple (unPackageName) 26 | import Options.Applicative (helper) 27 | import qualified Options.Applicative as Opt 28 | import Path (fromAbsDir) 29 | import Path.IO (withCurrentDir) 30 | import Stack.Build.Source (loadLocalPackage) 31 | import Stack.Options.GlobalParser (globalOptsFromMonoid, globalOptsParser) 32 | import Stack.Options.Utils (GlobalOptsContext (OuterGlobalOpts)) 33 | import Stack.Prelude (RIO, toList, view) 34 | import qualified Stack.Prelude as Stack 35 | import Stack.Runners (ShouldReexec (NoReexec), withConfig, withDefaultEnvConfig, withRunnerGlobal) 36 | import Stack.Types.Build (LocalPackage) 37 | import Stack.Types.BuildConfig (HasBuildConfig) 38 | import Stack.Types.EnvConfig (HasSourceMap (sourceMapL)) 39 | import Stack.Types.Package (LocalPackage (..), Package (..)) 40 | import qualified Stack.Types.Package as Stack 41 | import Stack.Types.SourceMap (SourceMap (..)) 42 | 43 | localPackageToPackage :: LocalPackage -> Package 44 | localPackageToPackage lp = 45 | fromMaybe (lpPackage lp) (lpTestBench lp) 46 | 47 | {- | Resolve the direct (depth 0) external dependencies of the given local packages (assumed to come from project packages) 48 | 49 | Stolen from @stack@ and further simplified. 50 | -} 51 | projectPackageDependencies :: 52 | [LocalPackage] -> [(Stack.PackageName, Set Stack.PackageName)] 53 | projectPackageDependencies locals = 54 | map 55 | ( \lp -> 56 | let pkg = localPackageToPackage lp 57 | in (Stack.packageName pkg, deps pkg) 58 | ) 59 | locals 60 | where 61 | deps pkg = 62 | Set.intersection localNames (packageAllDeps pkg) 63 | localNames = Set.fromList $ map (Stack.packageName . lpPackage) locals 64 | 65 | buildPackageGraph :: PackageGraphOptions Stack -> IO PackageGraph 66 | buildPackageGraph PackageGraphOptions {customOptions = StackOptions {..}, ..} = do 67 | withCurrentDir targetPath $ do 68 | let pInfo = 69 | Opt.info 70 | (globalOptsParser (fromAbsDir targetPath) OuterGlobalOpts <**> helper) 71 | mempty 72 | cliOpts = 73 | "--skip-ghc-check" 74 | : concat 75 | [ ["--test", "--no-run-tests"] 76 | | tests components 77 | ] 78 | <> concat 79 | [ ["--bench", "--no-run-benchmarks"] 80 | | benchmarks components 81 | ] 82 | ++ map T.unpack stackOptions 83 | Just gopt <- 84 | mapM (globalOptsFromMonoid False) $ 85 | Opt.getParseResult $ 86 | Opt.execParserPure (Opt.prefs mempty) pInfo cliOpts 87 | 88 | withRunnerGlobal gopt $ 89 | withConfig NoReexec $ 90 | withDefaultEnvConfig buildPackageGraphM 91 | 92 | buildPackageGraphM :: 93 | (HasSourceMap env, HasBuildConfig env) => 94 | RIO env PackageGraph 95 | buildPackageGraphM = do 96 | sourceMap <- view sourceMapL 97 | locals <- mapM loadLocalPackage $ toList $ smProject sourceMap 98 | let gr = projectPackageDependencies locals 99 | pure $ 100 | getOverlayed $ 101 | foldMap 102 | ( \(fromStackPackageName -> pkg, deps) -> 103 | foldMap (Overlayed . G.edge pkg . fromStackPackageName) deps 104 | ) 105 | gr 106 | 107 | fromStackPackageName :: Stack.PackageName -> Guard.PackageName 108 | fromStackPackageName = fromString . unPackageName 109 | -------------------------------------------------------------------------------- /guardian.cabal: -------------------------------------------------------------------------------- 1 | cabal-version: 2.2 2 | 3 | -- This file has been generated from package.yaml by hpack version 0.36.0. 4 | -- 5 | -- see: https://github.com/sol/hpack 6 | 7 | name: guardian 8 | version: 0.5.0.0 9 | synopsis: The border guardian for your package dependencies 10 | description: Guardian secures your Haskell monorepo package dependency boundary. Please read [README.md](https://github.com/deepflowinc-oss/guardian#readme) for more details. 11 | category: Development 12 | homepage: https://github.com/deepflowinc-oss/guardian#readme 13 | bug-reports: https://github.com/deepflowinc-oss/guardian/issues 14 | author: DeepFlow, Inc. 15 | maintainer: DeepFlow, Inc. 16 | copyright: (c) 2021-2023, DeepFlow, Inc. 17 | license: BSD-3-Clause 18 | license-file: LICENSE 19 | build-type: Simple 20 | extra-source-files: 21 | README.md 22 | ChangeLog.md 23 | data-dir: data 24 | 25 | source-repository head 26 | type: git 27 | location: https://github.com/deepflowinc-oss/guardian 28 | 29 | flag cabal 30 | description: Enables Cabal adapter 31 | manual: True 32 | default: True 33 | 34 | flag stack 35 | description: Enables Cabal adapter 36 | manual: True 37 | default: True 38 | 39 | flag test-cabal-plan 40 | description: Enables testcases for custom+cabal-plan 41 | manual: True 42 | default: True 43 | 44 | flag test-graphmod 45 | description: Enables testcases for custom+cabal-plan 46 | manual: True 47 | default: True 48 | 49 | library 50 | exposed-modules: 51 | Development.Guardian.App 52 | Development.Guardian.Constants 53 | Development.Guardian.Flags 54 | Development.Guardian.Graph 55 | Development.Guardian.Graph.Adapter.Cabal 56 | Development.Guardian.Graph.Adapter.Cabal.Types 57 | Development.Guardian.Graph.Adapter.Custom 58 | Development.Guardian.Graph.Adapter.Detection 59 | Development.Guardian.Graph.Adapter.Stack 60 | Development.Guardian.Graph.Adapter.Stack.Types 61 | Development.Guardian.Graph.Adapter.Types 62 | Development.Guardian.Types 63 | Text.Pattern 64 | other-modules: 65 | Paths_guardian 66 | autogen-modules: 67 | Paths_guardian 68 | hs-source-dirs: 69 | src 70 | ghc-options: -Wall -Wunused-packages 71 | build-depends: 72 | aeson 73 | , algebraic-graphs 74 | , base >=4.7 && <5 75 | , bytestring-trie 76 | , case-insensitive 77 | , containers 78 | , dlist 79 | , generic-lens 80 | , githash 81 | , hashable 82 | , indexed-traversable 83 | , indexed-traversable-instances 84 | , language-dot 85 | , optparse-applicative 86 | , parsec 87 | , path 88 | , path-io 89 | , regex-applicative-text 90 | , rio 91 | , semigroups 92 | , template-haskell 93 | , text 94 | , transformers 95 | , typed-process 96 | , unordered-containers 97 | , validation-selective 98 | , vector 99 | , yaml 100 | default-language: Haskell2010 101 | if flag(cabal) 102 | other-modules: 103 | Development.Guardian.Graph.Adapter.Cabal.Enabled 104 | cpp-options: -DENABLE_CABAL 105 | build-depends: 106 | Cabal 107 | , Cabal-syntax 108 | , cabal-install 109 | else 110 | other-modules: 111 | Development.Guardian.Graph.Adapter.Cabal.Disabled 112 | if flag(stack) 113 | other-modules: 114 | Development.Guardian.Graph.Adapter.Stack.Enabled 115 | cpp-options: -DENABLE_STACK 116 | build-depends: 117 | Cabal 118 | , stack >=2.9 119 | else 120 | other-modules: 121 | Development.Guardian.Graph.Adapter.Stack.Disabled 122 | 123 | executable guardian 124 | main-is: Main.hs 125 | other-modules: 126 | Paths_guardian 127 | autogen-modules: 128 | Paths_guardian 129 | hs-source-dirs: 130 | app 131 | ghc-options: -Wall -Wunused-packages 132 | build-depends: 133 | base >=4.7 && <5 134 | , guardian 135 | default-language: Haskell2010 136 | 137 | test-suite guardian-test 138 | type: exitcode-stdio-1.0 139 | main-is: Spec.hs 140 | other-modules: 141 | Development.Guardian.AppSpec 142 | Development.Guardian.Graph.Adapter.CabalSpec 143 | Development.Guardian.Graph.Adapter.StackSpec 144 | Development.Guardian.Graph.Adapter.TestUtils 145 | Development.Guardian.GraphSpec 146 | Development.Guardian.Test.Flags 147 | Paths_guardian 148 | autogen-modules: 149 | Paths_guardian 150 | hs-source-dirs: 151 | test 152 | ghc-options: -Wall -Wunused-packages 153 | build-tool-depends: 154 | tasty-discover:tasty-discover 155 | build-depends: 156 | algebraic-graphs 157 | , base >=4.7 && <5 158 | , bytestring 159 | , containers 160 | , guardian 161 | , path 162 | , path-io 163 | , rio 164 | , tasty 165 | , tasty-expected-failure 166 | , tasty-hunit 167 | , text 168 | , unordered-containers 169 | , validation-selective 170 | default-language: Haskell2010 171 | if flag(test-cabal-plan) 172 | cpp-options: -DTEST_CABAL_PLAN 173 | if flag(test-graphmod) 174 | cpp-options: -DTEST_GRAPHMOD 175 | -------------------------------------------------------------------------------- /test/Development/Guardian/GraphSpec.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedLists #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# OPTIONS_GHC -Wno-type-defaults #-} 4 | 5 | module Development.Guardian.GraphSpec where 6 | 7 | import qualified Algebra.Graph as G 8 | import qualified Algebra.Graph.ToGraph as AM 9 | import qualified Data.HashMap.Strict as HM 10 | import Data.List.NonEmpty (NonEmpty ((:|))) 11 | import qualified Data.List.NonEmpty as NE 12 | import qualified Data.Map.Strict as Map 13 | import Development.Guardian.Graph 14 | import Development.Guardian.Types 15 | import qualified GHC.OldList as OL 16 | import Test.Tasty 17 | import Test.Tasty.HUnit 18 | import Validation 19 | 20 | {- | 21 | @ 22 | A1--+ +--B1 23 | | | | 24 | | v v 25 | | A2 --> B2 26 | v | 27 | A3 +-->B3 28 | | | 29 | | | 30 | +---> C1 <----+ 31 | @ 32 | -} 33 | packages1 :: PackageGraph 34 | packages1 = G.edges [(a1, a3), (a1, a2), (a2, b2), (b1, b2), (b2, b3), (a3, c1), (b3, c1)] 35 | where 36 | (a1, a2, a3) = ("A1", "A2", "A3") 37 | (b1, b2, b3) = ("B1", "B2", "B3") 38 | c1 = "C1" 39 | 40 | {- | 41 | @ A B 42 | +-------+ +-------+ 43 | | A1 |<-+----B1 | 44 | | A2-+--+->B2 | 45 | | A3 | | B3 | 46 | +---+---+ +---+---+ 47 | | | 48 | v C v 49 | +---+----------+----+ 50 | | C1 | 51 | | | 52 | +-------------------+ 53 | @ 54 | -} 55 | domains1 :: Domains 56 | domains1 = 57 | Domains $ 58 | HM.fromList 59 | [ ("A", Domain {dependsOn = Just ["C"], packages = [a1, a2, a3]}) 60 | , ("B", Domain {dependsOn = Just ["C"], packages = [b1, b2, b3]}) 61 | , ("C", Domain {dependsOn = Nothing, packages = [c1]}) 62 | ] 63 | where 64 | a1 = PackageDef {packageName = "A1", extraDeps = []} 65 | a2 = PackageDef {packageName = "A2", extraDeps = [PackageDep "B2"]} 66 | a3 = PackageDef {packageName = "A3", extraDeps = []} 67 | b1 = PackageDef {packageName = "B1", extraDeps = []} 68 | b2 = PackageDef {packageName = "B2", extraDeps = [DomainDep "A"]} 69 | b3 = PackageDef {packageName = "B3", extraDeps = []} 70 | c1 = PackageDef {packageName = "C1", extraDeps = []} 71 | 72 | test_buildDomainInfo :: TestTree 73 | test_buildDomainInfo = 74 | testGroup 75 | "buildDomainInfo" 76 | [ testCase "domains1 is valid" $ do 77 | let vinfo = buildDomainInfo domains1 78 | isSuccess vinfo @? "Domains #1 should be valid, but got: " <> show vinfo 79 | ] 80 | 81 | test_detectPackageCycle :: TestTree 82 | test_detectPackageCycle = 83 | testGroup 84 | "detectPackageCycle" 85 | [ testCase "validates packages1" $ 86 | detectPackageCycle packages1 @?= Success () 87 | , testCase "invalidates (packages1 + A2 -> A1)" $ 88 | detectPackageCycle (packages1 `G.overlay` G.edge "A2" "A1") 89 | @?= Failure (CyclicPackageDep ("A2" :| ["A1"])) 90 | ] 91 | 92 | test_validatePackageGraph :: TestTree 93 | test_validatePackageGraph = 94 | testGroup 95 | "validatePackageGraph" 96 | [ testCase "packages1 is valid, with B2 -> (A) redundant" $ do 97 | doms <- fromDomainInfo $ buildDomainInfo domains1 98 | case validatePackageGraph doms packages1 of 99 | f@Failure {} -> assertFailure $ "Must success, but got: " <> show f 100 | Success (OkWithDiagnostics Diagnostics {usedExceptionalRules = useds, redundantExtraDeps = reds}) 101 | | [("B2", [DomainDep "A"])] <- Map.toList reds 102 | , [("A2", [PackageDep "B2"])] <- useds -> 103 | pure () 104 | s@Success {} -> assertFailure $ "B2 -> (A) must be reported redundant, but got: " <> show s 105 | , testCase "(packages1 + A2 -> B1) is invalid" $ do 106 | doms <- fromDomainInfo $ buildDomainInfo domains1 107 | let pg = packages1 `G.overlay` G.edge "A2" "B1" 108 | case validatePackageGraph doms pg of 109 | Failure 110 | ( DomainBoundaryViolation 111 | { fromDom = "A" 112 | , toDom = "B" 113 | , introducedBy = [("A2", "B1")] 114 | } 115 | :| [] 116 | ) -> pure () 117 | f -> 118 | assertFailure $ 119 | "Must fail reporting A2 -> B1 as invalid, but got: " 120 | <> show f 121 | , testCase ("detects package cycle in " <> show (AM.toAdjacencyMap $ packages1 `G.overlay` G.edge "A2" "A1")) $ do 122 | doms <- fromDomainInfo $ buildDomainInfo domains1 123 | let pg = packages1 `G.overlay` G.edge "A2" "A1" 124 | case validatePackageGraph doms pg of 125 | Failure (CyclicPackageDep cyc :| []) 126 | | cyc `OL.elem` ["A2" :| ["A1"], "A1" :| ["A2"]] -> pure () 127 | f -> 128 | assertFailure $ 129 | "Must fail reporting A2 -> A1 -> A2 as valid, but got: " 130 | <> show f 131 | ] 132 | 133 | fromDomainInfo :: Validation (NonEmpty DomainGraphError) DomainInfo -> IO DomainInfo 134 | fromDomainInfo = 135 | validation 136 | (assertFailure . ("Invalid Domain: " <>) . show . NE.toList) 137 | pure 138 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph/Adapter/Custom.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveAnyClass #-} 2 | {-# LANGUAGE DeriveGeneric #-} 3 | {-# LANGUAGE DerivingStrategies #-} 4 | {-# LANGUAGE FlexibleInstances #-} 5 | {-# LANGUAGE LambdaCase #-} 6 | {-# LANGUAGE OverloadedStrings #-} 7 | {-# LANGUAGE RecordWildCards #-} 8 | {-# LANGUAGE TypeFamilies #-} 9 | 10 | module Development.Guardian.Graph.Adapter.Custom ( 11 | Custom, 12 | buildPackageGraph, 13 | CustomPackageOptions (..), 14 | CustomAdapterException (..), 15 | fromDotGraph, 16 | ) where 17 | 18 | import qualified Algebra.Graph as G 19 | import Control.Applicative (liftA2, (<|>)) 20 | import Control.Exception (Exception, throwIO) 21 | import Control.Monad (when) 22 | import Control.Monad.IO.Class (MonadIO, liftIO) 23 | import Data.Aeson (FromJSON) 24 | import qualified Data.Aeson as J 25 | import qualified Data.Bifunctor as Bi 26 | import qualified Data.CaseInsensitive as CI 27 | import qualified Data.DList.DNonEmpty as DLNE 28 | import Data.Function (on, (&)) 29 | import Data.Functor ((<&>)) 30 | import Data.List.NonEmpty (NonEmpty) 31 | import qualified Data.List.NonEmpty as NE 32 | import qualified Data.Map as Map 33 | import Data.Maybe (fromMaybe) 34 | import Data.Monoid (Ap (..)) 35 | import Data.String (IsString (..)) 36 | import Data.Text (Text) 37 | import qualified Data.Text as T 38 | import qualified Data.Text.Lazy as LT 39 | import qualified Data.Text.Lazy.Encoding as LT 40 | import Development.Guardian.Graph.Adapter.Types 41 | import Development.Guardian.Types 42 | import GHC.Generics (Generic) 43 | import Language.Dot (parseDot) 44 | import qualified Language.Dot as Dot 45 | import Path (File, SomeBase, fromAbsDir, fromAbsFile) 46 | import Path.IO (makeAbsolute) 47 | import RIO (tshow) 48 | import System.Environment (getEnvironment) 49 | import System.Process.Typed (ProcessConfig, proc, readProcessStdout_, setEnv, shell) 50 | import qualified Text.Parsec as Parsec 51 | import Validation 52 | 53 | data Custom 54 | 55 | newtype instance CustomPackageOptions Custom = CustomOptions {custom :: CustomOpts} 56 | deriving (Show, Eq, Ord, Generic) 57 | deriving anyclass (J.FromJSON) 58 | 59 | data CustomOpts = CustomOpts 60 | { adapter :: ExternalProcess 61 | , ignoreLoop :: Bool 62 | } 63 | deriving (Show, Eq, Ord, Generic) 64 | 65 | instance FromJSON CustomOpts where 66 | parseJSON = J.withObject "dict" $ \obj -> do 67 | adapter <- 68 | Shell <$> (obj J..: "shell") 69 | <|> Program <$> (obj J..: "program") 70 | ignoreLoop <- obj J..:? "ignore_loop" J..!= False 71 | pure CustomOpts {..} 72 | 73 | data ExternalProcess = Shell Text | Program (SomeBase File) 74 | deriving (Show, Eq, Ord, Generic) 75 | 76 | createExternalAdapter :: 77 | MonadIO m => 78 | PackageGraphOptions Custom -> 79 | m (ProcessConfig () () ()) 80 | createExternalAdapter PackageGraphOptions {..} = do 81 | env0 <- liftIO getEnvironment 82 | cfg0 <- case adapter $ custom customOptions of 83 | Shell txt -> 84 | pure $ shell $ fromString $ T.unpack txt 85 | Program prog -> do 86 | progAbs <- makeAbsolute prog 87 | pure $ proc (fromAbsFile progAbs) [fromAbsDir targetPath] 88 | let componentEnvs = 89 | [(includeTestVar, "1") | tests components] 90 | ++ [(includeBenchVar, "1") | benchmarks components] 91 | env' = 92 | ("GUARDIAN_ROOT_DIR", fromAbsDir targetPath) 93 | : componentEnvs 94 | ++ filter ((`notElem` [includeTestVar, includeBenchVar]) . fst) env0 95 | pure $ cfg0 & setEnv env' 96 | 97 | includeBenchVar :: String 98 | includeBenchVar = "GUARDIAN_INCLUDE_BENCHMARKS" 99 | 100 | includeTestVar :: String 101 | includeTestVar = "GUARDIAN_INCLUDE_TESTS" 102 | 103 | data CustomAdapterException 104 | = InvalidAdapterOutput String Parsec.ParseError 105 | | InvalidGraph String Dot.Graph (NonEmpty GraphViolation) 106 | deriving (Eq, Generic) 107 | deriving anyclass (Exception) 108 | 109 | instance Show CustomAdapterException where 110 | showsPrec d exc = showParen (d > 10) $ 111 | case exc of 112 | InvalidAdapterOutput src pe -> 113 | showString "Parse error in adapter output:\n" 114 | . showString "Error: \n" 115 | . showString (unlines $ map ('\t' :) $ lines $ show pe) 116 | . showString "Input: \n" 117 | . showString (unlines $ map ('\t' :) $ lines src) 118 | InvalidGraph src gr ne -> 119 | showString "Invalid Dot Graph was passed: " 120 | . shows (NE.toList ne) 121 | . showString "\nParsedGraph:\n\t" 122 | . shows gr 123 | . showString "\nInput:\n" 124 | . showString (unlines $ map ('\t' :) $ lines src) 125 | 126 | data GraphViolation 127 | = DirectedGraphExpected 128 | | EdgeToSubgraphNotSupported (Maybe Dot.Id) 129 | | MultiEdgeNotSupported [String] 130 | deriving (Show, Eq, Ord, Generic) 131 | deriving anyclass (Exception) 132 | 133 | buildPackageGraph :: PackageGraphOptions Custom -> IO PackageGraph 134 | buildPackageGraph opts = do 135 | pc <- createExternalAdapter opts 136 | src <- LT.unpack . LT.decodeUtf8 <$> readProcessStdout_ pc 137 | gr <- either (throwIO . InvalidAdapterOutput src) pure $ parseDot "" src 138 | let pkgGr = fromDotGraph (custom $ customOptions opts) gr 139 | either (throwIO . InvalidGraph src gr) pure pkgGr 140 | 141 | data DotEntity = DotId Text | Pkg PackageName 142 | deriving (Show, Eq, Ord, Generic) 143 | 144 | fromDotGraph :: CustomOpts -> Dot.Graph -> Either (NonEmpty GraphViolation) PackageGraph 145 | fromDotGraph CustomOpts {..} (Dot.Graph _ dir _ stmts) = 146 | Bi.first DLNE.toNonEmpty $ 147 | validationToEither $ 148 | when 149 | (dir /= Dot.DirectedGraph) 150 | (failed DirectedGraphExpected) 151 | *> getAp (foldMap (Ap . (liftA2 (,) <$> go <*> (pure . buildDic))) stmts) 152 | <&> \(gr0, dic) -> 153 | fmap 154 | ( \case 155 | DotId txt -> fromMaybe (PackageName txt) $ Map.lookup txt dic 156 | Pkg pn -> pn 157 | ) 158 | gr0 159 | where 160 | buildDic (Dot.NodeStatement ni atts) = 161 | Map.singleton (prettyNodeId ni) $ parseNode ni atts 162 | buildDic _ = mempty 163 | go (Dot.NodeStatement ni atts) = 164 | pure $ 165 | G.vertex $ 166 | Pkg $ 167 | parseNode ni atts 168 | go (Dot.EdgeStatement [l, r] _) 169 | | ignoreLoop, parseEntity l == parseEntity r = mempty 170 | | otherwise = 171 | (G.connect `on` G.vertex) <$> parseEntity l <*> parseEntity r 172 | go (Dot.EdgeStatement ents _) = 173 | failed $ MultiEdgeNotSupported $ map (show . Dot.pp) ents 174 | go Dot.AttributeStatement {} = pure mempty 175 | go Dot.AssignmentStatement {} = pure mempty 176 | go Dot.SubgraphStatement {} = mempty 177 | 178 | prettyNodeId :: Dot.NodeId -> Text 179 | prettyNodeId (Dot.NodeId ni _) = prettyId ni 180 | 181 | parseNode :: Dot.NodeId -> [Dot.Attribute] -> PackageName 182 | parseNode (Dot.NodeId origId _) attrs = 183 | PackageName $ 184 | prettyId $ 185 | fromMaybe origId $ 186 | lookup 187 | (CI.mk "label") 188 | [ (CI.mk $ prettyId l, v) 189 | | Dot.AttributeSetValue l v <- attrs 190 | ] 191 | 192 | prettyId :: Dot.Id -> Text 193 | prettyId (Dot.NameId s) = T.pack s 194 | prettyId (Dot.StringId s) = T.pack s 195 | prettyId (Dot.IntegerId n) = tshow n 196 | prettyId (Dot.FloatId x) = tshow x 197 | prettyId (Dot.XmlId xml) = tshow $ Dot.pp xml 198 | 199 | failed :: e -> Validation (DLNE.DNonEmpty e) b 200 | failed = Failure . DLNE.singleton 201 | 202 | parseEntity :: Dot.Entity -> Validation (DLNE.DNonEmpty GraphViolation) DotEntity 203 | parseEntity (Dot.ENodeId _ (Dot.NodeId ni _)) = pure $ DotId $ prettyId ni 204 | parseEntity (Dot.ESubgraph _ sub) = failed $ EdgeToSubgraphNotSupported $ subGraphId sub 205 | 206 | subGraphId :: Dot.Subgraph -> Maybe Dot.Id 207 | subGraphId (Dot.NewSubgraph mid _) = mid 208 | subGraphId (Dot.SubgraphRef ident) = Just ident 209 | -------------------------------------------------------------------------------- /.github/workflows/haskell.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | pull_request: 5 | branches: ['**'] 6 | push: 7 | branches: [main] 8 | tags: 9 | - "v*" 10 | workflow_dispatch: 11 | inputs: 12 | macOS: 13 | description: "Builds macOS binaries" 14 | type: boolean 15 | required: true 16 | default: false 17 | release_tag: 18 | description: "Release Tag" 19 | type: string 20 | required: false 21 | default: "" 22 | 23 | jobs: 24 | fourmolu: 25 | name: Fourmolu 26 | runs-on: ubuntu-latest 27 | steps: 28 | # Note that you must checkout your code before running fourmolu/fourmolu-action 29 | - uses: actions/checkout@v2 30 | - uses: fourmolu/fourmolu-action@v6 31 | with: 32 | pattern: | 33 | src/**/*.hs 34 | src/**/*.hs-boot 35 | src/**/*.lhs 36 | app/**/*.hs 37 | app/**/*.hs-boot 38 | app/**/*.lhs 39 | test/**/*.hs 40 | test/**/*.hs-boot 41 | test/**/*.lhs 42 | 43 | cabal-check: 44 | name: Cabal Check 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v3 48 | - name: Workaround runner image issue 49 | if: ${{ matrix.os == 'Linux' }} 50 | # https://github.com/actions/runner-images/issues/7061 51 | run: sudo chown -R "${USER}" /usr/local/.ghcup 52 | - uses: haskell-actions/setup@v2 53 | with: 54 | cabal-version: 3.10.2.1 55 | ghc-version: 9.6.4 56 | - run: cabal check 57 | 58 | build: 59 | name: Build 60 | strategy: 61 | matrix: 62 | os: [macOS] 63 | ghc: ['9.6.4'] 64 | image: [''] 65 | include: 66 | - {os: 'ubuntu', ghc: '9.6.4', image: 'fpco/alpine-haskell-stack:9.2.5'} 67 | fail-fast: false 68 | env: 69 | bin-artifacts: "bin-artifacts" 70 | cabal: cabal --project-file=cabal-${{matrix.os}}.project 71 | runs-on: ${{matrix.os}}-latest 72 | container: 73 | image: ${{ matrix.image }} 74 | steps: 75 | - name: Checkout 76 | uses: actions/checkout@v3 77 | - name: Setup environment (Linux) 78 | if: ${{ runner.os == 'Linux' }} 79 | run: | 80 | apk add sudo tar zstd xz 81 | echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" 82 | echo "${HOME}/.cabal/bin" >> "${GITHUB_PATH}" 83 | echo "${HOME}/.ghcup/bin" >> "${GITHUB_PATH}" 84 | - name: Setup environment (macOS) 85 | if: ${{ matrix.os == 'macOS' }} 86 | run: | 87 | brew install pkg-config 88 | - name: Setup Haskell 89 | uses: haskell-actions/setup@v2 90 | with: 91 | ghc-version: ${{ matrix.ghc }} 92 | cabal-version: 3.10.2.1 93 | enable-stack: false 94 | - name: Cache Global Cabal 95 | uses: actions/cache@v3 96 | with: 97 | path: '~/.cabal/store' 98 | key: | 99 | cabal-global-${{ matrix.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze') }}-${{ hashFiles('**/*.cabal')}} 100 | restore-keys: | 101 | cabal-global-${{ matrix.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze') }}- 102 | cabal-global-${{ matrix.os }}-${{ matrix.ghc }}- 103 | - name: Cache dist-newstyle 104 | uses: actions/cache@v3 105 | with: 106 | path: 'dist-newstyle' 107 | key: | 108 | cabal-dist-${{ matrix.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze') }}-${{ hashFiles('*.cabal') }}-${{ hashFiles('**/*.hs') }} 109 | restore-keys: | 110 | cabal-dist-${{ matrix.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze', 'cabal-${{matrix.os}}.project') }}-${{ hashFiles('*.cabal') }}- 111 | cabal-dist-${{ matrix.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze', 'cabal-${{matrix.os}}.project') }}- 112 | cabal-dist-${{ matrix.os }}-${{ matrix.ghc }}- 113 | - name: "cabal v2-update" 114 | shell: bash 115 | run: 116 | ${{ env.cabal }} v2-update 'hackage.haskell.org,2023-02-13T02:00:06Z' 117 | - name: "Build" 118 | shell: bash 119 | run: > 120 | ${{ env.cabal }} 121 | v2-build --enable-tests 122 | - name: 'Collect binaries' 123 | shell: bash 124 | run: | 125 | set -euxo pipefail 126 | 127 | mkdir -p "${{env.bin-artifacts}}" 128 | cabal list-bin exe:guardian | while read -r FILE; do 129 | echo "COPYING: $(basename "${FILE}") (${FILE})" 130 | cp "${FILE}" "${{env.bin-artifacts}}/" 131 | done 132 | 133 | cabal list-bin guardian-test | while read -r FILE; do 134 | echo "COPYING: $(basename "${FILE}") (${FILE})" 135 | cp "${FILE}" "${{env.bin-artifacts}}/" 136 | done 137 | - uses: actions/upload-artifact@v4 138 | name: Upload Binary Artifacts 139 | with: 140 | name: bins-${{ runner.os }} 141 | path: ${{ env.bin-artifacts }} 142 | 143 | test: 144 | name: Test 145 | needs: [build] 146 | strategy: 147 | matrix: 148 | os: [macOS] 149 | ghc: ['9.6.4'] 150 | image: [''] 151 | include: 152 | - os: 'ubuntu' 153 | ghc: '9.6.4' 154 | image: 'fpco/alpine-haskell-stack:9.2.5' 155 | fail-fast: false 156 | runs-on: ${{ matrix.os }}-latest 157 | steps: 158 | - uses: actions/checkout@v3 159 | - uses: actions/download-artifact@v4 160 | id: download 161 | with: 162 | name: bins-${{ runner.os }} 163 | - name: Setup permission 164 | run: 'chmod +x "${{steps.download.outputs.download-path}}/guardian-test"' 165 | - name: Workaround runner image issue 166 | if: ${{ matrix.os == 'Linux' }} 167 | # https://github.com/actions/runner-images/issues/7061 168 | run: sudo chown -R "${USER}" /usr/local/.ghcup 169 | - name: Installs pkg-config (macOS) 170 | if: ${{ matrix.os == 'macOS' }} 171 | run: brew install pkg-config 172 | - uses: haskell-actions/setup@v2 173 | with: 174 | ghc-version: 9.6.4 175 | cabal-version: 3.10.2.1 176 | stack-version: 2.13.1 177 | enable-stack: true 178 | - run: cabal v2-update 'hackage.haskell.org,2023-02-13T02:00:06Z' 179 | - name: Installs test tool dependencies 180 | run: | 181 | cabal install graphmod 182 | mkdir -p ~/.local/bin 183 | echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" 184 | echo "${HOME}/.ghcup/bin" >> "${GITHUB_PATH}" 185 | - name: Intall cabal-plan (Linux) 186 | if: ${{ matrix.os == 'ubuntu' }} 187 | run: | 188 | curl --location 'https://github.com/haskell-hvr/cabal-plan/releases/download/v0.6.2.0/cabal-plan-0.6.2.0-x86_64-linux.xz' | unxz > ~/.local/bin/cabal-plan 189 | chmod +x ~/.local/bin/cabal-plan 190 | - name: Run tests 191 | run: | 192 | ${{steps.download.outputs.download-path}}/guardian-test 193 | 194 | release: 195 | name: Release 196 | env: 197 | ghc: 9.6.4 198 | tag: "${{ github.event.inputs.release_tag }}" 199 | runs-on: ubuntu-latest 200 | needs: [build] 201 | if: > 202 | github.event_name == 'push' 203 | && 204 | startsWith(github.ref, 'refs/tags/v') 205 | || 206 | github.event_name == 'workflow_dispatch' 207 | && 208 | github.event.inputs.release_tag != '' 209 | permissions: 210 | contents: write 211 | steps: 212 | - name: Setup envvars 213 | run: | 214 | if [ -n "${{env.tag}}" ]; then 215 | echo 'RELEASE=${{env.tag}}' >> "${GITHUB_ENV}" 216 | else 217 | RELEASE=$(echo ${{github.ref_name}} | sed 's/^v//') 218 | echo "RELEASE=${RELEASE}" >> "${GITHUB_ENV}" 219 | fi 220 | - name: Checkout 221 | uses: actions/checkout@v3 222 | - name: Download Artifact(s) 223 | id: download 224 | uses: actions/download-artifact@v4 225 | with: 226 | path: ${{ github.workspace }}/artifacts 227 | - name: Check Version 228 | run: | 229 | ./scripts/check-version.sh "${RELEASE}" "${{ steps.download.outputs.download-path }}" 230 | - name: Make Release on GitHub 231 | env: 232 | GH_TOKEN: ${{ github.token }} 233 | run: | 234 | ./scripts/make-release.sh "${RELEASE}" "${{ steps.download.outputs.download-path }}" 235 | - name: Creates Tarball 236 | run: cabal sdist 237 | - name: Upload to Hackage 238 | if: > 239 | github.event_name == 'push' 240 | && 241 | startsWith(github.ref, 'refs/tags/v') 242 | uses: haskell-actions/hackage-publish@v1 243 | with: 244 | hackageToken: ${{ secrets.HACKAGE_ACCESS_TOKEN }} 245 | publish: false 246 | packagesPath: dist-newstyle/sdist 247 | 248 | -------------------------------------------------------------------------------- /src/Development/Guardian/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE BlockArguments #-} 2 | {-# LANGUAGE DeriveAnyClass #-} 3 | {-# LANGUAGE DeriveGeneric #-} 4 | {-# LANGUAGE DeriveTraversable #-} 5 | {-# LANGUAGE DerivingStrategies #-} 6 | {-# LANGUAGE GeneralizedNewtypeDeriving #-} 7 | {-# LANGUAGE OverloadedLabels #-} 8 | {-# LANGUAGE OverloadedStrings #-} 9 | {-# LANGUAGE RecordWildCards #-} 10 | {-# LANGUAGE ScopedTypeVariables #-} 11 | {-# LANGUAGE TupleSections #-} 12 | {-# LANGUAGE TypeApplications #-} 13 | 14 | module Development.Guardian.Types where 15 | 16 | import Algebra.Graph (Graph) 17 | import qualified Algebra.Graph as G 18 | import Algebra.Graph.AdjacencyMap.Algorithm (Cycle) 19 | import qualified Algebra.Graph.Class as GC 20 | import Algebra.Graph.Label 21 | import qualified Algebra.Graph.Labelled as L 22 | import Algebra.Graph.Relation.Preorder (PreorderRelation) 23 | import Control.Applicative 24 | import Control.Exception (Exception) 25 | import Control.Monad 26 | import Control.Monad.Trans.Reader (ReaderT (ReaderT, runReaderT)) 27 | import Control.Monad.Trans.Writer.CPS (runWriter, tell) 28 | import Data.Aeson (FromJSON (..), FromJSONKey, genericParseJSON, withObject, withText, (.:), (.:?)) 29 | import qualified Data.Aeson as J 30 | import qualified Data.Bifunctor as Bi 31 | import Data.Coerce (coerce) 32 | import qualified Data.DList.DNonEmpty as DLNE 33 | import Data.Foldable (fold) 34 | import qualified Data.Foldable as F 35 | import Data.Functor.Compose (Compose (..)) 36 | import Data.Generics.Labels () 37 | import qualified Data.HashMap.Strict as HM 38 | import Data.Hashable (Hashable) 39 | import Data.List.NonEmpty (NonEmpty) 40 | import Data.Map (Map) 41 | import qualified Data.Map.Strict as Map 42 | import Data.Maybe (isJust, mapMaybe) 43 | import Data.Monoid (Last (..)) 44 | import Data.Set (Set) 45 | import qualified Data.Set as Set 46 | import Data.String (IsString) 47 | import Data.Text (Text) 48 | import qualified Data.Text.Encoding as T 49 | import qualified Data.Trie as Trie 50 | import qualified Data.Vector as V 51 | import GHC.Generics (Generic) 52 | import RIO ((^.)) 53 | import Text.Pattern (Pattern, concretePrefix) 54 | import qualified Text.Pattern as P 55 | 56 | type DomainGraph = PreorderRelation DomainName 57 | 58 | type PackageGraph = Graph PackageName 59 | 60 | type ActualGraph' e = L.Graph e DomainName 61 | 62 | type ActualGraph = ActualGraph' (Path PackageName) 63 | 64 | type PackageDic = Map PackageName (DomainName, V.Vector Dependency) 65 | 66 | data DomainGraphError 67 | = CyclicDomainDep (Cycle DomainName) 68 | | OverlappingPackages (Map PackageName (Set DomainName)) 69 | deriving (Show, Eq, Ord, Generic) 70 | deriving anyclass (Exception) 71 | 72 | data CheckResult 73 | = Ok 74 | | OkWithDiagnostics Diagnostics 75 | deriving (Show, Eq, Ord, Generic) 76 | 77 | isEmptyDiagnostics :: Diagnostics -> Bool 78 | isEmptyDiagnostics Diagnostics {..} = 79 | Map.null redundantExtraDeps 80 | -- && Set.null redundantDomainDeps 81 | && Map.null usedExceptionalRules 82 | 83 | data Diagnostics = Diagnostics 84 | { redundantExtraDeps :: Map PackageName (Set Dependency) 85 | , -- , redundantDomainDeps :: Set (DomainName, DomainName) 86 | usedExceptionalRules :: Map PackageName (Set Dependency) 87 | } 88 | deriving (Show, Eq, Ord, Generic) 89 | 90 | data DomainInfo = DomainInfo 91 | { domainConfig :: Domains 92 | , domainGraph :: DomainGraph 93 | , packageDic :: PackageDic 94 | } 95 | deriving (Show, Eq, Ord, Generic) 96 | 97 | data PackageViolation 98 | = DomainBoundaryViolation 99 | { fromDom :: DomainName 100 | , toDom :: DomainName 101 | , introducedBy :: [(PackageName, PackageName)] 102 | } 103 | | CyclicPackageDep (Cycle PackageName) 104 | | OrphanPackage PackageName [PackageName] 105 | | UncoveredPackages [PackageName] 106 | deriving (Show, Eq, Ord, Generic) 107 | deriving anyclass (Exception) 108 | 109 | newtype PackageName = PackageName {getPackageName :: Text} 110 | deriving (Eq, Ord, Generic) 111 | deriving newtype (Show, IsString, FromJSON, Hashable) 112 | 113 | newtype DomainName = DomainName {getDomainName :: Text} 114 | deriving (Eq, Ord, Generic) 115 | deriving newtype (Show, IsString, FromJSON, FromJSONKey, Hashable) 116 | 117 | data Domain' a = Domain 118 | { dependsOn :: Maybe (V.Vector DomainName) 119 | , packages :: V.Vector (PackageDef' a) 120 | } 121 | deriving (Show, Eq, Ord, Generic, Functor, Foldable, Traversable) 122 | 123 | type Domain = Domain' PackageName 124 | 125 | instance FromJSON a => FromJSON (Domain' a) where 126 | parseJSON = 127 | genericParseJSON 128 | J.defaultOptions 129 | { J.omitNothingFields = True 130 | , J.fieldLabelModifier = J.camelTo2 '_' 131 | } 132 | 133 | type PackageDef = PackageDef' PackageName 134 | 135 | newtype Domains = Domains 136 | { getDomains :: HM.HashMap DomainName Domain 137 | } 138 | deriving (Show, Eq, Ord, Generic) 139 | 140 | data DomainsConfig 141 | = -- | Domain definition without wildcards 142 | ConcreteDomains Domains 143 | | -- | Domain definition containing wildcards 144 | WildcardDomains (HM.HashMap DomainName (Domain' Pattern)) 145 | deriving (Show, Eq, Ord, Generic) 146 | 147 | instance FromJSON DomainsConfig where 148 | parseJSON = withObject "{domains: ... [, wildcards: true]}" $ \obj -> do 149 | wildcards <- obj .:? "wildcards" J..!= False 150 | if wildcards 151 | then WildcardDomains <$> obj .: "domains" 152 | else ConcreteDomains . Domains <$> obj .: "domains" 153 | 154 | data DomainResult = DomainResult {domains :: Domains, warnings :: Maybe (NonEmpty String)} 155 | deriving (Show, Eq, Ord, Generic) 156 | 157 | {- | 158 | Resolves patterns in packages in the domains. 159 | If the multiple patterns matched, it picks one with the longest prefix. 160 | -} 161 | resolveDomainName :: DomainsConfig -> PackageGraph -> DomainResult 162 | resolveDomainName (ConcreteDomains doms) _ = DomainResult doms Nothing 163 | resolveDomainName (WildcardDomains patDoms) pkgGraph = 164 | let pkgs = G.vertexList pkgGraph 165 | pats = 166 | Trie.fromList $ 167 | Map.toList $ 168 | Map.fromListWith (<>) $ 169 | map ((,) <$> T.encodeUtf8 . concretePrefix <*> Set.singleton) $ 170 | F.toList $ 171 | Compose patDoms 172 | pkgAssigns = 173 | fmap (V.fromList . Set.toList) $ 174 | HM.fromList $ 175 | Map.toList $ 176 | Map.fromListWith (<>) $ 177 | mapMaybe 178 | ( \pn@(PackageName nm) -> 179 | fmap (,Set.singleton pn) 180 | $ getLast 181 | $ foldMap 182 | (\(_, ps, _) -> Last $ F.find (isJust . (`P.match` nm)) ps) 183 | $ Trie.matches pats (T.encodeUtf8 nm) 184 | ) 185 | pkgs 186 | (doms, warns) = Bi.second (fmap DLNE.toNonEmpty) $ 187 | runWriter $ 188 | forM patDoms $ \dom -> do 189 | pkgs' <- fmap fold $ V.forM (packages dom) $ \pkgDef -> do 190 | let aPat = pkgDef ^. #packageName 191 | cands = HM.lookupDefault mempty aPat pkgAssigns 192 | when (V.null cands) $ 193 | tell $ 194 | Just $ 195 | DLNE.singleton $ 196 | "No match found for pattern: " <> show aPat 197 | pure $ V.map (\pn -> pkgDef {packageName = pn}) cands 198 | pure dom {packages = pkgs'} 199 | in DomainResult {domains = Domains doms, warnings = warns} 200 | 201 | data PackageDef' a = PackageDef 202 | { packageName :: !a 203 | , extraDeps :: V.Vector Dependency 204 | } 205 | deriving (Show, Eq, Ord, Generic, Functor, Foldable, Traversable) 206 | 207 | instance FromJSON a => FromJSON (PackageDef' a) where 208 | parseJSON = 209 | runReaderT $ 210 | ReaderT (fmap (`PackageDef` V.empty) . J.parseJSON) 211 | <|> ReaderT do 212 | withObject "object" $ \dic -> do 213 | packageName <- dic .: "package" 214 | excepts <- dic .:? "exception" 215 | extraDeps <- maybe (pure V.empty) (.: "depends_on") excepts 216 | pure PackageDef {..} 217 | 218 | data Dependency 219 | = DomainDep !DomainName 220 | | PackageDep !PackageName 221 | deriving (Show, Eq, Ord, Generic) 222 | 223 | instance FromJSON Dependency where 224 | parseJSON = 225 | runReaderT $ 226 | ReaderT (withText "domain name" (pure . DomainDep . DomainName)) 227 | <|> ReaderT 228 | ( withObject 229 | "{package: ...} or {domain: ...}" 230 | ( \obj -> 231 | DomainDep <$> obj .: "domain" <|> PackageDep <$> obj .: "package" 232 | ) 233 | ) 234 | 235 | newtype Overlayed gr = Overlayed {getOverlayed :: gr} 236 | deriving (Show, Eq, Ord) 237 | 238 | instance GC.Graph gr => Semigroup (Overlayed gr) where 239 | (<>) = coerce $ GC.overlay @gr 240 | 241 | instance GC.Graph gr => Monoid (Overlayed gr) where 242 | mempty = Overlayed GC.empty 243 | -------------------------------------------------------------------------------- /test/Development/Guardian/AppSpec.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE BlockArguments #-} 2 | {-# LANGUAGE LambdaCase #-} 3 | {-# LANGUAGE OverloadedStrings #-} 4 | {-# LANGUAGE QuasiQuotes #-} 5 | {-# LANGUAGE TupleSections #-} 6 | 7 | module Development.Guardian.AppSpec (test_defaultMainWith) where 8 | 9 | import Control.Exception 10 | import Control.Monad (void) 11 | import qualified Data.ByteString.Builder as BB 12 | import qualified Data.Text.Lazy as LT 13 | import qualified Data.Text.Lazy.Encoding as LT 14 | import Data.Version (showVersion) 15 | import Development.Guardian.App 16 | import Development.Guardian.Flags (cabalEnabled, stackEnabled) 17 | import Development.Guardian.Graph.Adapter.Detection 18 | import Development.Guardian.Test.Flags 19 | import GHC.IO.Exception (ExitCode (..)) 20 | import Path 21 | import Path.IO 22 | import Paths_guardian (version) 23 | import RIO (logOptionsMemory, mkSimpleApp, readIORef, runRIO, runSimpleApp, withLogFunc) 24 | import qualified RIO.ByteString.Lazy as LBS 25 | import RIO.Process (proc, runProcess_) 26 | import Test.Tasty 27 | import Test.Tasty.ExpectedFailure (ignoreTestBecause) 28 | import Test.Tasty.HUnit 29 | 30 | fakeBuildInfo :: BuildInfo 31 | fakeBuildInfo = BuildInfo {versionString = showVersion version, gitInfo = Nothing} 32 | 33 | test_defaultMainWith :: TestTree 34 | test_defaultMainWith = 35 | testGroup "defaultMainWith" $ 36 | concreteAdapterTests : stackCases ++ cabalCases ++ autoCases ++ customCases 37 | 38 | customCases :: [TestTree] 39 | customCases = 40 | [ testGroup 41 | "custom adapter" 42 | [ testConditional "cabal-plan" testCabalPlan 43 | $ testCase "works with cabal-plan dot" 44 | $ withCurrentDir 45 | ([reldir|data|] [reldir|only-custom-cabal-plan|]) 46 | $ do 47 | runSimpleApp $ proc "cabal" ["v2-update"] runProcess_ 48 | runSimpleApp $ proc "cabal" ["v2-build", "--dry-run", "all"] runProcess_ 49 | successfully_ $ 50 | mainWith ["custom"] 51 | , testConditional "graphmod" testGraphmod $ 52 | testCase "works with graphmod" $ 53 | successfully_ $ 54 | mainWith ["custom", "-c", "dependency-domains-graphmod.yaml"] 55 | , testCase "works with stack dot" 56 | $ withCurrentDir 57 | ([reldir|data|] [reldir|only-stack|]) 58 | $ successfully_ 59 | $ mainWith ["custom", "-c", "dependency-domains-stack-dot.yaml"] 60 | ] 61 | ] 62 | 63 | stackCases :: [TestTree] 64 | stackCases = 65 | [ skipIfStackDisabled $ 66 | testGroup 67 | "stack-specific options" 68 | [ testCase "Respects --stack-yaml" 69 | $ withCurrentDir 70 | ([reldir|data|] [reldir|test-only-dependency|]) 71 | $ successfully_ 72 | $ mainWith ["cabal", "-c", "dependency-domains-custom-stack.yaml"] 73 | ] 74 | ] 75 | 76 | cabalCases :: [TestTree] 77 | cabalCases = 78 | [ skipIfCabalDisabled $ 79 | testGroup 80 | "cabal-specific options" 81 | [ testCase "Respects projectFile" 82 | $ withCurrentDir 83 | ([reldir|data|] [reldir|test-only-dependency|]) 84 | $ successfully_ 85 | $ mainWith ["cabal", "-c", "dependency-domains-custom-cabal.yaml"] 86 | , testCase "Respects update: true" 87 | $ withCurrentDir 88 | ([reldir|data|] [reldir|test-only-dependency|]) 89 | $ successfully_ 90 | $ mainWith ["cabal", "-c", "dependency-domains-cabal-update-true.yaml"] 91 | , testCase "Respects update: (index-state)" 92 | $ withCurrentDir 93 | ([reldir|data|] [reldir|test-only-dependency|]) 94 | $ successfully_ 95 | $ mainWith ["cabal", "-c", "dependency-domains-cabal-update-index.yaml"] 96 | ] 97 | ] 98 | 99 | skipIfCabalDisabled :: TestTree -> TestTree 100 | skipIfCabalDisabled = testConditional "cabal" cabalEnabled 101 | 102 | skipIfStackDisabled :: TestTree -> TestTree 103 | skipIfStackDisabled = testConditional "stack" stackEnabled 104 | 105 | testConditional :: String -> Bool -> TestTree -> TestTree 106 | testConditional label testIt = 107 | if testIt 108 | then id 109 | else ignoreTestBecause $ "Test disabled for " <> label 110 | 111 | autoCases :: [TestTree] 112 | autoCases = 113 | [ testGroup 114 | "Auto detection" 115 | [ skipIfCabalDisabled 116 | $ testCase "Accepts config with cabal section only" 117 | $ withCurrentDir 118 | ([reldir|data|] [reldir|test-only-dependency|]) 119 | $ successfully 120 | (LT.isInfixOf "with backend Cabal" . LT.decodeUtf8) 121 | $ mainWith ["auto", "-c", "dependency-domains-custom-cabal.yaml"] 122 | , skipIfStackDisabled 123 | $ testCase "Accepts config with stack section only" 124 | $ withCurrentDir 125 | ([reldir|data|] [reldir|test-only-dependency|]) 126 | $ successfully 127 | (LT.isInfixOf "with backend Stack" . LT.decodeUtf8) 128 | $ mainWith ["auto", "-c", "dependency-domains-custom-stack.yaml"] 129 | , skipIfCabalDisabled 130 | $ testCase "Accepts unambiguous directory (cabal)" 131 | $ withCurrentDir 132 | ([reldir|data|] [reldir|only-cabal|]) 133 | $ successfully 134 | (LT.isInfixOf "with backend Cabal" . LT.decodeUtf8) 135 | $ mainWith ["auto"] 136 | , skipIfStackDisabled 137 | $ testCase "Accepts unambiguous directory (stack)" 138 | $ withCurrentDir 139 | ([reldir|data|] [reldir|only-stack|]) 140 | $ successfully 141 | (LT.isInfixOf "with backend Stack" . LT.decodeUtf8) 142 | $ mainWith ["auto"] 143 | , testCaseSteps "Rejects ambiguous inputs" \step -> 144 | withCurrentDir 145 | ([reldir|data|] [reldir|test-only-dependency|]) 146 | $ do 147 | step "Abmiguous Directory & Config without any custom section" 148 | mainWith ["auto"] `shouldThrow` (== NoCustomConfigSpecified) 149 | step "Abmiguous Directory & Config wit both custom sections" 150 | mainWith ["auto", "-c", "dependency-domains-ambiguous.yaml"] 151 | `shouldThrow` (== MultipleAdapterConfigFound) 152 | ] 153 | ] 154 | 155 | concreteAdapterTests :: TestTree 156 | concreteAdapterTests = 157 | testGroup 158 | "Concrete adapter behaviours, independent of adapters" 159 | [ skipIfDisabled $ 160 | testGroup 161 | backend 162 | [ testCase "invalidates test-only-dependency with default config" 163 | $ withCurrentDir 164 | ([reldir|data|] [reldir|test-only-dependency|]) 165 | $ mainWith [backend] `shouldThrow` (== ExitFailure 1) 166 | , testCaseSteps "invalidates test-only-dependency with default config (explicit path argument)" \step -> do 167 | step "Absolute dir" 168 | dir <- canonicalizePath ([reldir|data|] [reldir|test-only-dependency|]) 169 | mainWith [backend, fromAbsDir dir] `shouldThrow` (== ExitFailure 1) 170 | step "Relative dir" 171 | let rdir = [reldir|data|] [reldir|test-only-dependency|] 172 | mainWith [backend, fromRelDir rdir] `shouldThrow` (== ExitFailure 1) 173 | , testCaseSteps "accepts non-standard config yaml" $ \step -> 174 | withCurrentDir ([reldir|data|] [reldir|test-only-dependency|]) $ do 175 | step "Accepts when tests and benchmarks disabled" 176 | successfully_ $ 177 | mainWith [backend, "-c", "dependency-domains-no-tests-benchmarks.yaml"] 178 | step "Accepts input with exception rule" 179 | successfully (LT.isInfixOf "exceptional rules are used" . LT.decodeUtf8) $ 180 | mainWith [backend, "-c", "dependency-domains-except-A2-B1.yaml"] 181 | ] 182 | | (backend, skipIfDisabled) <- 183 | [ ("cabal", skipIfCabalDisabled) 184 | , ("stack", skipIfStackDisabled) 185 | ] 186 | ] 187 | 188 | successfully_ :: HasCallStack => IO (LBS.ByteString, Maybe SomeException) -> IO () 189 | successfully_ = void . successfully (const True) 190 | 191 | successfully :: HasCallStack => (LBS.ByteString -> Bool) -> IO (LBS.ByteString, Maybe SomeException) -> IO () 192 | successfully isOk act = do 193 | (a, exc) <- act 194 | case exc of 195 | Just err -> 196 | assertFailure $ 197 | "Exception: " 198 | <> displayException err 199 | <> "\nwith log: \n" 200 | <> LT.unpack (LT.decodeUtf8 a) 201 | Nothing 202 | | isOk a -> pure () 203 | | otherwise -> 204 | assertFailure $ 205 | "Exits Successfully, but the output is invalid: \n" 206 | <> LT.unpack (LT.decodeUtf8 a) 207 | 208 | shouldThrow :: (HasCallStack, Exception e) => IO (a, Maybe e) -> (e -> Bool) -> Assertion 209 | shouldThrow act p = 210 | act >>= \case 211 | (_, Nothing) -> 212 | assertFailure 213 | "Expected to throw excetpion, but exists successfully" 214 | (_, Just err) 215 | | p err -> pure () 216 | | otherwise -> 217 | assertFailure $ 218 | "Exception has been thrown, but does not satisfy the requirement: " 219 | <> show err 220 | 221 | mainWith :: Exception exc => [String] -> IO (LBS.ByteString, Maybe exc) 222 | mainWith args = do 223 | (logs, opts) <- logOptionsMemory 224 | eith <- try $ withLogFunc opts $ \logFunc -> do 225 | app <- mkSimpleApp logFunc Nothing 226 | runRIO app $ 227 | defaultMainWith fakeBuildInfo args 228 | (,either Just (const Nothing) eith) . BB.toLazyByteString 229 | <$> readIORef logs 230 | -------------------------------------------------------------------------------- /src/Development/Guardian/App.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE ApplicativeDo #-} 2 | {-# LANGUAGE DeriveAnyClass #-} 3 | {-# LANGUAGE DerivingStrategies #-} 4 | {-# LANGUAGE LambdaCase #-} 5 | {-# LANGUAGE OverloadedStrings #-} 6 | {-# LANGUAGE RecordWildCards #-} 7 | {-# LANGUAGE TemplateHaskell #-} 8 | 9 | module Development.Guardian.App ( 10 | BuildInfo (..), 11 | defaultMain, 12 | defaultMainWith, 13 | reportPackageGraphValidation, 14 | buildInfoQ, 15 | ) where 16 | 17 | import qualified Algebra.Graph as G 18 | import Control.Applicative ((<**>)) 19 | import qualified Data.Aeson as J 20 | import Data.Foldable.WithIndex 21 | import Data.Functor.WithIndex.Instances () 22 | import Data.List (intersperse) 23 | import qualified Data.List.NonEmpty as NE 24 | import qualified Data.Map.Strict as Map 25 | import Data.Monoid (Sum (..)) 26 | import qualified Data.Set as Set 27 | import Data.Version (Version (..), showVersion) 28 | import qualified Data.Yaml as Y 29 | import Development.Guardian.Constants (configFileName) 30 | import Development.Guardian.Flags 31 | import Development.Guardian.Graph 32 | import qualified Development.Guardian.Graph.Adapter.Cabal as Cabal 33 | import qualified Development.Guardian.Graph.Adapter.Custom as Custom 34 | import Development.Guardian.Graph.Adapter.Detection (detectAdapterThrow) 35 | import qualified Development.Guardian.Graph.Adapter.Stack as Stack 36 | import Development.Guardian.Graph.Adapter.Types 37 | import Development.Guardian.Types 38 | import GitHash (GitInfo, giDirty, giHash, tGitInfoCwdTry) 39 | import Language.Haskell.TH.Syntax 40 | import qualified Options.Applicative as Opts 41 | import Path 42 | import Path.IO (canonicalizePath, getCurrentDir) 43 | import RIO 44 | import System.Environment (getArgs) 45 | import Validation (validation, validationToEither) 46 | 47 | data Option = Option 48 | { mode :: Maybe StandardAdapters 49 | , target :: Maybe (SomeBase Dir) 50 | , config :: Path Rel File 51 | } 52 | deriving (Show, Eq, Ord) 53 | 54 | data BuildInfo = BuildInfo {versionString :: String, gitInfo :: Maybe GitInfo} 55 | deriving (Show) 56 | 57 | buildInfoQ :: Version -> Code Q BuildInfo 58 | buildInfoQ version = 59 | [|| 60 | BuildInfo 61 | { versionString = $$(liftTyped $ showVersion version) 62 | , gitInfo = either (const Nothing) Just $$(tGitInfoCwdTry) 63 | } 64 | ||] 65 | 66 | optsPI :: BuildInfo -> Opts.ParserInfo Option 67 | optsPI BuildInfo {..} = Opts.info (p <**> Opts.helper <**> versions <**> numericVersion) mempty 68 | where 69 | gitRev = 70 | maybe 71 | "" 72 | ( \gi -> 73 | ", Git revision " 74 | <> giHash gi 75 | <> if giDirty gi then " (dirty)" else "" 76 | ) 77 | gitInfo 78 | verStr = 79 | "Guardian Version " 80 | <> versionString 81 | <> gitRev 82 | <> " (cabal adapter: " 83 | <> show cabalEnabled 84 | <> ", stack adapter: " 85 | <> show stackEnabled 86 | <> ")" 87 | numericVersion = Opts.infoOption versionString (Opts.long "numeric-version" <> Opts.short 'V' <> Opts.help "Prints numeric version and exit.") 88 | versions = Opts.infoOption verStr (Opts.long "version" <> Opts.short 'V' <> Opts.help "Prints version string and exit.") 89 | inP mode = do 90 | config <- 91 | Opts.option (Opts.eitherReader parsFileP) $ 92 | Opts.long "config" 93 | <> Opts.short 'c' 94 | <> Opts.metavar "PATH" 95 | <> Opts.value configFileName 96 | <> Opts.showDefault 97 | <> Opts.help "configuration file, relative to the target directory" 98 | 99 | target <- 100 | optional $ 101 | Opts.argument (Opts.eitherReader parseDirP) $ 102 | Opts.help "input directory (uses current directory if missing)" <> Opts.metavar "DIR" 103 | pure Option {..} 104 | autoP = 105 | Opts.info (inP Nothing) $ 106 | Opts.progDesc "Defends borders against the auto-detected build-system" 107 | 108 | p = 109 | Opts.hsubparser 110 | ( mconcat $ 111 | [Opts.command "auto" autoP] 112 | ++ [ Opts.command 113 | "stack" 114 | ( Opts.info (inP $ Just Stack) $ 115 | Opts.progDesc "Defends borders against stack.yaml" 116 | ) 117 | | stackEnabled 118 | ] 119 | ++ [ Opts.command 120 | "cabal" 121 | ( Opts.info (inP $ Just Cabal) $ 122 | Opts.progDesc "Defends borders against cabal.project" 123 | ) 124 | | cabalEnabled 125 | ] 126 | ++ [ Opts.command 127 | "custom" 128 | ( Opts.info (inP $ Just Custom) $ 129 | Opts.progDesc "Defends borders with a custom adapter" 130 | ) 131 | ] 132 | ) 133 | 134 | parseDirP :: String -> Either String (SomeBase Dir) 135 | parseDirP = first show . parseSomeDir 136 | 137 | parsFileP :: String -> Either String (Path Rel File) 138 | parsFileP = first show . parseRelFile 139 | 140 | data GuardianException 141 | = InvalidDependencyDomainYaml [DomainGraphError] 142 | | PackageGraphErrors [PackageViolation] 143 | deriving (Show, Eq, Ord) 144 | deriving anyclass (Exception) 145 | 146 | eitherResult :: J.Result a -> Either String a 147 | eitherResult (J.Error s) = Left s 148 | eitherResult (J.Success a) = Right a 149 | 150 | defaultMainWith :: 151 | (MonadUnliftIO m, MonadReader env m, HasLogFunc env) => 152 | BuildInfo -> 153 | [String] -> 154 | m () 155 | defaultMainWith buildInfo args = do 156 | Option {..} <- 157 | liftIO $ 158 | Opts.handleParseResult $ 159 | Opts.execParserPure Opts.defaultPrefs (optsPI buildInfo) args 160 | targ <- maybe getCurrentDir canonicalizePath target 161 | logInfo $ "Using configuration: " <> fromString (fromRelFile config) 162 | yaml <- Y.decodeFileThrow (fromAbsFile $ targ config) 163 | domsCfg <- either throwString pure $ eitherResult $ J.fromJSON yaml 164 | 165 | mode' <- maybe (detectAdapterThrow yaml targ) pure mode 166 | logInfo $ 167 | "Checking dependency of " 168 | <> displayShow targ 169 | <> " with backend " 170 | <> displayShow mode' 171 | pkgGraph <- case mode' of 172 | Stack -> 173 | liftIO $ 174 | either throwString (Stack.buildPackageGraph . flip withTargetPath targ) $ 175 | eitherResult $ 176 | J.fromJSON yaml 177 | Cabal -> 178 | liftIO $ 179 | either throwString (Cabal.buildPackageGraph . flip withTargetPath targ) $ 180 | eitherResult $ 181 | J.fromJSON yaml 182 | Custom -> 183 | liftIO $ 184 | either throwString (Custom.buildPackageGraph . flip withTargetPath targ) $ 185 | eitherResult $ 186 | J.fromJSON yaml 187 | let DomainResult doms mwarns = resolveDomainName domsCfg pkgGraph 188 | forM_ mwarns $ \warns -> do 189 | logWarn $ "* " <> displayShow (length warns) <> " warnings during pattern expansion:" 190 | logWarn $ "Available entities: " <> displayShow (G.vertexList pkgGraph) 191 | mapM_ (logWarn . fromString) warns 192 | reportPackageGraphValidation doms pkgGraph 193 | 194 | reportPackageGraphValidation :: 195 | (MonadReader env m, MonadIO m, HasLogFunc env) => 196 | Domains -> 197 | PackageGraph -> 198 | m () 199 | reportPackageGraphValidation doms pkgGraph = do 200 | let mdomInfo = buildDomainInfo doms 201 | domInfo <- 202 | validation 203 | ( \errs -> do 204 | logError "Errors exists in dependency domain definition!" 205 | mapM_ (logError . displayShow) errs 206 | exitFailure 207 | ) 208 | pure 209 | mdomInfo 210 | let chkResult = 211 | validationToEither $ 212 | validatePackageGraph domInfo pkgGraph 213 | case chkResult of 214 | Left errs -> do 215 | logError $ 216 | "Dependency domain violation(s): found " 217 | <> displayShow (NE.length errs) 218 | mapM_ (logError . displayShow) errs 219 | exitFailure 220 | Right Ok -> logInfo "All dependency boundary is good!" 221 | Right (OkWithDiagnostics Diagnostics {..}) -> do 222 | unless (Map.null usedExceptionalRules) $ do 223 | logWarn "------------------------------" 224 | logWarn "* Following exceptional rules are used:" 225 | iforM_ usedExceptionalRules $ \pkg deps -> do 226 | let ds = Set.toList deps 227 | logWarn $ 228 | " - " 229 | <> displayShow pkg 230 | <> " depends on: " 231 | <> fold 232 | (intersperse "," $ map displayShow ds) 233 | 234 | unless (Map.null redundantExtraDeps) $ do 235 | let reduntCount = getSum $ foldMap (Sum . Set.size) redundantExtraDeps 236 | logWarn "------------------------------" 237 | logWarn $ 238 | "* " 239 | <> displayShow reduntCount 240 | <> " redundant exceptional dependency(s) found:" 241 | iforM_ redundantExtraDeps $ \pkg deps -> do 242 | let ds = Set.toList deps 243 | logWarn $ 244 | " - " 245 | <> displayShow pkg 246 | <> " doesn't depends on: " 247 | <> fold (intersperse ", " $ map displayShow ds) 248 | <> "\n" 249 | 250 | logInfo "------------------------------" 251 | logInfo "All dependency boundary is good, with some additional warning." 252 | 253 | defaultMain :: MonadUnliftIO m => BuildInfo -> m () 254 | defaultMain binfo = do 255 | logOpts <- 256 | logOptionsHandle stdout True 257 | <&> setLogUseTime False 258 | <&> setLogUseLoc False 259 | withLogFunc logOpts $ \logFun -> do 260 | app <- mkSimpleApp logFun Nothing 261 | runRIO app $ 262 | defaultMainWith binfo =<< liftIO getArgs 263 | -------------------------------------------------------------------------------- /src/Development/Guardian/Graph.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE ApplicativeDo #-} 2 | {-# LANGUAGE BangPatterns #-} 3 | {-# LANGUAGE DeriveAnyClass #-} 4 | {-# LANGUAGE DeriveGeneric #-} 5 | {-# LANGUAGE DeriveTraversable #-} 6 | {-# LANGUAGE DerivingVia #-} 7 | {-# LANGUAGE FlexibleContexts #-} 8 | {-# LANGUAGE GADTs #-} 9 | {-# LANGUAGE LambdaCase #-} 10 | {-# LANGUAGE OverloadedStrings #-} 11 | {-# LANGUAGE RecordWildCards #-} 12 | {-# LANGUAGE ScopedTypeVariables #-} 13 | {-# LANGUAGE TypeApplications #-} 14 | 15 | module Development.Guardian.Graph where 16 | 17 | import qualified Algebra.Graph as G 18 | import qualified Algebra.Graph.Class as GC 19 | import Algebra.Graph.Label (Path) 20 | import qualified Algebra.Graph.Labelled as LG 21 | import qualified Algebra.Graph.Relation as Rel 22 | import qualified Algebra.Graph.Relation.Preorder as Preorder 23 | import qualified Algebra.Graph.ToGraph as GC 24 | import Control.Monad (guard, void) 25 | import Data.Bifunctor (Bifunctor) 26 | import qualified Data.Bifunctor as Bi 27 | import Data.Coerce (coerce) 28 | import qualified Data.DList as DL 29 | import Data.DList.DNonEmpty (DNonEmpty) 30 | import qualified Data.DList.DNonEmpty as DLNE 31 | import qualified Data.HashMap.Strict as HM 32 | import qualified Data.List.NonEmpty as NE 33 | import Data.Map.Strict (Map) 34 | import qualified Data.Map.Strict as Map 35 | import Data.Monoid (Ap (..)) 36 | import Data.Semigroup.Generic 37 | import Data.Set (Set) 38 | import qualified Data.Set as Set 39 | import qualified Data.Vector as V 40 | import Development.Guardian.Types 41 | import GHC.Generics (Generic) 42 | import Validation 43 | 44 | buildDomainInfo :: Domains -> Validation (NE.NonEmpty DomainGraphError) DomainInfo 45 | buildDomainInfo domainConfig = do 46 | let packageDic = buildPackageDic domainConfig 47 | domainGraph <- toDomainGraph domainConfig 48 | pure DomainInfo {..} 49 | 50 | buildRawDomainGraph :: 51 | (GC.Graph gr, GC.Vertex gr ~ DomainName) => 52 | Domains -> 53 | gr 54 | buildRawDomainGraph Domains {..} = 55 | getOverlayed $ 56 | HM.foldMapWithKey 57 | ( \dom Domain {..} -> 58 | Overlayed (GC.vertex dom) 59 | <> maybe mempty (foldMap (Overlayed . GC.edge dom)) dependsOn 60 | ) 61 | getDomains 62 | 63 | toDomainGraph :: Domains -> Validation (NE.NonEmpty DomainGraphError) DomainGraph 64 | toDomainGraph doms = 65 | Bi.first DLNE.toNonEmpty $ 66 | ans 67 | <$ Bi.first DLNE.singleton (detectCycle raw) 68 | <* Bi.first DLNE.singleton (detectPackageOverlaps doms) 69 | where 70 | !raw = buildRawDomainGraph doms 71 | !ans = Preorder.fromRelation raw 72 | 73 | detectPackageOverlaps :: 74 | Domains -> Validation DomainGraphError () 75 | detectPackageOverlaps Domains {..} 76 | | Map.null overlaps = Success () 77 | | otherwise = Failure $ OverlappingPackages overlaps 78 | where 79 | overlaps = 80 | Map.filter ((> 1) . Set.size) $ 81 | getCatMap $ 82 | HM.foldMapWithKey 83 | ( \dom -> 84 | foldMap 85 | ( CatMap 86 | . flip Map.singleton (Set.singleton dom) 87 | . packageName 88 | ) 89 | . packages 90 | ) 91 | getDomains 92 | 93 | newtype CatMap k v = CatMap {getCatMap :: Map k v} 94 | 95 | instance (Semigroup v, Ord k) => Semigroup (CatMap k v) where 96 | (<>) = coerce $ Map.unionWith @k @v (<>) 97 | {-# INLINE (<>) #-} 98 | 99 | instance (Semigroup v, Ord k) => Monoid (CatMap k v) where 100 | mempty = CatMap Map.empty 101 | {-# INLINE mempty #-} 102 | 103 | detectCycle :: 104 | (GC.ToGraph t, Ord (GC.ToVertex t), GC.ToVertex t ~ DomainName) => 105 | t -> 106 | Validation DomainGraphError () 107 | detectCycle gr = 108 | Bi.first CyclicDomainDep $ 109 | eitherToValidation $ 110 | void $ 111 | GC.topSort gr 112 | 113 | buildPackageDic :: Domains -> PackageDic 114 | buildPackageDic = 115 | HM.foldMapWithKey 116 | ( \domName Domain {..} -> 117 | Map.fromList $ 118 | map (\PackageDef {..} -> (packageName, (domName, extraDeps))) $ 119 | V.toList packages 120 | ) 121 | . getDomains 122 | 123 | matches :: PackageDic -> Dependency -> PackageName -> Bool 124 | matches pkgDic (DomainDep dn) pkg = 125 | maybe False ((dn ==) . fst) $ Map.lookup pkg pkgDic 126 | matches _ (PackageDep pn) pkg = pn == pkg 127 | 128 | newtype LOverlayed e a = LOverlayed {getLOverlayed :: LG.Graph e a} 129 | deriving (Show, Eq, Ord, Generic) 130 | 131 | instance Monoid e => Semigroup (LOverlayed e a) where 132 | (<>) = coerce $ LG.overlay @e @a 133 | {-# INLINE (<>) #-} 134 | 135 | instance Monoid e => Monoid (LOverlayed e a) where 136 | mempty = LOverlayed LG.empty 137 | {-# INLINE mempty #-} 138 | 139 | data ActualGraphs a b = AGs {activatedGraph :: a, exceptionGraph :: b} 140 | deriving (Functor, Show, Eq, Ord, Generic) 141 | deriving (Semigroup, Monoid) via GenericSemigroupMonoid (ActualGraphs a b) 142 | 143 | instance Bifunctor ActualGraphs where 144 | bimap f g (AGs x y) = AGs (f x) (g y) 145 | {-# INLINE bimap #-} 146 | first f (AGs x y) = AGs (f x) y 147 | {-# INLINE first #-} 148 | second g (AGs x y) = AGs x (g y) 149 | {-# INLINE second #-} 150 | 151 | buildActualGraphs :: 152 | PackageDic -> 153 | PackageGraph -> 154 | Validation 155 | (DNonEmpty PackageViolation) 156 | (ActualGraphs ActualGraph (Map PackageName (Set Dependency))) 157 | buildActualGraphs pkgDic = 158 | let pkgs = Map.keys pkgDic 159 | in fmap 160 | ( Bi.bimap 161 | (Bi.first DL.toList . getLOverlayed) 162 | (Map.filter (not . Set.null) . getCatMap) 163 | ) 164 | . getAp 165 | . foldMap 166 | ( \e@(src, dst) -> Ap $ do 167 | src' <- 168 | maybe (failed $ OrphanPackage src pkgs) Success $ 169 | Map.lookup src pkgDic 170 | 171 | (dstDomain, _) <- 172 | maybe (failed $ OrphanPackage dst pkgs) Success $ 173 | Map.lookup dst pkgDic 174 | 175 | pure $ 176 | let (srcDomain, srcExcept) = src' 177 | aGraph = LOverlayed $ LG.edge (DL.singleton e) srcDomain dstDomain 178 | excepts = Set.fromList $ V.toList $ V.filter (flip (matches pkgDic) dst) srcExcept 179 | in if Set.null excepts 180 | then AGs {exceptionGraph = mempty, activatedGraph = aGraph} 181 | else AGs {activatedGraph = mempty, exceptionGraph = CatMap $ Map.singleton src excepts} 182 | ) 183 | . G.edgeList 184 | 185 | failed :: e -> Validation (DNonEmpty e) a 186 | failed = Failure . pure 187 | 188 | validatePackageGraph :: 189 | DomainInfo -> PackageGraph -> Validation (NE.NonEmpty PackageViolation) CheckResult 190 | validatePackageGraph DomainInfo {..} pg = 191 | Bi.first DLNE.toNonEmpty $ 192 | case buildActualGraphs packageDic pg of 193 | Failure e -> Failure e 194 | Success AGs {..} -> 195 | let redundantExtras = findRedundantExtraDeps packageDic pg 196 | diags = 197 | Diagnostics 198 | { redundantExtraDeps = 199 | Set.fromList . V.toList <$> redundantExtras 200 | , usedExceptionalRules = exceptionGraph 201 | } 202 | resl 203 | | isEmptyDiagnostics diags = Ok 204 | | otherwise = OkWithDiagnostics diags 205 | in resl 206 | <$ ( case Bi.first DLNE.singleton (detectPackageCycle pg) of 207 | f@Failure {} -> f 208 | Success {} -> 209 | Bi.first DLNE.singleton (coversAllPackages packageDic pg) 210 | <* satisfiesDomainGraph domainGraph activatedGraph 211 | ) 212 | 213 | detectPackageCycle :: 214 | PackageGraph -> Validation PackageViolation () 215 | detectPackageCycle pkgs = 216 | void $ 217 | eitherToValidation $ 218 | Bi.first CyclicPackageDep $ 219 | GC.topSort pkgs 220 | 221 | type ExemptDomDeps = ActualGraph 222 | 223 | findRedundantExtraDeps :: 224 | PackageDic -> 225 | PackageGraph -> 226 | Map PackageName (V.Vector Dependency) 227 | findRedundantExtraDeps pkgDic pg = 228 | Map.mapMaybeWithKey 229 | ( \pkg (_, specifiedDeps) -> do 230 | let actualDepPkgs = GC.postSet pkg pg 231 | actualDepDoms = 232 | Set.map 233 | (\dpkg -> maybe (error $ "No pkg find: " <> show (dpkg, actualDepPkgs)) fst (Map.lookup dpkg pkgDic)) 234 | actualDepPkgs 235 | deps = 236 | V.filter 237 | ( \case 238 | DomainDep dn -> dn `Set.notMember` actualDepDoms 239 | PackageDep pn -> pn `Set.notMember` actualDepPkgs 240 | ) 241 | specifiedDeps 242 | guard $ not $ V.null deps 243 | pure deps 244 | ) 245 | pkgDic 246 | 247 | coversAllPackages :: 248 | PackageDic -> PackageGraph -> Validation PackageViolation () 249 | coversAllPackages pkgDic pg = 250 | if null remain 251 | then Success () 252 | else Failure $ UncoveredPackages remain 253 | where 254 | remain = Set.toList $ G.vertexSet pg Set.\\ Map.keysSet pkgDic 255 | 256 | satisfiesDomainGraph :: 257 | DomainGraph -> ActualGraph -> Validation (DLNE.DNonEmpty PackageViolation) () 258 | satisfiesDomainGraph domGr ag = 259 | maybeToFailure () (DLNE.fromNonEmpty <$> NE.nonEmpty violatingEdges) 260 | where 261 | expectedEdges = Rel.edgeSet $ Preorder.toRelation domGr 262 | actualEdges :: Map (DomainName, DomainName) (Path PackageName) 263 | actualEdges = 264 | Map.fromList $ 265 | map (\(x1, dn, dn') -> ((dn, dn'), x1)) $ 266 | LG.edgeList ag 267 | violatingEdges = 268 | map 269 | ( uncurry $ uncurry DomainBoundaryViolation 270 | ) 271 | $ Map.toList 272 | $ actualEdges `Map.withoutKeys` expectedEdges 273 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # guardian - The border guardian for your package dependencies 2 | 3 | [![Build & Test](https://github.com/deepflowinc-oss/guardian/actions/workflows/haskell.yml/badge.svg)](https://github.com/deepflowinc-oss/guardian/actions/workflows/haskell.yml) 4 | ![Hackage](https://img.shields.io/hackage/v/guardian) 5 | 6 | Guardian enforces dependency boundary constraints and keeps your project dependencies sane. 7 | Mainly designed to apply to the package dependencies internal of a large Haskell monorepo, but it can also be used with monorepos in other/multi languages or at the level of modules. 8 | 9 | - [Introduction - How to keep your monorepo sane](#introduction---how-to-keep-your-monorepo-sane) 10 | + [Dependency Inversion Principle](#dependency-inversion-principle) 11 | + [The emergence of Guardian - package dependency domain isolation in practice](#the-emergence-of-guardian---package-dependency-domain-isolation-in-practice) 12 | + [Summary](#summary) 13 | - [Installation](#installation) 14 | - [Usage](#usage) 15 | + [Actual validation logic](#actual-validation-logic) 16 | + [Syntax of `dependency-domains.yaml`](#syntax-of-dependency-domainsyaml) 17 | - [Domain Definition](#domain-definition) 18 | - [Component Section](#component-section) 19 | - [Cabal specific settings](#cabal-specific-settings) 20 | - [Stack specific settings](#stack-specific-settings) 21 | - [Custom adapter settings](#custom-adapter-settings) 22 | + [Current limitation](#current-limitation) 23 | - [Example Configuration](#example-configuration) 24 | - [GitHub Actions](#github-actions) 25 | - [Contribution](#contribution) 26 | - [Copyright](#copyright) 27 | 28 | ## Introduction - How to keep your monorepo sane 29 | 30 | Maintaining a large monorepo consisting of dozens of packages is not easy. 31 | Sometimes, just making sure all packages compile on CI is not enough - to accelerate the development cycle, it is crucial to ensure that changes to the codebase trigger only necessary rebuilds as far as possible. 32 | 33 | But enforcing this requirement by hand is not easy. 34 | To make things work, a kind of people known as programmers - especially Haskellers, you know - are inherently lazy. 35 | They are so lazy to grep through the project, shouting out "look, there is what we want!", and adding extra dependency - finally they make everything depend on each other and constitute the gigantic net of dependencies that takes a quarter day even if only a small portion of the code was changed. 36 | 37 | Indeed, this was exactly the situation we developers at DeepFlow faced in 2021. 38 | Our main product is a high-performance numeric solver[^1], consisting of ~70 Haskell packages, which sums up to ~150k lines of code. 39 | It includes: 40 | 41 | - abstraction of numeric computation backend, 42 | - its concrete implemenatations, 43 | - abstraction of plugins to extend solvers, 44 | - its concrete implementations, 45 | - abstraction of equation system, 46 | - its concrete implementations, 47 | - abstraction of communication strategies, 48 | - its concrete implementations (e.g. MPI, standalone), 49 | - abstract solver logic, and 50 | - concrete solver implementations putting these all together. 51 | 52 | What we had in 2021 was the chaotic melting pot of these players. 53 | If one changes a single line of a backend, it forces seemingly unrelated plugins to be recompiled. 54 | If one removes a single redundant instance, then it miraculously triggers the rebuild in some concrete implementation of some specialised solver. 55 | In any case, it took at most six hours and ~100 GiB to complete, as we make heavy use of type-level hackery and rely on the aggressive optimisation mechanism of GHC[^2]. 56 | 57 | This situation is far from optimal, sacrifices developer experience, and severely slows down the iteration speed. 58 | Our product seems dying slowly but surely. 59 | 60 | [^1]: For those who are curious, please read our [old slide][old slide] - it is an old presentation, so some details are outdated, but the overall situation is not so different. 61 | [^2]: The situation, however, gets better as newer GHC releases came out and we refactor the code structure. 62 | Nowadays, it takes at most two and a half hours and only ~20GiB. 63 | The detail of such change is out of the scope of this article. 64 | 65 | ### Dependency Inversion Principle 66 | 67 | In the middle of 2021, we decided to fight against this situation to save our project. 68 | Our rule of thumb here was _Dependency Inversion Principle_ from the realm of OOP. 69 | That says: 70 | 71 | > 1. High-level modules should not import anything from low-level modules. Both should depend on abstractions 72 | > 2. Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions. 73 | > 74 | > (_excerpt from [Wikipedia][dip]; renumbering is due to the author_) 75 | 76 | This is called dependency _inversion_ because it seems inverse to the traditional usage of inheritance in OOP. 77 | We decided to apply this principle to package dependencies. 78 | 79 | We divided the packages into several groups, called _dependency domains_. 80 | We drew the term and intuition from the [blog post by GitHub about partitioning databases into domains][gh-db-doms]. 81 | 82 | Our packages are divided into the following groups[^3]: 83 | 84 | - `infra`: providing common infrastructure, such as data containers and algorithms. 85 | - `highlevel`: high-level abstractions of backends, equations, and plugins. 86 | - `lowlevel`: low-level and concrete implementations. 87 | - `plugin`: concrete plugin implementations 88 | - `solver`: concrete solver implementations. 89 | - `tool`: misc utility apps independent of solvers. 90 | - `test`: test packages, mainly providing cross-package integration tests. 91 | 92 | Any package in monorepo must be classified into exactly one dependency domain. 93 | Furthermore, we define constraints on the dependencies between domains: each domain is given the set of domains on which its members can (transitively) depend, and the induced dependency graph must form a directed acyclic graph. 94 | 95 | The example is as follows: 96 | 97 | ```mermaid 98 | flowchart TD 99 | test[test] --> solver[solver]; 100 | test --> tool[tool]; 101 | highlevel[highlevel] --> infra[infra]; 102 | plugin[plugin] --> highlevel; 103 | lowlevel[lowlevel] --> infra; 104 | solver[solver] --> highlevel; 105 | solver --> lowlevel; 106 | solver --> plugin; 107 | tool --> lowlevel; 108 | tool --> plugin; 109 | ``` 110 | 111 | Each package of a domain can depend only on the packages in the same or downstream package in the diagram. 112 | Note that, in this setting, high-level (or abstract) packages can only depend on another abstraction or infrastructure package and only the application packages (say, `solver` and `tool) can depend both on abstractions and implementations. 113 | 114 | [dip]: https://en.wikipedia.org/wiki/Dependency_inversion_principle 115 | [old slide]: https://speakerdeck.com/konn/da-gui-mo-shu-zhi-ji-suan-wozhi-eru-haskell-nil-nil-pragmatic-haskell-in-large-scale-numerical-computation-nil-nil 116 | [gh-db-doms]: https://github.blog/2021-09-27-partitioning-githubs-relational-databases-scale/ 117 | [^3]: These are not exactly what we have in reality but in simplified form. 118 | 119 | ### The emergence of Guardian - package dependency domain isolation in practice 120 | 121 | So far, so good. 122 | Now that we have the grand picture of the package dependency hierarchy, it is time to make things laid out. 123 | 124 | The problem is: enforcing such an invariant by hand is almost impossible as the number of packages gets larger and larger. 125 | Even after we sorted out where the violation occurred, it takes much time to fix them all up while continuing to add new features and fix bugs. 126 | Furthermore, even if we had worked out things once somehow, our laziness could strike back to slowly violate such constraints and make everything rotten again. Uh-oh. 127 | 128 | ... Not really. Here, our brave `guardian` can help us! 129 | 130 | `Guardian` is a tool designed for this purpose and developed in DeepFlow. We are running `guardian` on our CI for almost two years. 131 | 132 | In guardian, we declare the whole dependency constraints in `dependency-domains.yaml`. 133 | The above constraints are expressed as follows: 134 | 135 | ```yaml 136 | domains: 137 | infra: 138 | depends_on: [] 139 | packages: 140 | - algorithms 141 | - geometry 142 | - linalgs 143 | highlevel: 144 | packages: 145 | - numeric-backends 146 | - plugin-base 147 | - equation-class 148 | depends_on: [infra] 149 | lowlevel: 150 | packages: 151 | - reference-backend 152 | - fast-backend 153 | - mesh-format 154 | depends_on: [infra] 155 | plugin: 156 | packages: 157 | - plugin-A 158 | - plugin-B 159 | depends_on: [highlevel] 160 | solver: 161 | packages: 162 | - solver-standalone 163 | - solver-parallel 164 | depends_on: [plugin, lowlevel, highlevel] 165 | tool: 166 | packages: 167 | - post 168 | - pre 169 | depends_on: [plugin, lowlevel] 170 | test: 171 | packages: 172 | - integration-tests 173 | - regression-tests 174 | depends_on: [solver, tool] 175 | 176 | # We track dependencies in test & benchmark components as well. 177 | components: 178 | tests: true 179 | benchmarks: false 180 | ``` 181 | 182 | Note that we don't have to include `highlevel` into the dependencies of `solver` - it is already introduced via dependency on `plugin`. 183 | Here, we include `highlevel` just for documentation. 184 | 185 | Actually, this is not the first rule we had. 186 | As mentioned above, our codebase consists of ~70 packages summing up to ~150k lines of code. 187 | In some cases, it takes much effort to remove dependency violating the dependency domain boundary without sacrificing performance. 188 | 189 | To accommodate such cases, `guradian` provides _exception rules_ for compromise. 190 | It looks like this[^4]: 191 | 192 | ```yaml 193 | ... 194 | plugin: 195 | packages: 196 | - package: plugin-A 197 | exception: 198 | depends_on: 199 | - lowlevel 200 | - package: fast-backend 201 | - plugin-B 202 | depends_on: [highlevel] 203 | ... 204 | ``` 205 | 206 | As shown above, exception rules are specified per-package manner. 207 | It can allow dependencies to other dependency domains and/or individual package which is otherwise banned. 208 | An exception rule doesn't affect the transitive dependency - only the dependencies of the specified package are exempted. 209 | In the above example, only `plugin-A` is allowed to depend on the exempted targets. 210 | So, if `plugin-B` depends on `plugin-A`, it cannot depend on `fast-backend` package explicitly. 211 | 212 | The point is that exception rules must be considered as tentative compromises - they must be removed at some points to enforce dependency inversion at the package level to keep the entire project healthy. 213 | To make it clear, `guardian` will emit warnings when the exception rules are used: 214 | 215 | ```log 216 | [info] Using configuration: dependency-domains.yaml 217 | [info] Checking dependency of /path/to/project/" with backend Cabal 218 | [warn] ------------------------------ 219 | [warn] * Following exceptional rules are used: 220 | [warn] - "A2" depends on: PackageDep "B1" 221 | [info] ------------------------------ 222 | [info] All dependency boundary is good, with some additional warning. 223 | ``` 224 | 225 | And when the situation allows some rules to be lifted, it also tells us as follows: 226 | 227 | ```log 228 | [warn] * 1 redundant exceptional dependency(s) found: 229 | [warn] - "A2" doesn't depends on: PackageDep "B1" 230 | ``` 231 | 232 | So, what `guardian` would do is: 233 | 234 | - Check if all the packages are disjointly classified into dependency domains. 235 | - Makes sure that the dependency graph between each domain forms a DAG. 236 | - Check if all the dependency constraint is met, except for prescribed exceptions. 237 | - Report the validation results with information about used/redundant exception rules. 238 | 239 | One thing to note is that `guardian` checks dependency constraints based solely on the dependencies specified in `*.cabal` and/or `package.yaml` file. 240 | In other words, guardian doesn't treat a dependency introduced by module imports. 241 | This is because such a dependency is already handled by the compiler. 242 | 243 | In our case, we had two exception rules enabled at first and it took almost half a year to finally abolish all of the exception rules. 244 | This was finally done when the major rework on the entire structure of our product. 245 | Indeed, one of the motivations of the refactoring was to completely deprecate the exception rules and the overall dependency graph above was the driving force to make out the correct design of the entire package structure. 246 | In this way, the presence of exception rules in the dependency boundary constraints signals the design flaw in the package hierarchy and gives us a useful guideline for a redesign. 247 | 248 | The benefit of dependency constraint enforcement by guardian is not limited to refactoring. 249 | Running `guardian` on CI, one can always check the sanity of the entire structure when adding new features/packages to the monorepo. 250 | When one adds new packages to the monorepo, guardian force us to think in which domain to put them. 251 | If we accidentally add package dependencies violating constraints in the midway of adding new features, guardian will tell us it generously. 252 | In this way, guardian helps the product evolve healthily while preventing getting rotten. 253 | 254 | [^4]: As package `fast-backend` is already included in `lowlevel` domain, we don't have to include `fast-backend` as a separate exception rule. Here, we included it for the exposition. 255 | 256 | ### Summary 257 | 258 | With guardian, you can: 259 | 260 | - divide monorepo packages into disjoint groups called _dependency domains_, 261 | - define the topology of DAG of dependency domains to secure dependency boundaries, 262 | - you can still specify exception rules for compromise - they will be warned so that you can finally remove it. 263 | 264 | Equipped with these features, we can: 265 | 266 | - Keep the package hierarchy of the monorepo clean and loose-coupled. 267 | - Be more careful when adding new packages/features as guardian shouts at us when violations are found. 268 | 269 | There are several possibilities in the design of actual DAG of domains, we recommend the following rules: 270 | 271 | - Separate abstractions and concrete implementations as far as possible. 272 | + Ideally, applications and/or tests can depend on both. 273 | + Keep DIP in mind: Abstractions SHOULD NOT depend on implementations. Implementation should depend on abstractions. 274 | - Refine domains based on semantics/purposes. 275 | - Use dependency domain constraints as a guideline for the design of the overall monorepo. 276 | + It guides us in making out the "correct" place to put new packages/features. 277 | + The number of exception rules indicates the code smell. 278 | 279 | ## Installation 280 | 281 | You can download prebuilt binaries for macOS and Linux from [Release](https://github.com/deepflowinc-oss/guardian/releases/latest). 282 | 283 | You can also use [GitHub Action](#github-actions) in your CI. 284 | 285 | To build from source, we recommend using `cabal-install >= 3.8`: 286 | 287 | ```sh 288 | git clone git@github.com/deepflowinc-oss/guardian.git 289 | cd guardian 290 | cabal install 291 | ``` 292 | 293 | ## Usage 294 | 295 | ```sh 296 | guardian (auto|stack|cabal) [-c|--config PATH] [DIR] 297 | ``` 298 | 299 | Subcommand `auto`, `stack`, `cabal` specifies the _adapter_, i.e. build-system, to compute dependency. 300 | 301 | - `stack`: uses Stack (>= 2.9) as an adapter. 302 | - `cabal`: uses cabal-install (>= 3.8) as an adapter. 303 | - `auto`: determines adapter based on the directory contents and guardian configuration. Currently, it chooses an adapter from `stack` or `cabal`. 304 | + If exactly one of `cabal.project` or `stack.yaml` is found, use the corresponding build system as an adapter. 305 | + If exactly one of the custom config sections (say, `cabal:` or `stack:`) is found in the config file, use the corresponding build system. 306 | - `custom`: uses the user-specified external process as an adapter. 307 | With this adapter, you can check dependency between arbitrary entities in any language. 308 | See the [Custom adapter settings](#custom-adapter-settings) section for more detail. 309 | 310 | Note that `guardian` links directly to `stack` and `cabal-install`, so you don't need those binaries. 311 | Make sure the project configuration is compatible with the above version constraint. 312 | 313 | Optional argument `DIR` specifies the project root directory to check. If omitted, the current working directory is used. 314 | 315 | The option `--config` (or `-c` for short) specifies the path of the guardian configuration file relative to the project root. 316 | If omitted, `dependency-domains.yaml` is used. 317 | 318 | ### Actual validation logic 319 | 320 | When invoked, guardian will check in these steps: 321 | 322 | 1. Defines a dependency graph based on the package dependencies. 323 | 2. Checks if the graph forms DAG. 324 | 3. Checks if the dependency domain constraints are satisfied first ignoring exception rules. 325 | 4. If violating dependency is detected, exempt it if it is covered by any exception rule. 326 | + If any exception rule can be used, warn of its use; otherwise, report it as an error. 327 | 5. Report results, warning about used and redundant exception rules. 328 | 329 | ### Syntax of `dependency-domains.yaml` 330 | 331 | Guardian configuration file consists of the following top-level sections: 332 | 333 | | Section | Description | 334 | | --------- | ----------- | 335 | | `domains` | **Required**. Definition of Dependency Domains (see [Domain Definition](#domain-definition)) | 336 | | `wildcards` | _Optional_. If `true`, package name can contain wildcards `*`, which matches arbitrary string (when enabled and you need the literal character `*`, use `\*`). Note that even if this option is set, you CANNOT use `*` in exception rule targets. (Default: `false`) | 337 | | `components` | _Optional_. Configuration whether track test/benchmark dependencies as well (see [Component Section](#component-section)) | 338 | | `cabal` | _Optional_. Cabal-specific configurations. (see [Cabal specific settings](#cabal-specific-settings)) | 339 | | `stack` | _Optional_. Stack-specific configurations. (see [stack specific settings](#stack-specific-settings)) | 340 | 341 | The ordering of sections is irrelevant. 342 | 343 | See [Example Configuration](#example-configuration) for a complete example. 344 | 345 | #### Domain Definition 346 | 347 | Dependency domains, their members, and constraints are specified in the `domains` section. 348 | 349 | The `domains` section must be a dictionary associating each domain label to the domain definition. 350 | A domain label must match `/[A-Za-z0-9-_]+/`. 351 | 352 | Individual domain definition object has the following fields: 353 | 354 | | Field | Type | Description | 355 | | ----- | ---- | ----------- | 356 | | `depends_on` | `[String]` | **Required**. Labels of the other domains that the dependency being defined is depending on. | 357 | | `packages` | `Package` | **Required**. A list of packages of the domain. Each package can be a string or object; see below for detail | 358 | | `description` | `Maybe String` | _Optional_. A human-readable description of the domain. Note that this field is not processed by guardian. | 359 | 360 | Package entry occurring in the `packages` field can either be a string or package object. 361 | A single string, e.g. `mypackage` is interpreted as a package object with only the `package` field specified, e.g. `{package: "mypackage"}`. 362 | Package object has the following field: 363 | 364 | | Field | Type | Description | 365 | | --------- | -------- | ----------- | 366 | | `package` | `String` | **Required**. Package name | 367 | | `exception` | `ExceptionRule` | _Optional_. Package-specific exception rules | 368 | 369 | Exception rule object is an object of form `{depends_on: ]}`. 370 | Exception item can be either a simple string or an object of form `{package: "package-name"}`. 371 | A single string as an exception rule is interpreted as a dependency on the domain with having the string itself as the label. 372 | An object `{package: "package-name"}` is interpreted as a dependency on the package `package-name`. 373 | 374 | Example: 375 | 376 | ```yaml 377 | domains: 378 | A: 379 | depends_on: [C] 380 | packages: 381 | - A1 382 | - package: A2 383 | exception: {depends_on: [package: B1]} 384 | B: 385 | packages: [B1] 386 | depends_on: [C] 387 | C: 388 | packages: [C] 389 | depends_on: [] 390 | ``` 391 | 392 | In the above example, packages are divided into three domains: `A`, `B`, and `C`. 393 | Apart from the packages in the same domain, packages in domains `A` and `B` can depend on those in `C`. 394 | Exception rule is specified for package `A2` in domain `A`, which allows `A2` to directly depend on package `B1`. 395 | Even if `A1` depends on `A2`, `A1` cannot depend on it directly - this is how exception rules work. 396 | 397 | #### Component Section 398 | 399 | Sometimes, one wants to exclude special components such as tests or benchmarks from dependency tracking. 400 | The `component` section is exactly for this purpose. 401 | It consists of the following optional fields: 402 | 403 | | Field | Type | Description | 404 | | ----- | ---- | ----------- | 405 | | `tests` | `Bool` | _Optional_. If `true`, tracks tests (default: `true`). | 406 | | `benchmarks` | `Bool` | _Optional_. If `true`, tracks benchmarks (default: `true`). | 407 | 408 | The `component` section itself can be omitted - in such cases, all the tests and benchmarks will be tracked for dependency. 409 | 410 | #### Cabal specific settings 411 | 412 | Configurations specified to `cabal-install` backend can be specified in `cabal` top-level section. 413 | 414 | It has the following fields: 415 | 416 | | Field | Type | Description | 417 | | ----- | ---- | ----------- | 418 | | `projectFile` | `FilePath` | _Optional_. The path of the cabal project file relative to the project root (default: `cabal.project`). | 419 | | `update` | `Bool` or `String` | _Optional_. If `true`, run (the equivalent of) `cabal update` before dependency checking. If a non-empty string is given, it will be treated as an `index-state` string and passed to the `update` command. (default: `false`) | 420 | 421 | Example: 422 | 423 | ```yaml 424 | cabal: 425 | projectFile: cabal-custom.project 426 | update: true 427 | ``` 428 | 429 | Example with index-state: 430 | 431 | ```yaml 432 | cabal: 433 | projectFile: cabal-custom.project 434 | update: "hackage.haskell.org,2023-02-03T00:00:00Z" 435 | ``` 436 | 437 | #### Stack specific settings 438 | 439 | Stack-specific options are specified in the `stack` top-level section. 440 | For the time being, it only has the `options` field, which is a possibly empty list of options to be passed to the `stack` command. 441 | It can be used, for example, to specify the custom `stack.yaml` file as follows: 442 | 443 | ```yaml 444 | stack: 445 | options: 446 | - "--stack-yaml=stack-test.yaml" 447 | ``` 448 | 449 | If the `stack` section is omitted, the `options` will be treated as empty. 450 | 451 | #### Custom adapter settings 452 | 453 | This is the most general adapter: it reads a dependency graph from STDOUT of an external process. 454 | This adapter allows you to enforce dependency boundary constraints for _any entity in any language_, provided that there is an external program that emits dependency graph (currently in [Dot][dot-lang] language). 455 | 456 | [dot-lang]: https://graphviz.org/doc/info/lang.html 457 | 458 | For example: 459 | 460 | - You can write custom shell script to emit a package dependency graph for the build system you use. 461 | For example, Bazel provides [the `query --output graph` command][bazel query]. 462 | - You can use [`graphmod`][graphmod] to enforce Haskell *module* dependency domain constraints. 463 | See [`./dependency-domains-graphmod.yaml`](./dependency-domains-graphmod.yaml) for such an example. 464 | 465 | [graphmod]: https://hackage.haskell.org/package/graphmod 466 | [bazel query]: https://bazel.build/query/guide 467 | 468 | The custom adapter sets the following environmental variables when invoking external process: 469 | 470 | | Variable | Description | 471 | | -------- | ----------- | 472 | | `GUARDIAN_ROOT_DIR` | The path to the project root to check dependencies | 473 | | `GUARDIAN_INCLUDE_TESTS` | Set to `1` if `components.tests` is enabeld; otherwise unset. | 474 | | `GUARDIAN_INCLUDE_BENCHMARKS` | Set to `1` if `components.benchmarks` is enabled; otherwise unset. | 475 | 476 | Guardian will parse the standard output of the process as a [Dot][dot-lang] program and regard it as a dependency graph. 477 | 478 | You can configure the custom adapter in the `custom` top-level section in the configuration file. 479 | It has the following fields: 480 | 481 | | Field | Type | Description | 482 | | ----- | ---- | ----------- | 483 | | `program` | `Path` | _Exactly one of `program` or `shell` must be specified_. Specifies the path to the external program to use as an adapter. Beside the environmental variables mentioned above, it passes the project root as the first argument. The path must be point to an executable file and has the right permission. See also `command`. | 484 | | `shell` | `String` | _Exactly one of `program` or `shell` must be specified_. You can specify shell script instead of the path to the program. See also `program`. | 485 | | `ignore_loop` | `Bool` | _Optional_. If `true`, gurdian ignores self-loops in the dependency graph. (default: `false`) | 486 | 487 | An example setting with the `shell` field: 488 | 489 | ```yaml 490 | custom: 491 | shell: | 492 | stack dot --no-external --no-include-base 2>/dev/null 493 | ``` 494 | 495 | An example with the `program` field: 496 | 497 | ```yaml 498 | custom: 499 | program: "./decode-cabal-plan.sh" 500 | ignore_loop: true 501 | ``` 502 | 503 | with `decode-cabal-plan.sh:` 504 | 505 | ```sh 506 | #!/bin/bash 507 | cabal-plan --hide-builtin --hide-global dot 2>/dev/null \ 508 | | sed -r 's/:(test|benchmark|exe):[^\"]+//g; s/-[0-9]+(\.[0-9]+)*//g' 509 | ``` 510 | 511 | An example calling `graphmod`: 512 | 513 | ```yaml 514 | custom: 515 | shell: graphmod --no-cluster 2>/dev/null # --no-cluster is needed to avoid subgraphs 516 | ``` 517 | 518 | ##### Current limitation 519 | 520 | - Only [Dot][dot-lang] format is supported. 521 | - Custom adapter silently ignores subgraphs in a dot file. 522 | 523 | #### Example Configuration 524 | 525 | ```yaml 526 | components: # Specifies whether track test/benchmark dependencies as well: 527 | tests: true 528 | benchmarks: false 529 | 530 | domains: 531 | domain-A: 532 | depends_on: 533 | - common 534 | # Domain CANNOT depend on a separate package directly! 535 | # - package: B3 # Error! 536 | packages: 537 | - A1 538 | - A2 539 | - package: A3 540 | exception: # Exception rules for a particular package. 541 | depends_on: 542 | - C # domain name if a plain string 543 | - package: B3 # You can specify a single package name ONLY in package rule. 544 | domain-B: 545 | depends_on: 546 | - common 547 | packages: 548 | - B1 549 | - B2 550 | - B3 551 | C: 552 | depends_on: 553 | - common 554 | packages: 555 | - C1 556 | common: 557 | depends_on: [] # You MUST specify empty dependency explicitly. 558 | packages: 559 | - mybase 560 | - urbase 561 | ``` 562 | 563 | ## GitHub Actions 564 | 565 | Guardian provides a GitHub Action that can be used in GitHub Workflow. 566 | 567 | Prerequisites: 568 | 569 | - OS running the action must be either Linux or macOS with the following executables in PATH: 570 | + `sha256sum` 571 | + `tar` 572 | + `curl` 573 | + `jq` 574 | - If you are using the Cabal backend and `with-compiler` is specified explicitly, 575 | the corresponding version of GHC must be in the PATH. 576 | 577 | Example workflow: 578 | 579 | ```yaml 580 | check-dependecy-boundary: 581 | name: Checks Dependency Constraint 582 | runs-on: ubuntu-20.04 583 | continue-on-error: true 584 | steps: 585 | - uses: actions/checkout@v3 586 | - uses: haskell-actions/setup@v2 587 | with: 588 | ghc-version: 9.0.2 # Install needed version of ghc 589 | - uses: deepflowinc-oss/guardian/action@v0.4.0.0 590 | name: Check with guardian 591 | with: 592 | backend: cabal # auto, cabal, or stack; auto if omitted 593 | version: 0.4.0.0 # latest if omitted 594 | 595 | ## Specify the following if the project root /= repository root 596 | # target: path/to/project/root 597 | 598 | ## If you are using non-standard name for config file 599 | # config: custom-dependency-domains.yaml 600 | ``` 601 | 602 | ## Contribution 603 | 604 | Please feel free to open an issue, but also please search for existing issues to check if there already is a similar one. 605 | 606 | See [CONTRIBUTING.md][CONTRIBUTING] for more details. 607 | 608 | [CONTRIBUTING]: ./CONTRIBUTING.md 609 | 610 | ## Copyright 611 | 612 | (c) 2021-2023, DeepFlow Inc. 613 | --------------------------------------------------------------------------------