├── .credo.exs ├── .dialyzer_ignore ├── .formatter.exs ├── .github └── workflows │ └── boundary.yaml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── LICENSE ├── README.md ├── demos └── my_system │ ├── .formatter.exs │ ├── .gitignore │ ├── README.md │ ├── config │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ └── test.exs │ ├── lib │ ├── my_system.ex │ ├── my_system │ │ ├── application.ex │ │ └── user.ex │ ├── my_system_web.ex │ ├── my_system_web │ │ ├── channels │ │ │ └── user_socket.ex │ │ ├── controllers │ │ │ └── user_controller.ex │ │ ├── endpoint.ex │ │ ├── gettext.ex │ │ ├── router.ex │ │ ├── templates │ │ │ └── error │ │ │ │ └── index.html.eex │ │ └── views │ │ │ ├── error_helpers.ex │ │ │ └── error_view.ex │ └── some_top_level_module.ex │ ├── mix.exs │ ├── mix.lock │ ├── priv │ └── gettext │ │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ │ └── errors.pot │ └── test │ ├── my_system_web │ └── views │ │ └── error_view_test.exs │ ├── support │ ├── channel_case.ex │ └── conn_case.ex │ └── test_helper.exs ├── images ├── vscode_warning_1.png └── vscode_warning_2.png ├── lib ├── boundary.ex └── boundary │ ├── checker.ex │ ├── definition.ex │ ├── graph.ex │ ├── mix.ex │ └── mix │ ├── classifier.ex │ ├── compiler_state.ex │ ├── tasks │ ├── compile │ │ └── boundary.ex │ ├── ex_doc_groups.ex │ ├── find_external_deps.ex │ ├── spec.ex │ ├── visualize.ex │ └── visualize │ │ ├── funs.ex │ │ └── mods.ex │ └── view.ex ├── mix.exs ├── mix.lock └── test ├── boundary └── graph_test.exs ├── mix └── tasks │ ├── compile │ └── boundary_test.exs │ ├── ex_doc_groups_test.exs │ ├── find_external_deps_test.exs │ ├── spec_test.exs │ ├── visualize │ ├── funs_test.exs │ └── mods_test.exs │ └── visualize_test.exs ├── support ├── compiler_tester.ex └── test_project.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{ 6 | included: ["lib/", "src/", "test/"], 7 | excluded: [~r"/_build/", ~r"/deps/"] 8 | }, 9 | requires: [], 10 | strict: true, 11 | color: true, 12 | checks: [ 13 | # extra enabled checks 14 | {Credo.Check.Readability.Specs, []}, 15 | {Credo.Check.Readability.AliasAs, []}, 16 | 17 | # disabled checks 18 | {Credo.Check.Design.TagTODO, false}, 19 | {Credo.Check.Design.AliasUsage, false}, 20 | {Credo.Check.Readability.MaxLineLength, false} 21 | ] 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.dialyzer_ignore: -------------------------------------------------------------------------------- 1 | # dialyxir bug: https://github.com/jeremyjh/dialyxir/issues/369 2 | Unknown function persistent_term:get/1 3 | Unknown function persistent_term:put/2 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | line_length: 120, 5 | locals_without_parens: [ 6 | gen: :*, 7 | check: :*, 8 | all: :* 9 | ] 10 | ] 11 | -------------------------------------------------------------------------------- /.github/workflows/boundary.yaml: -------------------------------------------------------------------------------- 1 | name: "boundary" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | CACHE_VERSION: v2 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - uses: erlef/setup-elixir@v1 16 | with: 17 | otp-version: 26.0 18 | elixir-version: 1.15.4 19 | 20 | - name: Restore cached deps 21 | uses: actions/cache@v1 22 | with: 23 | path: deps 24 | key: deps-${{ env.CACHE_VERSION }}-${{ github.ref }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 25 | restore-keys: | 26 | deps-${{ env.CACHE_VERSION }}-${{ github.ref }}- 27 | deps-${{ env.CACHE_VERSION }}- 28 | 29 | - name: Restore cached build 30 | uses: actions/cache@v1 31 | with: 32 | path: _build 33 | key: build-${{ env.CACHE_VERSION }}-${{ github.ref }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 34 | restore-keys: | 35 | build-${{ env.CACHE_VERSION }}-${{ github.ref }}- 36 | build-${{ env.CACHE_VERSION }}- 37 | 38 | - run: mix deps.get 39 | 40 | - name: Compile project 41 | run: | 42 | MIX_ENV=test mix compile --warnings-as-errors 43 | MIX_ENV=dev mix compile --warnings-as-errors 44 | MIX_ENV=prod mix compile --warnings-as-error 45 | 46 | - run: mix format --check-formatted 47 | - run: mix test 48 | - run: mix docs 49 | - run: MIX_ENV=test mix credo list 50 | - run: mix dialyzer 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | boundary-*.tar 24 | 25 | /tmp/ 26 | /boundary/ 27 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 26.0 2 | elixir 1.15.4-otp-26 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.10.4 2 | 3 | - Properly ignore aliases in strict boundaries 4 | 5 | # 0.10.3 6 | 7 | - Fixed false positives introduced in 0.10.2 (https://github.com/sasa1977/boundary/issues/64) 8 | 9 | # 0.10.2 10 | 11 | - Ignore implicitly defined modules, such as protocol impls added via `@derive`. 12 | 13 | # 0.10.1 14 | 15 | - Improved compiler performance. On a large project (7k+ files, 480k LOC), the running time is reduced from about 50 seconds to about 1 second. 16 | 17 | # 0.10.0 18 | 19 | - Added the support for ignoring "dirty" xrefs via the `dirty_xrefs` option. See `Boundary` module docs for details. 20 | 21 | # 0.9.4 22 | 23 | - Properly unload the tracer on compilation error. Fixes crashes in ElixirLS. 24 | 25 | # 0.9.3 26 | 27 | - Fix inconsistent behaviour in umbrella/poncho projects. 28 | 29 | # 0.9.2 30 | 31 | - Properly handle sub-boundary exports on mass export 32 | - Improve tracer performance 33 | 34 | # 0.9.1 35 | 36 | - Remove unwanted cross-module deps 37 | 38 | # 0.9.0 39 | 40 | - Support exporting modules of sub-boundaries. 41 | 42 | ## Bugfixes 43 | 44 | - Properly handle `:strict` scope in sub-boundaries. 45 | - Remove compilation warnings when the recompiled module has no external calls 46 | - Allow references to protocol implementations from externals 47 | - Fix a compilation crash 48 | 49 | # 0.8.0 50 | 51 | - Reports forbidden struct expansions (`%Foo{...}`) 52 | - Optionally reports alias references (e.g. `Foo`, `apply(Foo, ...)`). This check is by default disabled, but can be enabled globally or per-boundary with the option `check: [aliases: true]`. 53 | 54 | # 0.7.1 55 | 56 | - fixes a bug which prevented the project from compiling on a named node 57 | 58 | # 0.7.0 59 | 60 | ## New 61 | 62 | - added two mix task `boundary.visualize.mods` and `boundary.visualize.funs` that can help visualizing cross-module and in-module dependencies. 63 | 64 | # 0.6.1 65 | 66 | - relax Elixir requirement 67 | 68 | # 0.6.0 69 | 70 | ## Breaking 71 | 72 | - The `:externals_mode` option is renamed to `type`. 73 | - The `:extra_externals` option is dropped, use `check: [apps: list_of_apps]` instead. 74 | - Global boundary options are now specified via `boundary: [default: default_opts]` in mix project config. 75 | - Diagrams produces by `mix boundary.visualize` won't include boundaries from external apps. 76 | - Non-strict sub-boundaries implicitly inherit ancestors deps. 77 | 78 | ## Deprecated 79 | 80 | - `ignore?: true` is deprecated in favour of `check: [in: false, out: false]`. 81 | 82 | ## New 83 | 84 | - Added `boundary.ex_doc_groups` mix task for generating ex_doc groups for defined boundaries. 85 | - Better support for finer-grained ignoring with `check: [in: boolean, out: boolean]`. 86 | - Support for global default externals checks with `boundary: [default: [check: [apps: apps]]]`. 87 | 88 | # 0.5.0 89 | 90 | - Support sub-boundaries ([docs](https://hexdocs.pm/boundary/Boundary.html#module-nested-boundaries)) 91 | - Support mass export ([docs](https://hexdocs.pm/boundary/Boundary.html#module-mass-exports)) 92 | - New boundary.visualize mix task which generates a graphiviz dot file for each non-empty boundary 93 | - Eliminated compile-time dependencies to deps and exports. 94 | 95 | # 0.4.4 96 | 97 | - Fixed app config bug related to unloading 98 | 99 | # 0.4.3 100 | 101 | - Fixed a few bugs which were noticed in ElixirLS 102 | - Expanded cache to further reduce check time 103 | 104 | # 0.4.2 105 | 106 | - Remote calls from macro are treated as compile-time calls. 107 | 108 | # 0.4.1 109 | 110 | - Fixes false positive report of an unknown external boundary. 111 | 112 | # 0.4.0 113 | 114 | - Support for permitting dep to be used only at compile time via `deps: [{SomeDep, :compile}]`. 115 | - Support for alias-like grouping (e.g. `deps: [Ecto.{Changeset, Query}]`) 116 | - The boundary compiler now caches boundaries from external dependencies, which significantly reduces check duration in the cases where the client app doesn't need to be fully recompiled. 117 | 118 | # 0.3.2 119 | 120 | - Eliminates duplicate warnings 121 | 122 | # 0.3.1 123 | 124 | - Fixed app loading bug which led to some dependencies being missed. 125 | 126 | # 0.3.0 127 | 128 | - Added support for controlling the usage of external deps. If external dep defines boundaries, the client app can refer to those boundaries. Otherwise, the client app can define implicit boundaries. See [docs](https://hexdocs.pm/boundary/Boundary.html#module-external-dependencies) for details. 129 | - Added `boundary.spec` and `boundary.find_external_deps` mix tasks. 130 | - Manual classification via the `:classify_to` option is now also allowed for mix tasks. 131 | - Stabilized memory usage, reduced disk usage and analysis time. Boundary is still not thoroughly optimized, but it should behave better in larger projects. 132 | - Boundary database files are now stored in the [manifest path](https://hexdocs.pm/mix/Mix.Project.html#manifest_path/1). Previously they were stored in apps `ebin` which means they would be also included in the OTP release. 133 | 134 | # 0.2.0 135 | 136 | - **[Breaking]** Requires Elixir 1.10 or higher 137 | - **[Breaking]** The `:boundary` compiler should be listed first 138 | - Uses [compilation tracers](https://hexdocs.pm/elixir/Code.html#module-compilation-tracers) instead of mix xref to collect usage 139 | 140 | # 0.1.0 141 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019, Saša Jurić 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Boundary 2 | 3 | [![hex.pm](https://img.shields.io/hexpm/v/boundary.svg?style=flat-square)](https://hex.pm/packages/boundary) 4 | [![hexdocs.pm](https://img.shields.io/badge/docs-latest-green.svg?style=flat-square)](https://hexdocs.pm/boundary/) 5 | ![Build Status](https://github.com/sasa1977/boundary/workflows/boundary/badge.svg) 6 | 7 | Boundary is a library which helps managing and restraining cross-module dependencies in Elixir projects. A few examples of the things you can do with boundary include: 8 | 9 | - Prevent invocations from the context layer to the web layer 10 | - Prevent invocations from the web layer to internal context modules 11 | - Prevent usage of Phoenix and Plug in the context layer 12 | - Limit usage of Ecto in the web layer to only Ecto.Changeset 13 | - Allow `:mix` modules to be used only at compile time 14 | 15 | ## Status 16 | 17 | This library has been used in smaller production projects for about a year, and while it has not been tested on larger projects or umbrella projects, no issues are expected in those cases. That being said, if you do run into problems, please open an issue! 18 | 19 | ## Documentation 20 | 21 | For a detailed reference see docs for [Boundary module](https://hexdocs.pm/boundary/Boundary.html) and [mix compiler](https://hexdocs.pm/boundary/Mix.Tasks.Compile.Boundary.html). 22 | 23 | ## Basic usage 24 | 25 | To use this library, you first need to define the boundaries of your project. A __boundary__ is a named group of one or more modules. Each boundary exports some (but not all!) of its modules, and can depend on other boundaries. During compilation, the boundary compiler will find and report all cross-module function calls which are not permitted according to the boundary configuration. 26 | 27 | ### Example 28 | 29 | Add boundary as a dependency in mix.exs: 30 | 31 | ```elixir 32 | defmodule MySystem.MixProject do 33 | use Mix.Project 34 | 35 | # ... 36 | 37 | defp deps do 38 | [ 39 | {:boundary, "~> 0.10", runtime: false}, 40 | # ... 41 | ] 42 | end 43 | 44 | # ... 45 | end 46 | ``` 47 | 48 | The following code defines boundaries for a typical Phoenix based project generated with `mix phx.new`. 49 | 50 | ```elixir 51 | defmodule MySystem do 52 | use Boundary, deps: [], exports: [] 53 | # ... 54 | end 55 | 56 | defmodule MySystemWeb do 57 | use Boundary, deps: [MySystem], exports: [Endpoint] 58 | # ... 59 | end 60 | 61 | defmodule MySystem.Application do 62 | use Boundary, top_level?: true, deps: [MySystem, MySystemWeb] 63 | # ... 64 | end 65 | ``` 66 | 67 | The configuration above defines three boundaries: `MySystem`, `MySystemWeb`, and `MySystem.Application`. 68 | 69 | Boundary modules are determined automatically from the boundary name. For example, the `MySystem` boundary contains the `MySystem` module, as well as any module whose name starts with `MySystem.` (e.g. `MySystem.User`, `MySystem.User.Schema`, ...). 70 | 71 | The configuration specifies the following rules: 72 | 73 | - Modules residing in the `MySystemWeb` boundary are allowed to invoke functions from modules exported by the `MySystem` boundary. 74 | - Modules residing in the `MySystem.Application` namespace are allowed to invoke functions from modules exported by `MySystem` and `MySystemWeb` boundaries. 75 | 76 | All other cross-boundary calls are not permitted. 77 | 78 | Next, you need to add the mix compiler: 79 | 80 | ```elixir 81 | defmodule MySystem.MixProject do 82 | use Mix.Project 83 | 84 | def project do 85 | [ 86 | compilers: [:boundary] ++ Mix.compilers(), 87 | # ... 88 | ] 89 | end 90 | 91 | # ... 92 | end 93 | ``` 94 | 95 | Boundary rules are validated during compilation. For example, if we have the following code: 96 | 97 | ```elixir 98 | defmodule MySystem.User do 99 | def auth do 100 | MySystemWeb.Endpoint.url() 101 | end 102 | end 103 | 104 | ``` 105 | 106 | The compiler will emit a warning: 107 | 108 | ``` 109 | $ mix compile 110 | 111 | warning: forbidden reference to MySystemWeb 112 | (references from MySystem to MySystemWeb are not allowed) 113 | lib/my_system/user.ex:3 114 | ``` 115 | 116 | The complete working example is available [here](demos/my_system). 117 | 118 | Because `boundary` is implemented as a mix compiler, it integrates seamlessly with editors which can work with mix compiler. For example, in VS Code with [Elixir LS](https://github.com/elixir-lsp/elixir-ls): 119 | 120 | ![VS Code warning 1](images/vscode_warning_1.png) 121 | 122 | ![VS Code warning 2](images/vscode_warning_2.png) 123 | 124 | 125 | ### Restricting usage of external apps 126 | 127 | Boundary can also be used to manage calls to other apps, even if those apps don't define their own boundaries. For example, suppose you want to enforce the following rules: 128 | 129 | - Only `MySystemWeb` can use phoenix modules 130 | - Only `MySystem` can use ecto modules, except for Ecto.Changeset which can be used by `MySystemWeb` too 131 | - Only `MySystemMix` can use mix modules at runtime. Everyone can use mix modules at compile time. 132 | 133 | By default, boundary doesn't check calls to other apps. However, we can instruct it to check calls to the desired apps. This setting can be provided for each individual boundary, or globally. Since we want to restrict calls to these boundaries in the entire project, let's do this globally in mix.exs: 134 | 135 | ```elixir 136 | # mix.exs 137 | 138 | defmodule MySystem.MixProject do 139 | def project do 140 | [ 141 | boundary: [ 142 | default: [ 143 | check: [ 144 | apps: [:phoenix, :ecto, {:mix, :runtime}] 145 | ] 146 | ] 147 | ], 148 | # ... 149 | ] 150 | end 151 | end 152 | ``` 153 | 154 | With these settings, boundary will check all calls to phoenix and ecto, and all runtime calls to mix. Compile-time calls to mix won't be checked. 155 | 156 | Now we need to allow the calls to these apps in our boundaries: 157 | 158 | ```elixir 159 | defmodule MySystemWeb do 160 | use Boundary, deps: [Phoenix, Ecto.Changeset] 161 | end 162 | 163 | defmodule MySystem do 164 | use Boundary, deps: [Ecto, Ecto.Changeset] 165 | end 166 | 167 | defmodule MySystemMix do 168 | use Boundary, deps: [Mix] 169 | end 170 | ``` 171 | 172 | Note that in `MySystem` we're specifying both `Ecto` and `Ecto.Changeset`. This is because `Ecto.Changeset` is listed as a dep in `MySystemWeb`, and so it is treated as a separate boundary. 173 | 174 | 175 | ## Roadmap 176 | 177 | - [x] validate calls to external deps (e.g. preventing `Ecto` usage from `MySystemWeb`, or `Plug` usage from `MySystem`) 178 | - [x] support compile time vs runtime deps 179 | - [x] support nested boundaries (defining internal boundaries within a boundary) 180 | - [ ] support Erlang modules 181 | 182 | ## License 183 | 184 | [MIT](LICENSE) 185 | -------------------------------------------------------------------------------- /demos/my_system/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /demos/my_system/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | my_system-*.tar 24 | 25 | # Since we are building assets from assets/, 26 | # we ignore priv/static. You may want to comment 27 | # this depending on your deployment strategy. 28 | /priv/static/ 29 | 30 | # Files matching config/*.secret.exs pattern contain sensitive 31 | # data and you should not commit them into version control. 32 | # 33 | # Alternatively, you may comment the line below and commit the 34 | # secrets files as long as you replace their contents by environment 35 | # variables. 36 | /config/*.secret.exs 37 | -------------------------------------------------------------------------------- /demos/my_system/README.md: -------------------------------------------------------------------------------- 1 | # MySystem 2 | 3 | To start your Phoenix server: 4 | 5 | * Install dependencies with `mix deps.get` 6 | * Start Phoenix endpoint with `mix phx.server` 7 | 8 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 9 | 10 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 11 | 12 | ## Learn more 13 | 14 | * Official website: http://www.phoenixframework.org/ 15 | * Guides: https://hexdocs.pm/phoenix/overview.html 16 | * Docs: https://hexdocs.pm/phoenix 17 | * Mailing list: http://groups.google.com/group/phoenix-talk 18 | * Source: https://github.com/phoenixframework/phoenix 19 | -------------------------------------------------------------------------------- /demos/my_system/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | use Mix.Config 9 | 10 | # Configures the endpoint 11 | config :my_system, MySystemWeb.Endpoint, 12 | url: [host: "localhost"], 13 | secret_key_base: "HR6cDTs5SmM9sN6rZ7lgRyJHxJ51P2xVk6NkMx64/FPBLEeAfL/BRchvcy3VdEZx", 14 | render_errors: [view: MySystemWeb.ErrorView, accepts: ~w(json)], 15 | pubsub: [name: MySystem.PubSub, adapter: Phoenix.PubSub.PG2] 16 | 17 | # Configures Elixir's Logger 18 | config :logger, :console, 19 | format: "$time $metadata[$level] $message\n", 20 | metadata: [:request_id] 21 | 22 | # Use Jason for JSON parsing in Phoenix 23 | config :phoenix, :json_library, Jason 24 | 25 | # Import environment specific config. This must remain at the bottom 26 | # of this file so it overrides the configuration defined above. 27 | import_config "#{Mix.env()}.exs" 28 | -------------------------------------------------------------------------------- /demos/my_system/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with webpack to recompile .js and .css sources. 9 | config :my_system, MySystemWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [] 15 | 16 | # ## SSL Support 17 | # 18 | # In order to use HTTPS in development, a self-signed 19 | # certificate can be generated by running the following 20 | # Mix task: 21 | # 22 | # mix phx.gen.cert 23 | # 24 | # Note that this task requires Erlang/OTP 20 or later. 25 | # Run `mix help phx.gen.cert` for more information. 26 | # 27 | # The `http:` config above can be replaced with: 28 | # 29 | # https: [ 30 | # port: 4001, 31 | # cipher_suite: :strong, 32 | # keyfile: "priv/cert/selfsigned_key.pem", 33 | # certfile: "priv/cert/selfsigned.pem" 34 | # ], 35 | # 36 | # If desired, both `http:` and `https:` keys can be 37 | # configured to run both http and https servers on 38 | # different ports. 39 | 40 | # Do not include metadata nor timestamps in development logs 41 | config :logger, :console, format: "[$level] $message\n" 42 | 43 | # Set a higher stacktrace during development. Avoid configuring such 44 | # in production as building large stacktraces may be expensive. 45 | config :phoenix, :stacktrace_depth, 20 46 | 47 | # Initialize plugs at runtime for faster development compilation 48 | config :phoenix, :plug_init_mode, :runtime 49 | -------------------------------------------------------------------------------- /demos/my_system/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :my_system, MySystemWeb.Endpoint, 13 | http: [:inet6, port: System.get_env("PORT") || 4000], 14 | url: [host: "example.com", port: 80], 15 | cache_static_manifest: "priv/static/cache_manifest.json" 16 | 17 | # Do not print debug messages in production 18 | config :logger, level: :info 19 | 20 | # ## SSL Support 21 | # 22 | # To get SSL working, you will need to add the `https` key 23 | # to the previous section and set your `:url` port to 443: 24 | # 25 | # config :my_system, MySystemWeb.Endpoint, 26 | # ... 27 | # url: [host: "example.com", port: 443], 28 | # https: [ 29 | # :inet6, 30 | # port: 443, 31 | # cipher_suite: :strong, 32 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 33 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 34 | # ] 35 | # 36 | # The `cipher_suite` is set to `:strong` to support only the 37 | # latest and more secure SSL ciphers. This means old browsers 38 | # and clients may not be supported. You can set it to 39 | # `:compatible` for wider support. 40 | # 41 | # `:keyfile` and `:certfile` expect an absolute path to the key 42 | # and cert in disk or a relative path inside priv, for example 43 | # "priv/ssl/server.key". For all supported SSL configuration 44 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 45 | # 46 | # We also recommend setting `force_ssl` in your endpoint, ensuring 47 | # no data is ever sent via http, always redirecting to https: 48 | # 49 | # config :my_system, MySystemWeb.Endpoint, 50 | # force_ssl: [hsts: true] 51 | # 52 | # Check `Plug.SSL` for all available options in `force_ssl`. 53 | 54 | # ## Using releases (distillery) 55 | # 56 | # If you are doing OTP releases, you need to instruct Phoenix 57 | # to start the server for all endpoints: 58 | # 59 | # config :phoenix, :serve_endpoints, true 60 | # 61 | # Alternatively, you can configure exactly which server to 62 | # start per endpoint: 63 | # 64 | # config :my_system, MySystemWeb.Endpoint, server: true 65 | # 66 | # Note you can't rely on `System.get_env/1` when using releases. 67 | # See the releases documentation accordingly. 68 | 69 | # Finally import the config/prod.secret.exs which should be versioned 70 | # separately. 71 | import_config "prod.secret.exs" 72 | -------------------------------------------------------------------------------- /demos/my_system/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :my_system, MySystemWeb.Endpoint, 6 | http: [port: 4002], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystem do 2 | use Boundary, 3 | exports: [User], 4 | deps: [] 5 | 6 | Mix.env() 7 | 8 | def foo() do 9 | Mix.env() 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system/application.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystem.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Boundary, top_level?: true, deps: [MySystem, MySystemWeb] 7 | use Application 8 | 9 | def start(_type, _args) do 10 | # List all child processes to be supervised 11 | children = [ 12 | # Start the endpoint when the application starts 13 | MySystemWeb.Endpoint 14 | # Starts a worker by calling: MySystem.Worker.start_link(arg) 15 | # {MySystem.Worker, arg}, 16 | ] 17 | 18 | # See https://hexdocs.pm/elixir/Supervisor.html 19 | # for other strategies and supported options 20 | opts = [strategy: :one_for_one, name: MySystem.Supervisor] 21 | Supervisor.start_link(children, opts) 22 | end 23 | 24 | # Tell Phoenix to update the endpoint configuration 25 | # whenever the application is updated. 26 | def config_change(changed, _new, removed) do 27 | MySystemWeb.Endpoint.config_change(changed, removed) 28 | :ok 29 | end 30 | 31 | def foo(), do: :ok 32 | end 33 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system/user.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystem.User do 2 | def auth do 3 | MySystemWeb.Endpoint.url() 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system_web.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystemWeb do 2 | use Boundary, 3 | exports: [Endpoint], 4 | deps: [MySystem, Ecto.Changeset] 5 | 6 | def controller do 7 | quote do 8 | use Phoenix.Controller, namespace: MySystemWeb 9 | 10 | import Plug.Conn 11 | import MySystemWeb.Gettext 12 | alias MySystemWeb.Router.Helpers, as: Routes 13 | end 14 | end 15 | 16 | def view do 17 | quote do 18 | use Phoenix.View, 19 | root: "lib/my_system_web/templates", 20 | namespace: MySystemWeb 21 | 22 | # Import convenience functions from controllers 23 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1] 24 | 25 | import MySystemWeb.ErrorHelpers 26 | import MySystemWeb.Gettext 27 | alias MySystemWeb.Router.Helpers, as: Routes 28 | end 29 | end 30 | 31 | def router do 32 | quote do 33 | use Phoenix.Router 34 | import Plug.Conn 35 | import Phoenix.Controller 36 | end 37 | end 38 | 39 | def channel do 40 | quote do 41 | use Phoenix.Channel 42 | import MySystemWeb.Gettext 43 | end 44 | end 45 | 46 | @doc """ 47 | When used, dispatch to the appropriate controller/view/etc. 48 | """ 49 | defmacro __using__(which) when is_atom(which) do 50 | apply(__MODULE__, which, []) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystemWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", MySystemWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | def connect(_params, socket, _connect_info) do 19 | {:ok, socket} 20 | end 21 | 22 | # Socket id's are topics that allow you to identify all sockets for a given user: 23 | # 24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 25 | # 26 | # Would allow you to broadcast a "disconnect" event and terminate 27 | # all active sockets and channels for a given user: 28 | # 29 | # MySystemWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 30 | # 31 | # Returning `nil` makes this socket anonymous. 32 | def id(_socket), do: nil 33 | end 34 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system_web/controllers/user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystemWeb.UserController do 2 | import Ecto.Query 3 | 4 | def some_action() do 5 | Ecto.Changeset.cast(%{}, %{}, []) 6 | from(s in "foo", select: "bar") 7 | MySystem.User.auth() 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystemWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :my_system 3 | 4 | socket "/socket", MySystemWeb.UserSocket, 5 | websocket: true, 6 | longpoll: false 7 | 8 | # Serve at "/" the static files from "priv/static" directory. 9 | # 10 | # You should set gzip to true if you are running phx.digest 11 | # when deploying your static files in production. 12 | plug Plug.Static, 13 | at: "/", 14 | from: :my_system, 15 | gzip: false, 16 | only: ~w(css fonts images js favicon.ico robots.txt) 17 | 18 | # Code reloading can be explicitly enabled under the 19 | # :code_reloader configuration of your endpoint. 20 | if code_reloading? do 21 | plug Phoenix.CodeReloader 22 | end 23 | 24 | plug Plug.RequestId 25 | plug Plug.Logger 26 | 27 | plug Plug.Parsers, 28 | parsers: [:urlencoded, :multipart, :json], 29 | pass: ["*/*"], 30 | json_decoder: Phoenix.json_library() 31 | 32 | plug Plug.MethodOverride 33 | plug Plug.Head 34 | 35 | # The session will be stored in the cookie and signed, 36 | # this means its contents can be read but not tampered with. 37 | # Set :encryption_salt if you would also like to encrypt it. 38 | plug Plug.Session, 39 | store: :cookie, 40 | key: "_my_system_key", 41 | signing_salt: "Wr/1dx0I" 42 | 43 | plug MySystemWeb.Router 44 | end 45 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystemWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import MySystemWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :my_system 24 | end 25 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystemWeb.Router do 2 | use MySystemWeb, :router 3 | 4 | pipeline :api do 5 | plug :accepts, ["json"] 6 | end 7 | 8 | scope "/api", MySystemWeb do 9 | pipe_through :api 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system_web/templates/error/index.html.eex: -------------------------------------------------------------------------------- 1 | <%= MySystem.Application.foo() %> 2 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystemWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | @doc """ 7 | Translates an error message using gettext. 8 | """ 9 | def translate_error({msg, opts}) do 10 | # When using gettext, we typically pass the strings we want 11 | # to translate as a static argument: 12 | # 13 | # # Translate "is invalid" in the "errors" domain 14 | # dgettext("errors", "is invalid") 15 | # 16 | # # Translate the number of files with plural rules 17 | # dngettext("errors", "1 file", "%{count} files", count) 18 | # 19 | # Because the error messages we show in our forms and APIs 20 | # are defined inside Ecto, we need to translate them dynamically. 21 | # This requires us to call the Gettext module passing our gettext 22 | # backend as first argument. 23 | # 24 | # Note we use the "errors" domain, which means translations 25 | # should be written to the errors.po file. The :count option is 26 | # set by Ecto and indicates we should also apply plural rules. 27 | if count = opts[:count] do 28 | Gettext.dngettext(MySystemWeb.Gettext, "errors", msg, msg, count, opts) 29 | else 30 | Gettext.dgettext(MySystemWeb.Gettext, "errors", msg, opts) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /demos/my_system/lib/my_system_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystemWeb.ErrorView do 2 | use MySystemWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.json", _assigns) do 7 | # %{errors: %{detail: "Internal Server Error"}} 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.json" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /demos/my_system/lib/some_top_level_module.ex: -------------------------------------------------------------------------------- 1 | defmodule SomeTopLevelModule do 2 | use Boundary, check: [in: false, out: false] 3 | 4 | def foo do 5 | MySystemWeb.Endpoint.url() 6 | MySystem.User.auth() 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /demos/my_system/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule MySystem.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :my_system, 7 | version: "0.1.0", 8 | elixir: "~> 1.10", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:boundary, :phoenix] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {MySystem.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:phoenix, "~> 1.4.0"}, 36 | {:phoenix_pubsub, "~> 1.1"}, 37 | {:phoenix_html, "~> 2.13"}, 38 | {:gettext, "~> 0.11"}, 39 | {:jason, "~> 1.0"}, 40 | {:plug_cowboy, "~> 2.0"}, 41 | {:ecto, "~> 3.0"}, 42 | {:boundary, path: "../..", runtime: false} 43 | ] 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /demos/my_system/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, 3 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 4 | "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, 5 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 6 | "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, 7 | "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, 8 | "gettext": {:hex, :gettext, "0.23.1", "821e619a240e6000db2fc16a574ef68b3bd7fe0167ccc264a81563cc93e67a31", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "19d744a36b809d810d610b57c27b934425859d158ebd56561bc41f7eeb8795db"}, 9 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 10 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 11 | "phoenix": {:hex, :phoenix, "1.4.6", "8535f4a01291f0fbc2c30c78c4ca6a2eacc148db5178ad76e8b2fc976c590115", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "17aa6f4909e41eebfa7589b61c71f0ebe8fdea997194fd85596e629bbf8d3e15"}, 12 | "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"}, 13 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"}, 14 | "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, 15 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, 16 | "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, 17 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 18 | "stream_data": {:hex, :stream_data, "0.4.2", "fa86b78c88ec4eaa482c0891350fcc23f19a79059a687760ddcf8680aac2799b", [:mix], [], "hexpm"}, 19 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 20 | } 21 | -------------------------------------------------------------------------------- /demos/my_system/priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | -------------------------------------------------------------------------------- /demos/my_system/priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | -------------------------------------------------------------------------------- /demos/my_system/test/my_system_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MySystemWeb.ErrorViewTest do 2 | use MySystemWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.json" do 8 | assert render(MySystemWeb.ErrorView, "404.json", []) == %{errors: %{detail: "Not Found"}} 9 | end 10 | 11 | test "renders 500.json" do 12 | assert render(MySystemWeb.ErrorView, "500.json", []) == 13 | %{errors: %{detail: "Internal Server Error"}} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /demos/my_system/test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystemWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint MySystemWeb.Endpoint 25 | end 26 | end 27 | 28 | setup _tags do 29 | :ok 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /demos/my_system/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MySystemWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | alias MySystemWeb.Router.Helpers, as: Routes 23 | 24 | # The default endpoint for testing 25 | @endpoint MySystemWeb.Endpoint 26 | end 27 | end 28 | 29 | setup _tags do 30 | {:ok, conn: Phoenix.ConnTest.build_conn()} 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /demos/my_system/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /images/vscode_warning_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasa1977/boundary/5631505264992ac532e45674de7ea0fa8208ab99/images/vscode_warning_1.png -------------------------------------------------------------------------------- /images/vscode_warning_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasa1977/boundary/5631505264992ac532e45674de7ea0fa8208ab99/images/vscode_warning_2.png -------------------------------------------------------------------------------- /lib/boundary.ex: -------------------------------------------------------------------------------- 1 | defmodule Boundary do 2 | @moduledoc """ 3 | Definition of boundaries within the application. 4 | 5 | A boundary is a named group of modules which can export some of its modules, and depend on other 6 | boundaries. 7 | 8 | Boundary definitions can be used in combination with `Mix.Tasks.Compile.Boundary` to restrain 9 | cross-module dependencies. A few examples of what you can do with boundary include: 10 | 11 | - Prevent invocations from the context layer to the web layer 12 | - Prevent invocations from the web layer to internal context modules 13 | - Prevent usage of Phoenix and Plug in the context layer 14 | - Limit usage of Ecto in the web layer to only Ecto.Changeset 15 | - Allow `:mix` modules to be used only at compile time 16 | 17 | ## Quick example 18 | 19 | The following code defines boundaries for a typical Phoenix based project generated with 20 | `mix phx.new`. 21 | 22 | ``` 23 | defmodule MySystem do 24 | use Boundary, deps: [], exports: [] 25 | # ... 26 | end 27 | 28 | defmodule MySystemWeb do 29 | use Boundary, deps: [MySystem], exports: [Endpoint] 30 | # ... 31 | end 32 | 33 | defmodule MySystem.Application do 34 | use Boundary, top_level?: true, deps: [MySystem, MySystemWeb] 35 | # ... 36 | end 37 | ``` 38 | 39 | These boundaries specify the allowed cross-boundary usage: 40 | 41 | - Modules from `MySystemWeb` may use the `MySystem` module, but not other `MySystem.*` modules. 42 | - `MySystem.Application` code may use `MySystem`, `MySystemWeb`, and `MySystemWeb.Endpoint` 43 | modules. 44 | 45 | To enforce these rules on project compilation, you need to include the compiler in `mix.exs`: 46 | 47 | ``` 48 | defmodule MySystem.MixProject do 49 | # ... 50 | 51 | def project do 52 | [ 53 | compilers: [:boundary] ++ Mix.compilers(), 54 | # ... 55 | ] 56 | end 57 | 58 | # ... 59 | end 60 | ``` 61 | 62 | See `Mix.Tasks.Compile.Boundary` for more details on compilation warnings. 63 | 64 | 65 | ## Defining a boundary 66 | 67 | A boundary is defined via `use Boundary` expression in the root module. For example, the context 68 | boundary named `MySystem` can be defined as follows: 69 | 70 | ``` 71 | defmodule MySystem do 72 | use Boundary, opts 73 | # ... 74 | end 75 | ``` 76 | 77 | ## Module classification 78 | 79 | Based on the existing definitions, modules are classified into boundaries. Each module can 80 | belong to at most one boundary. A module doesn't need to belong to a boundary, in which case we 81 | say that the module is unclassified. 82 | 83 | Boundary membership is determined from the module name. In the previous example, we defined a 84 | single boundary, called `MySystem`. This boundary will contain the root module (`MySystem`), 85 | as well as all modules whose name starts with `MySystem.`. 86 | 87 | In addition, it's possible to extract some of the modules from a boundary into another boundary. 88 | For example: 89 | 90 | ``` 91 | defmodule MySystem do 92 | use Boundary, opts 93 | end 94 | 95 | defmodule MySystem.Endpoint do 96 | use Boundary, opts 97 | end 98 | ``` 99 | 100 | See the "Nested boundaries" section for details. 101 | 102 | ### Mix tasks 103 | 104 | By convention, mix tasks have to reside in the `Mix.Tasks` namespace, which makes it harder to 105 | put them under the same boundary. To assist with this, boundary supports manual reclassification 106 | of such modules. 107 | 108 | The advised approach is to introduce the `MySystem.Mix` boundary which can hold helper functions 109 | required by the mix tasks. With such boundary in place, you can manually classify mix tasks as: 110 | 111 | ``` 112 | defmodule Mix.Tasks.SomeTask do 113 | use Boundary, classify_to: MySystem.Mix 114 | use Mix.Task 115 | end 116 | 117 | defmodule Mix.Tasks.AnotherTask do 118 | use Boundary, classify_to: MySystem.Mix 119 | use Mix.Task 120 | end 121 | ``` 122 | 123 | This way, both modules will be considered as a part of the `MySystem.Mix` boundary. 124 | 125 | Note that manual classification is allowed only for mix tasks and protocol implementations (see 126 | the following section). 127 | 128 | ### Protocol implementation 129 | 130 | Consider the following protocol implementation: 131 | 132 | ``` 133 | defimpl String.Chars, for: MySchema, do: # ... 134 | ``` 135 | 136 | This code will generate the module `String.Chars.MySchema`. Therefore, the module sits in a 137 | completely different "namespace". In addition, the desired boundary of such module can vary from 138 | one case to another. In some cases, a protocol implementation might be a UI concern, while in 139 | others, it might be a domain concern. 140 | 141 | For these reasons, calls from a protocol implementation are by default not checked. However, you 142 | can manually classify the protocol implementation, as follows: 143 | 144 | ``` 145 | defimpl String.Chars, for: MySchema do 146 | use Boundary, classify_to: MySystem 147 | # ... 148 | end 149 | ``` 150 | 151 | In this case, the protocol implementation is considered to be a part of the `MySystem` boundary, 152 | and the code will be checked for cross-boundary calls. 153 | 154 | 155 | Note that `:classify_to` option is only allowed for protocol implementations and mix tasks. 156 | Other modules can't be manually classified. 157 | 158 | ## Exports 159 | 160 | Exports are boundary modules which can be used by modules from other boundaries. A boundary 161 | always exports its root module, and it may additionally export other modules, which can be 162 | configured with the `:exports` option: 163 | 164 | ``` 165 | defmodule MySystem do 166 | use Boundary, exports: [User] 167 | end 168 | ``` 169 | 170 | In this example, we're defining the `MySystem` boundary which exports the modules `MySystem` 171 | and `MySystem.User`. All other modules of this boundary are considered to be internal, and 172 | they may not be used by modules from other boundaries. 173 | 174 | ### Mass exports 175 | 176 | It's also possible to mass-export multiple modules with a single exports entry. 177 | 178 | For example, let's say that we keep Ecto schemas under the `MySystem.Schemas` namespace. Now we 179 | want to export all of these modules except `MySystem.Schemas.Base` which is a base module used 180 | by our schemas. We could list each individual schema in the exports section but that becomes 181 | tedious, and the `use Boundary` expression might become quite long and noisy. Instead, we can 182 | export all of these modules with the `exports: [{Schemas, except: [Base]}, ...]`. This will 183 | export all `MySystem.Schemas.*` modules, except for `MySystem.Schemas.Base`. 184 | 185 | You can also export all modules of the boundary with `use Boundary, exports: :all`. This will also 186 | export all sub-modules of the boundary. To exclude some modules from the export list use, 187 | `use Boundary, exports: {:all, except: [SomeMod, ...]}`. 188 | 189 | Mass export is not advised in most situations. Prefer explicitly listing exported modules. If 190 | your export list is long, it's a possible indication of an overly fragmented interface. Consider 191 | instead consolidating the interface in the main boundary module, which would act as a facade. 192 | Alternatively, perhaps the boundary needs to be split. 193 | 194 | However, cases such as Ecto schemas present a valid exception, since these modules are typically 195 | a part of the public context interface, since they are passed back and forth between the 196 | context and the interface layer (such as web). 197 | 198 | 199 | ## Dependencies 200 | 201 | Each boundary may depend on other boundaries. These dependencies are used to define allowed 202 | cross-boundary module usage. A module from another boundary may only be used if: 203 | 204 | - The callee boundary is a direct dependency of the caller boundary. 205 | - The callee boundary exports the used module. 206 | 207 | For example: 208 | 209 | ``` 210 | defmodule MySystem do 211 | use Boundary, exports: [User], deps: [] 212 | end 213 | 214 | defmodule MySystemWeb do 215 | use Boundary, exports: [], deps: [MySystem] 216 | end 217 | ``` 218 | 219 | In this example we specify the following rules: 220 | 221 | - Code from the `MySystem` boundary can't use any module from other boundaries. 222 | - Code from the `MySystemWeb` boundary may use exports of the `MySystem` boundary 223 | (`MySystem` and `MySystem.User`). 224 | 225 | Of course, in-boundary cross-module dependencies are always allowed (any module may use all 226 | other modules from the same boundary). 227 | 228 | When listing deps and exports, a "grouping" syntax can also be used: 229 | 230 | ``` 231 | use Boundary, deps: [Foo.{Bar, Baz}] 232 | ``` 233 | 234 | ### External dependencies 235 | 236 | By default, all dependencies on modules from other OTP applications are permitted. However, you can restrain such 237 | dependencies by including boundaries from the external application. For example, let's say you want to limit the ecto 238 | usage in the web tier to only `Ecto.Changeset`. This can be specified as follows: 239 | 240 | ``` 241 | defmodule MySystemWeb do 242 | use Boundary, deps: [Ecto.Changeset] 243 | end 244 | ``` 245 | 246 | Boundary is able to use boundary definitions from an external application, if such exists. If an external application 247 | doesn't define any boundary, you can still reference application modules. In such case, you're creating an _implicit 248 | boundary_. This is exactly what we're doing in the previous example. Ecto doesn't define its own boundaries, but we 249 | can still include `Ecto.Changeset` in the deps list. This will create an implicit boundary of the same name which will 250 | include all of the submodules like `Ecto.Changeset.Foo`, or `Ecto.Changeset.Bar.Baz`. An implicit boundary exports all 251 | of its submodules. Note that you can't define implicit boundaries in applications which define their own boundaries. 252 | 253 | The implicit boundaries are collected based on all deps of all boundaries in your application. For example, if one 254 | boundary specifies `Ecto.Query` as a dependency, while another references `Ecto.Query.API`, then two boundaries are 255 | defined, and the latter will not be a part of the former. 256 | 257 | In some cases you may want to completely prohibit the usage of some library. However, bear in mind that by default 258 | calls to an external application are restricted only if the client boundary references at least one dep boundary from 259 | that application. To force boundary to always restrict calls to some application, you can include the application in 260 | the check apps list: 261 | 262 | ``` 263 | defmodule MySystem do 264 | use Boundary, check: [apps: [:plug]], deps: [] 265 | end 266 | ``` 267 | 268 | The check apps list contains additional applications which are always checked. Any calls to given applications must 269 | be explicitly allowed via the `:deps` option. 270 | 271 | The check list can contain an application name (atom), or a `{app_name, call_mode}` tuple, where `call_mode` is either 272 | `:runtime` or `:compile`. If only app name is specified, then both, runtime and compile-time calls will be checked. 273 | 274 | You can also set a list of default apps checked for every boundary in mix.exs: 275 | 276 | ``` 277 | defmodule MySystem.MixProject do 278 | use Mix.Project 279 | 280 | def project do 281 | [ 282 | # ... 283 | boundary: [ 284 | default: [ 285 | check: [apps: [{:mix, :runtime}]] 286 | ] 287 | ] 288 | ] 289 | end 290 | 291 | # ... 292 | end 293 | ``` 294 | 295 | In the example above, we're explicitly checking all runtime mix calls, while compile-time calls won't be checked. 296 | 297 | In addition, you can define boundary as `:strict`: 298 | 299 | ``` 300 | defmodule MySystem do 301 | use Boundary, type: :strict 302 | end 303 | ``` 304 | 305 | With this setting, boundary will report all calls to all external applications which are not explicitly allowed via the 306 | `:dep` option. You can also configure the strict type globally in your mix.exs: 307 | 308 | ``` 309 | defmodule MySystem.MixProject do 310 | use Mix.Project 311 | 312 | def project do 313 | [ 314 | # ... 315 | boundary: [ 316 | default: [ 317 | type: :strict 318 | ] 319 | ] 320 | ] 321 | end 322 | 323 | # ... 324 | end 325 | ``` 326 | 327 | At this point, all boundaries will be checked with the strict mode. If you want to override this for some boundaries, 328 | you can do it with `use Boundary, type: :relaxed`. 329 | 330 | Note that restraining calls to the `:elixir`, `:boundary`, and pure Erlang applications, such as 331 | `:crypto` or `:cowboy`, is currently not possible. 332 | 333 | If you want to discover which external applications are used by your boundaries, you can use the helper mix task 334 | `Mix.Tasks.Boundary.FindExternalDeps`. 335 | 336 | ### Compile-time dependencies 337 | 338 | By default, a dependency allows calls at both, compile time and runtime. In some cases you may want to permit calls to 339 | some dependency only at compile-time. A typical example are modules from the `:mix` application. These modules are 340 | not safe to be used at runtime. Limiting their usage to compile-time only can be done as follows: 341 | 342 | ``` 343 | # option 1: force check all runtime calls to mix 344 | use Boundary, check: [{:mix, :runtime}] 345 | 346 | # option 2: permit `Mix` implicit boundary at compile time only 347 | use Boundary, deps: [{Mix, :compile}] 348 | ``` 349 | 350 | With such configuration, the following calls are allowed: 351 | 352 | - Function invocations at compile time (i.e. outside of any function, or in `unquote(...)`). 353 | - Macro invocations anywhere in the code. 354 | - Any invocations made from a public macro. 355 | 356 | Note that you might have some modules which will require runtime dependency on mix, such as custom mix tasks. It's 357 | advised to group such modules under a common boundary, such as `MySystem.Mix`, and allow `mix` as a runtime 358 | dependency only in that boundary. 359 | 360 | Finally, it's worth noting that it's not possible permitting some dependency only at runtime. If a dependency is 361 | allowed at runtime, then it can also be used at compile time. 362 | 363 | ## Controlling checks 364 | 365 | Occasionally you may need to relax the rules in some parts of the code. 366 | 367 | One typical example is when `boundary` is introduced to the existing, possibly large project, which has many complex 368 | dependencies that can't be untangled trivially. In this case it may be difficult to satisfy all boundary constraints 369 | immediately, and you may want to tolerate some violations. 370 | 371 | Boundary supports two mechanisms for this: dirty xrefs and ignored checks. 372 | 373 | A dirty xref is an invocation to another module that won't be checked by boundary. For example, suppose that in your 374 | context layer you invoke `MySystemWeb.Router.Helpers.some_url(...)`. If you don't have the time to clean up such 375 | invocations immediately, you can add the module to the `dirty_xrefs` list: 376 | 377 | ``` 378 | defmodule MySystem do 379 | use Boundary, 380 | # Invocations to these modules will not be checked. 381 | dirty_xrefs: [MySystemWeb.Router.Helpers, ...] 382 | end 383 | ``` 384 | 385 | In addition, you can tell boundary to avoid checking outgoing and/or incoming call for some boundary. This can be 386 | controlled with the `:check`. The default value is `check: [in: true, out: true]`, which means that all incoming and 387 | outgoing calls will be checked. 388 | 389 | The `in: false` setting will allow any boundary to use modules from this boundary. The `out: false` setting will allow 390 | this boundary to use any other boundary. If both options are set to false, boundary becomes ignored. These settings 391 | can only be provided for top-level boundaries. If a boundary has some check disabled, it may not contain 392 | sub-boundaries. 393 | 394 | Ignoring checks can be useful for the test support modules. By introducing a top-level boundary for such modules (e.g. 395 | `MySystemTest`), and marking the in and out checks as false, you can effectively instruct boundary to avoid checking 396 | the test support modules. 397 | 398 | ## Alias references 399 | 400 | Boundary can also check plain alias references (`Foo.Bar`). This check is by default disabled. To enable it, you can 401 | include `check: [aliases: true]` in global or boundary options. An alias reference is only checked if it corresponds 402 | to an existing module. 403 | 404 | ## Nested boundaries 405 | 406 | It is possible to define boundaries within boundaries. Nested boundaries allow you to further control the dependency 407 | graph inside the boundary, and make some in-boundary modules private to others. 408 | 409 | Let's see this in an example. Suppose that we're building a Phoenix-powered blog engine. Our context layer, 410 | `BlogEngine`, exposes two modules, `Accounts` and `Articles` (note that `BlogEngine.` prefix is omitted for brevity) 411 | to the web tier: 412 | 413 | ``` 414 | defmodule BlogEngine do 415 | use Boundary, exports: [Accounts, Articles] 416 | end 417 | 418 | defmodule BlogEngineWeb do 419 | use Boundary, deps: [BlogEngine] 420 | end 421 | ``` 422 | 423 | But beyond this, we want to further manage the dependencies inside the context. The context tier consists of the 424 | modules `Repo`, `Articles`, `Accounts`, and `Accounts.Mailer`. We'd like to introduce the following constraints: 425 | 426 | - `Articles` can use `Accounts` (but not the other way around). 427 | - Both `Articles` and `Accounts` can use `Repo`, but `Repo` can't use any other module. 428 | - Only the `Accounts` module can use the internal `Accounts.Mailer` module. 429 | 430 | Here's how we can do that: 431 | 432 | ``` 433 | defmodule BlogEngine.Repo do 434 | use Boundary 435 | end 436 | 437 | defmodule BlogEngine.Articles do 438 | use Boundary, deps: [BlogEngine.{Accounts, Repo}] 439 | end 440 | 441 | defmodule BlogEngine.Accounts do 442 | use Boundary, deps: [BlogEngine.Repo] 443 | end 444 | ``` 445 | 446 | Conceptually, we've built a boundary sub-tree inside `BlogEngine` which looks as: 447 | 448 | ```text 449 | BlogEngine 450 | | 451 | +----Repo 452 | | 453 | +----Articles 454 | | 455 | +----Accounts 456 | ``` 457 | 458 | With the following dependencies: 459 | 460 | ```text 461 | Articles ----> Repo 462 | | ^ 463 | v | 464 | Accounts -------+ 465 | ``` 466 | 467 | ### Root module 468 | 469 | The root module of a sub-boundary plays a special role. This module can be exported by the parent boundary, and at the 470 | same time it defines its own boundary. This can be seen in the previous example, where all three modules, `Articles`, 471 | `Accounts`, and `Repo` are exported by `BlogEngine`, while at the same time these modules define their own 472 | sub-boundaries. 473 | 474 | This demonstrates the main purpose of sub-boundaries. They are a mechanism which allows you to control the 475 | dependencies within the parent boundary. The parent boundary still gets to decide which of these sub-modules it will 476 | export. In this example, `Articles` and `Accounts` are exported, while `Repo` isn't. The sub-boundaries decide what 477 | will they depend on themselves. 478 | 479 | ### Exporting from sub-boundaries 480 | 481 | The parent boundary may export modules that are exported by its sub-boundaries: 482 | 483 | ``` 484 | defmodule BlogEngine do 485 | use Boundary, exports: [Accounts, Articles, Articles.Article] 486 | end 487 | 488 | defmodule BlogEngine.Articles do 489 | use Boundary, deps: [BlogEngine.{Accounts, Repo}], exports: [Article] 490 | end 491 | ``` 492 | 493 | In this example, `BlogEngine` exports `Articles.Article` which belongs to a sub-boundary. 494 | 495 | If you want to export all exports of a sub-boundary, you can use the mass export syntax: 496 | 497 | ``` 498 | use Boundary, exports: [{Articles, []}, ...] 499 | ``` 500 | 501 | This will export the `Articles` module together with all the modules exported by the articles 502 | sub-boundary. 503 | 504 | The parent boundary may not export a module that isn't exported by its owner boundary. 505 | 506 | ### Dependencies 507 | 508 | A sub-boundary inherits the deps from its ancestors by default. If you want to be more explicit, you can set the 509 | sub-boundary's type to `:strict`, in which case nothing is inherited by default, and sub-boundary must list its 510 | deps. Ancestors deps are inherited up to the first `:strict` ancestor. 511 | 512 | When listing deps, a boundary may only depend on its direct siblings, its parent, and any dependency of its ancestors. 513 | In other words, a boundary inherits all the constraints of its ancestors, and it can't bring in any new deps that are 514 | not know to some ancestor. 515 | 516 | A boundary can't depend on its descendants. However, the modules from the parent boundary are implicitly allowed to 517 | use the exports of the child sub-boundaries (but not of the descendants). This property holds even if boundary is 518 | declared as strict. 519 | 520 | #### Cross-app dependencies 521 | 522 | If the external lib defines its own boundaries, you can only depend on the top-level boundaries. If implicit 523 | boundaries are used (app doesn't define its own boundaries), all such boundaries are considered as top-level, and you 524 | can depend on any boundary from such app. 525 | 526 | ### Promoting boundaries to top-level 527 | 528 | It's possible to turn a nested boundary into a top-level boundary: 529 | 530 | ``` 531 | defmodule BlogEngine.Application do 532 | use Boundary, top_level?: true 533 | end 534 | ``` 535 | 536 | In this case `BlogEngine.Application` is not considered to be a sub-boundary of `BlogEngine`. This option is 537 | discouraged because it introduces a mismatch between the namespace hierarchy, and the logical model. Conceptually, 538 | `BlogEngine.Application` is a sibling of `BlogEngine` and `BlogEngineWeb`, but in the namespace hierarchy it usually 539 | resides under the context namespace (courtesy of generators such as `mix new` and `mix phx.new`). 540 | 541 | An alternative is to rename the module to `MySystemApp`: 542 | 543 | ``` 544 | defmodule MySystemApp do 545 | use Application 546 | use Boundary, deps: [MySystem, MySystemWeb] 547 | end 548 | ``` 549 | 550 | That way the namespace hierarchy will match the logical model. 551 | """ 552 | 553 | require Boundary.Definition 554 | alias Boundary.Definition 555 | 556 | Code.eval_quoted(Definition.generate([deps: [], exports: [:Definition]], __ENV__), [], __ENV__) 557 | 558 | @type t :: %{ 559 | name: name, 560 | ancestors: [name], 561 | deps: [{name, mode}], 562 | exports: [export], 563 | dirty_xrefs: MapSet.t(module), 564 | check: %{apps: [{atom, mode}], in: boolean, out: boolean, aliases: boolean}, 565 | type: :strict | :relaxed, 566 | file: String.t(), 567 | line: pos_integer, 568 | implicit?: boolean, 569 | app: atom, 570 | errors: [term] 571 | } 572 | 573 | @type view :: %{ 574 | version: String.t(), 575 | main_app: Application.app(), 576 | classifier: classifier, 577 | unclassified_modules: MapSet.t(module), 578 | module_to_app: %{module => Application.app()}, 579 | external_deps: MapSet.t(module), 580 | boundary_defs: %{module => %{atom => any}}, 581 | protocol_impls: MapSet.t(module) 582 | } 583 | 584 | @type classifier :: %{boundaries: %{Boundary.name() => Boundary.t()}, modules: %{module() => Boundary.name()}} 585 | 586 | @type name :: module 587 | @type export :: module | {module, [except: [module]]} 588 | @type mode :: :compile | :runtime 589 | 590 | @type error :: 591 | {:empty_boundary, dep_error} 592 | | {:ignored_dep, dep_error} 593 | | {:cycle, [name()]} 594 | | {:unclassified_module, [module]} 595 | | {:invalid_reference, reference_error} 596 | 597 | @type dep_error :: %{name: Boundary.name(), file: String.t(), line: pos_integer} 598 | 599 | @type reference_error :: %{ 600 | type: :normal | :runtime | :not_exported | :invalid_external_dep_call, 601 | from_boundary: name, 602 | to_boundary: name, 603 | reference: ref() 604 | } 605 | 606 | @type ref :: %{ 607 | to: module, 608 | from: module, 609 | from_function: {function :: atom, arity :: non_neg_integer} | nil, 610 | type: :call | :struct_expansion | :alias_reference, 611 | mode: :compile | :runtime, 612 | file: String.t(), 613 | line: non_neg_integer 614 | } 615 | 616 | @doc false 617 | defmacro __using__(opts), do: Definition.generate(opts, __CALLER__) 618 | 619 | @doc """ 620 | Returns definitions of all boundaries of the main app. 621 | 622 | You shouldn't access the data in this result directly, as it may change significantly without warnings. Use exported 623 | functions of this module to acquire the information you need. 624 | """ 625 | @spec all(view) :: [t] 626 | def all(view), 627 | do: view.classifier.boundaries |> Map.values() |> Enum.filter(&(&1.app == view.main_app)) 628 | 629 | @doc "Returns the definition of the given boundary." 630 | @spec fetch!(view, name) :: t 631 | def fetch!(view, name), do: Map.fetch!(view.classifier.boundaries, name) 632 | 633 | @doc "Returns the definition of the given boundary." 634 | @spec fetch(view, name) :: {:ok, t} | :error 635 | def fetch(view, name), do: Map.fetch(view.classifier.boundaries, name) 636 | 637 | @doc "Returns the definition of the given boundary." 638 | @spec get(view, name) :: t | nil 639 | def get(view, name), do: Map.get(view.classifier.boundaries, name) 640 | 641 | @doc "Returns definition of the boundary to which the given module belongs." 642 | @spec for_module(view, module) :: t | nil 643 | def for_module(view, module) do 644 | with boundary when not is_nil(boundary) <- Map.get(view.classifier.modules, module), 645 | do: fetch!(view, boundary) 646 | end 647 | 648 | @doc "Returns the collection of unclassified modules." 649 | @spec unclassified_modules(view) :: MapSet.t(module) 650 | def unclassified_modules(view), do: view.unclassified_modules 651 | 652 | @doc "Returns all boundary errors." 653 | @spec errors(view, Enumerable.t()) :: [error] 654 | def errors(view, references), do: Boundary.Checker.errors(view, references) 655 | 656 | @doc "Returns the application of the given module." 657 | @spec app(view, module) :: atom | nil 658 | def app(view, module), do: Map.get(view.module_to_app, module) 659 | 660 | @doc "Returns true if the module is an implementation of some protocol." 661 | @spec protocol_impl?(view, module) :: boolean 662 | def protocol_impl?(view, module) do 663 | if app(view, module) == view.main_app, 664 | do: MapSet.member?(view.protocol_impls, module), 665 | else: function_exported?(module, :__impl__, 1) 666 | end 667 | 668 | @doc "Returns the immediate parent of the boundary, or nil if the boundary is a top-level boundary." 669 | @spec parent(view, t) :: t | nil 670 | def parent(_view, %{ancestors: []}), do: nil 671 | def parent(view, %{ancestors: [parent_name | _]}), do: fetch!(view, parent_name) 672 | 673 | @doc "Returns true if given boundaries are siblings." 674 | @spec siblings?(t, t) :: boolean 675 | def siblings?(boundary1, boundary2), 676 | do: 677 | boundary1.app == boundary2.app and 678 | Enum.take(boundary1.ancestors, 1) == Enum.take(boundary2.ancestors, 1) 679 | 680 | defmodule Error do 681 | defexception [:message, :file, :line] 682 | end 683 | end 684 | -------------------------------------------------------------------------------- /lib/boundary/checker.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file Credo.Check.Readability.Specs 2 | defmodule Boundary.Checker do 3 | @moduledoc false 4 | 5 | def errors(view, references) do 6 | Enum.concat([ 7 | invalid_config(view), 8 | invalid_ignores(view), 9 | ancestor_with_ignored_checks(view), 10 | invalid_deps(view), 11 | invalid_exports(view), 12 | cycles(view), 13 | unclassified_modules(view), 14 | invalid_references(view, references), 15 | unused_dirty_xrefs(view, references) 16 | ]) 17 | |> Enum.uniq_by(fn 18 | # deduping by reference minus type/mode, because even if those vary the error can still be the same 19 | {:invalid_reference, data} -> update_in(data.reference, &Map.drop(&1, [:type, :mode])) 20 | other -> other 21 | end) 22 | end 23 | 24 | defp invalid_deps(view) do 25 | for boundary <- Boundary.all(view), 26 | {dep, type} <- boundary.deps, 27 | error = validate_dep(view, boundary, dep, type), 28 | error != :ok, 29 | do: error 30 | end 31 | 32 | defp invalid_config(view), do: view |> Boundary.all() |> Enum.flat_map(& &1.errors) 33 | 34 | defp invalid_ignores(view) do 35 | for boundary <- Boundary.all(view), 36 | boundary.app == view.main_app, 37 | not boundary.check.in or not boundary.check.out, 38 | not Enum.empty?(boundary.ancestors), 39 | do: {:invalid_ignores, boundary} 40 | end 41 | 42 | defp ancestor_with_ignored_checks(view) do 43 | for boundary <- Boundary.all(view), 44 | boundary.app == view.main_app, 45 | ancestor <- Enum.map(boundary.ancestors, &Boundary.fetch!(view, &1)), 46 | not ancestor.check.in or not ancestor.check.out, 47 | do: {:ancestor_with_ignored_checks, boundary, ancestor} 48 | end 49 | 50 | defp validate_dep(view, from_boundary, dep, type) do 51 | with {:ok, to_boundary} <- fetch_dep_boundary(view, from_boundary, dep), 52 | :ok <- validate_dep_check_in(from_boundary, to_boundary), 53 | do: validate_dep_allowed(view, from_boundary, to_boundary, type) 54 | end 55 | 56 | defp fetch_dep_boundary(view, from_boundary, dep) do 57 | case Boundary.get(view, dep) do 58 | nil -> {:unknown_dep, %{name: dep, file: from_boundary.file, line: from_boundary.line}} 59 | to_boundary -> {:ok, to_boundary} 60 | end 61 | end 62 | 63 | defp validate_dep_check_in(from_boundary, to_boundary) do 64 | if to_boundary.check.in, 65 | do: :ok, 66 | else: {:check_in_false_dep, %{name: to_boundary.name, file: from_boundary.file, line: from_boundary.line}} 67 | end 68 | 69 | defp validate_dep_allowed(_view, from_boundary, from_boundary, _type), 70 | do: {:forbidden_dep, %{name: from_boundary.name, file: from_boundary.file, line: from_boundary.line}} 71 | 72 | defp validate_dep_allowed(view, from_boundary, to_boundary, type) do 73 | parent_boundary = Boundary.parent(view, from_boundary) 74 | 75 | # a boundary can depend on its parent, sibling, or a dep of its parent 76 | if parent_boundary == to_boundary or 77 | parent_boundary == Boundary.parent(view, to_boundary) or 78 | (not is_nil(parent_boundary) and {to_boundary.name, type} in parent_boundary.deps), 79 | do: :ok, 80 | else: {:forbidden_dep, %{name: to_boundary.name, file: from_boundary.file, line: from_boundary.line}} 81 | end 82 | 83 | defp invalid_exports(view) do 84 | for boundary <- Boundary.all(view), 85 | export <- exports_to_check(boundary), 86 | error = validate_export(view, boundary, export), 87 | into: MapSet.new(), 88 | do: error 89 | end 90 | 91 | defp exports_to_check(boundary) do 92 | Enum.flat_map( 93 | boundary.exports, 94 | fn 95 | export when is_atom(export) -> [export] 96 | {root, opts} -> Enum.map(Keyword.get(opts, :except, []), &Module.concat(root, &1)) 97 | end 98 | ) 99 | end 100 | 101 | defp validate_export(view, boundary, export) do 102 | cond do 103 | is_nil(Boundary.app(view, export)) -> 104 | {:unknown_export, %{name: export, file: boundary.file, line: boundary.line}} 105 | 106 | # boundary can re-export exports of its descendants 107 | exported_by_child_subboundary?(view, boundary, export) -> 108 | nil 109 | 110 | (Boundary.for_module(view, export) || %{name: nil}).name != boundary.name -> 111 | {:export_not_in_boundary, %{name: export, file: boundary.file, line: boundary.line}} 112 | 113 | true -> 114 | nil 115 | end 116 | end 117 | 118 | defp exported_by_child_subboundary?(view, boundary, export) do 119 | case Boundary.for_module(view, export) do 120 | nil -> 121 | false 122 | 123 | owner_boundary -> 124 | # Start with `owner_boundary`, go up the ancestors chain, and find the immediate child of `boundary` 125 | owner_boundary 126 | |> Stream.iterate(&Boundary.parent(view, &1)) 127 | |> Stream.take_while(&(not is_nil(&1))) 128 | |> Enum.find(&(Enum.at(&1.ancestors, 0) == boundary.name)) 129 | |> case do 130 | nil -> 131 | false 132 | 133 | # If the export's `owner_boundary` exports all modules, include sub-modules 134 | %{exports: [{export_module, []}]} -> 135 | String.starts_with?(to_string(export), to_string(export_module)) 136 | 137 | child_subboundary -> 138 | export in [child_subboundary.name | child_subboundary.exports] 139 | end 140 | end 141 | end 142 | 143 | defp cycles(view) do 144 | graph = :digraph.new([:cyclic]) 145 | 146 | try do 147 | Enum.each(Boundary.all(view), &:digraph.add_vertex(graph, &1.name)) 148 | 149 | for boundary <- Boundary.all(view), 150 | {dep, _type} <- boundary.deps, 151 | do: :digraph.add_edge(graph, boundary.name, dep) 152 | 153 | :digraph.vertices(graph) 154 | |> Stream.map(&:digraph.get_short_cycle(graph, &1)) 155 | |> Stream.reject(&(&1 == false)) 156 | |> Stream.uniq_by(&MapSet.new/1) 157 | |> Enum.map(&{:cycle, &1}) 158 | after 159 | :digraph.delete(graph) 160 | end 161 | end 162 | 163 | defp unclassified_modules(view), do: Enum.map(Boundary.unclassified_modules(view), &{:unclassified_module, &1}) 164 | 165 | defp invalid_references(view, references) do 166 | for reference <- references, 167 | not unclassified_protocol_impl?(view, reference), 168 | 169 | # Ignore protocol impl refs to protocol. These refs always exist, but due to classification 170 | # of the impl, they may belong to different boundaries 171 | not reference_to_implemented_protocol?(view, reference), 172 | from_boundary = Boundary.for_module(view, reference.from), 173 | from_boundary != nil, 174 | from_boundary.check.aliases or reference.type != :alias_reference, 175 | to_boundaries = to_boundaries(view, from_boundary, reference), 176 | {type, to_boundary_name} <- [reference_error(view, reference, from_boundary, to_boundaries)] do 177 | {:invalid_reference, 178 | %{ 179 | type: type, 180 | from_boundary: from_boundary.name, 181 | to_boundary: to_boundary_name, 182 | reference: reference 183 | }} 184 | end 185 | end 186 | 187 | defp unclassified_protocol_impl?(view, reference) do 188 | Boundary.protocol_impl?(view, reference.from) and 189 | Boundary.Definition.classified_to(reference.from, view.boundary_defs) == nil 190 | end 191 | 192 | defp reference_to_implemented_protocol?(view, reference), 193 | do: Boundary.protocol_impl?(view, reference.from) and reference.from.__impl__(:protocol) == reference.to 194 | 195 | defp to_boundaries(view, from_boundary, reference) do 196 | case Boundary.for_module(view, reference.to) do 197 | nil -> 198 | [] 199 | 200 | boundary -> 201 | target_boundaries = 202 | boundary.ancestors 203 | |> Enum.reject(&(&1 == from_boundary.name)) 204 | |> Enum.map(&Boundary.fetch!(view, &1)) 205 | 206 | [boundary | target_boundaries] 207 | end 208 | end 209 | 210 | defp reference_error(_view, _reference, %{check: %{out: false}}, _to_boundaries), do: nil 211 | 212 | defp reference_error(view, reference, from_boundary, []) do 213 | # If we end up here, we couldn't determine a target boundary, so this is either a cross-app ref, or a ref 214 | # to an unclassified boundary. In the former case we'll report an error if the type is strict. In the 215 | # latter case, we won't report an error. 216 | if cross_app_ref?(view, reference) and check_external_dep?(view, reference, from_boundary), 217 | do: {:invalid_external_dep_call, reference.to}, 218 | else: nil 219 | end 220 | 221 | defp reference_error(view, reference, from_boundary, [_ | _] = to_boundaries) do 222 | errors = Enum.map(to_boundaries, &reference_error(view, reference, from_boundary, &1)) 223 | 224 | # if reference to at least one candidate to_boundary is allowed, this succeeds 225 | unless Enum.any?(errors, &is_nil/1), do: Enum.find(errors, &(not is_nil(&1))) 226 | end 227 | 228 | # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity 229 | defp reference_error(view, reference, from_boundary, to_boundary) do 230 | cond do 231 | not to_boundary.check.in -> 232 | nil 233 | 234 | to_boundary == from_boundary -> 235 | nil 236 | 237 | Boundary.protocol_impl?(view, reference.to) -> 238 | # We can enter here when there's `defimpl SomeProtocol, for: Type`. In this case, the caller 239 | # is `SomeProtocol`, while the callee is `SomeProtocol.Type`. This is never an error, so 240 | # we're ignoring this case. 241 | nil 242 | 243 | # explicitly allowed dirty refs 244 | Enum.member?(from_boundary.dirty_xrefs, reference.to) -> 245 | nil 246 | 247 | not cross_ref_allowed?(view, from_boundary, to_boundary, reference) -> 248 | tag = if Enum.member?(from_boundary.deps, {to_boundary.name, :compile}), do: :runtime, else: :normal 249 | {tag, to_boundary.name} 250 | 251 | not exported?(view, to_boundary, reference.to) -> 252 | {:not_exported, to_boundary.name} 253 | 254 | true -> 255 | nil 256 | end 257 | end 258 | 259 | defp check_external_dep?(view, reference, from_boundary) do 260 | Boundary.app(view, reference.to) != :boundary and 261 | (from_boundary.type == :strict or 262 | MapSet.member?( 263 | with_ancestors(view, from_boundary, & &1.check.apps), 264 | {Boundary.app(view, reference.to), reference.mode} 265 | )) 266 | end 267 | 268 | defp with_ancestors(view, boundary, fetch_fun) do 269 | {result, _} = 270 | [boundary] 271 | |> Stream.concat(Stream.map(boundary.ancestors, &Boundary.fetch!(view, &1))) 272 | |> Enum.flat_map_reduce( 273 | :continue, 274 | fn 275 | _boundary, :halt -> 276 | {:halt, nil} 277 | 278 | boundary, :continue -> 279 | {fetch_fun.(boundary), if(boundary.type == :strict, do: :halt, else: :continue)} 280 | end 281 | ) 282 | 283 | MapSet.new(result) 284 | end 285 | 286 | defp cross_ref_allowed?(view, from_boundary, to_boundary, reference) do 287 | cond do 288 | # reference to a child is always allowed 289 | from_boundary == Boundary.parent(view, to_boundary) -> 290 | true 291 | 292 | # reference to a sibling or the parent is allowed if target boundary is listed in deps 293 | Boundary.siblings?(from_boundary, to_boundary) or Boundary.parent(view, from_boundary) == to_boundary -> 294 | in_deps?(to_boundary, from_boundary.deps, reference) 295 | 296 | # reference to another app's boundary is implicitly allowed unless strict checking is required 297 | cross_app_ref?(view, reference) and not check_external_dep?(view, reference, from_boundary) -> 298 | true 299 | 300 | # reference to a non-sibling (either in-app or cross-app) is allowed if it is a dep of myself or any ancestor 301 | in_deps?(to_boundary, with_ancestors(view, from_boundary, & &1.deps), reference) -> 302 | true 303 | 304 | # no other reference is allowed 305 | true -> 306 | false 307 | end 308 | end 309 | 310 | defp in_deps?(%{name: name}, deps, reference) do 311 | Enum.any?( 312 | deps, 313 | fn 314 | {^name, :runtime} -> true 315 | {^name, :compile} -> compile_time_reference?(reference) 316 | _ -> false 317 | end 318 | ) 319 | end 320 | 321 | defp compile_time_reference?(%{mode: :compile}), do: true 322 | defp compile_time_reference?(%{from: module, from_function: {name, arity}}), do: macro_exported?(module, name, arity) 323 | defp compile_time_reference?(_), do: false 324 | 325 | defp cross_app_ref?(view, reference) do 326 | to_app = Boundary.app(view, reference.to) 327 | 328 | # to_app may be nil if no module is defined with the given alias 329 | # such call is treated as an in-app call 330 | to_app != nil and 331 | to_app != Boundary.app(view, reference.from) 332 | end 333 | 334 | defp exported?(view, boundary, module) do 335 | boundary.implicit? or module == boundary.name or 336 | Enum.any?(boundary.exports, &export_matches?(view, boundary, &1, module)) 337 | end 338 | 339 | defp export_matches?(_view, _boundary, module, module), do: true 340 | 341 | defp export_matches?(view, boundary, {root, opts}, module) do 342 | String.starts_with?(to_string(module), to_string(root)) and 343 | not Enum.any?(Keyword.get(opts, :except, []), &(Module.concat(root, &1) == module)) and 344 | (Boundary.for_module(view, module) == boundary or exported_by_child_subboundary?(view, boundary, module)) 345 | end 346 | 347 | defp export_matches?(_, _, _, _), do: false 348 | 349 | defp unused_dirty_xrefs(view, references) do 350 | all_dirty_xrefs = 351 | for boundary <- Boundary.all(view), 352 | xref <- boundary.dirty_xrefs, 353 | into: MapSet.new(), 354 | do: {boundary.name, xref} 355 | 356 | unused_dirty_xrefs = 357 | for reference <- references, 358 | not unclassified_protocol_impl?(view, reference), 359 | from_boundary = Boundary.for_module(view, reference.from), 360 | reduce: all_dirty_xrefs, 361 | do: (xrefs -> MapSet.delete(xrefs, {from_boundary.name, reference.to})) 362 | 363 | unused_dirty_xrefs 364 | |> Enum.sort() 365 | |> Enum.map(fn {boundary_name, dirty_xref} -> 366 | boundary = Boundary.fetch!(view, boundary_name) 367 | {:unused_dirty_xref, %{name: boundary.name, file: boundary.file, line: boundary.line}, dirty_xref} 368 | end) 369 | end 370 | end 371 | -------------------------------------------------------------------------------- /lib/boundary/definition.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file Credo.Check.Readability.Specs 2 | 3 | defmodule Boundary.Definition do 4 | @moduledoc false 5 | 6 | def generate(opts, env) do 7 | opts = 8 | opts 9 | # This ensures that alias references passed to `use Boundary` (e.g. deps, exports) are not 10 | # treated as dependencies (neither compile-time nor runtime) by the Elixir compiler. 11 | # 12 | # For example, invoking `use Boundary, deps: [MySystem]` in `MySystemWeb` won't add a 13 | # dependency from `MySystemWeb` to `MySystem`. We can do this safely here since we're not 14 | # injecting any calls to the modules referenced in `opts`. 15 | |> Macro.prewalk(fn term -> 16 | with {:__aliases__, _, _} <- term, 17 | do: Macro.expand(term, %{env | function: {:boundary, 1}, lexical_tracker: nil}) 18 | end) 19 | |> Enum.map(fn opt -> 20 | with {key, references} when key in ~w/deps exports dirty_xrefs/a and is_list(references) <- opt, 21 | do: {key, expand_references(references)} 22 | end) 23 | 24 | pos = Macro.escape(%{file: env.file, line: env.line}) 25 | 26 | quote bind_quoted: [opts: opts, app: Keyword.fetch!(Mix.Project.config(), :app), pos: pos] do 27 | @opts opts 28 | @pos pos 29 | @app app 30 | 31 | # Definition will be injected before compile, because we need to check if this module is 32 | # a protocol, which we can only do right before the module is about to be compiled. 33 | @before_compile Boundary.Definition 34 | end 35 | end 36 | 37 | defp expand_references(references) do 38 | Enum.flat_map( 39 | references, 40 | fn 41 | reference -> 42 | case Macro.decompose_call(reference) do 43 | {parent, :{}, children} -> Enum.map(children, &Module.concat(parent, &1)) 44 | _ -> [reference] 45 | end 46 | end 47 | ) 48 | end 49 | 50 | @doc false 51 | defmacro __before_compile__(_) do 52 | quote do 53 | Module.register_attribute(__MODULE__, Boundary, persist: true, accumulate: false) 54 | 55 | protocol? = Module.defines?(__MODULE__, {:__impl__, 1}, :def) 56 | mix_task? = String.starts_with?(inspect(__MODULE__), "Mix.Tasks.") 57 | 58 | data = %{opts: @opts, pos: @pos, app: @app, protocol?: protocol?, mix_task?: mix_task?} 59 | 60 | Boundary.Mix.CompilerState.add_module_meta(__MODULE__, :boundary_def, data) 61 | Module.put_attribute(__MODULE__, Boundary, data) 62 | end 63 | end 64 | 65 | def get(boundary, defs) do 66 | with definition when not is_nil(definition) <- definition(boundary, defs) do 67 | case Keyword.pop(definition.opts, :classify_to, nil) do 68 | {nil, opts} -> 69 | normalize(definition.app, boundary, opts, definition.pos) 70 | 71 | {classify_to, opts} -> 72 | target_definition = definition(classify_to, defs) 73 | 74 | cond do 75 | is_nil(target_definition) or Keyword.get(target_definition.opts, :classify_to) != nil -> 76 | normalize(definition.app, boundary, opts, definition.pos) 77 | |> add_errors([ 78 | {:unknown_boundary, name: classify_to, file: definition.pos.file, line: definition.pos.line} 79 | ]) 80 | 81 | not definition.protocol? and not definition.mix_task? -> 82 | normalize(definition.app, boundary, opts, definition.pos) 83 | |> add_errors([{:cant_reclassify, name: boundary, file: definition.pos.file, line: definition.pos.line}]) 84 | 85 | true -> 86 | nil 87 | end 88 | end 89 | end 90 | end 91 | 92 | def classified_to(module, defs) do 93 | with definition when not is_nil(definition) <- definition(module, defs), 94 | {:ok, boundary} <- Keyword.fetch(definition.opts, :classify_to), 95 | true <- definition.protocol? or definition.mix_task? do 96 | %{boundary: boundary, file: definition.pos.file, line: definition.pos.line} 97 | else 98 | _ -> nil 99 | end 100 | end 101 | 102 | defp definition(boundary, nil) do 103 | with true <- :code.get_object_code(boundary) != :error, 104 | [definition] <- Keyword.get(boundary.__info__(:attributes), Boundary), 105 | do: definition, 106 | else: (_ -> nil) 107 | end 108 | 109 | defp definition(boundary, defs), do: Map.get(defs, boundary) 110 | 111 | @doc false 112 | def normalize(app, boundary, definition, pos \\ %{file: nil, line: nil}) do 113 | definition 114 | |> normalize!(app, pos) 115 | |> normalize_check() 116 | |> normalize_exports(boundary) 117 | |> normalize_deps() 118 | |> Map.update!(:dirty_xrefs, &MapSet.new/1) 119 | end 120 | 121 | defp normalize!(user_opts, app, pos) do 122 | defaults() 123 | |> Map.merge(project_defaults(user_opts)) 124 | |> Map.merge(%{file: pos.file, line: pos.line, app: app}) 125 | |> merge_user_opts(user_opts) 126 | |> validate(&if &1.type not in ~w/strict relaxed/a, do: :invalid_type) 127 | end 128 | 129 | defp merge_user_opts(definition, user_opts) do 130 | user_opts = 131 | case Keyword.get(user_opts, :ignore?) do 132 | nil -> user_opts 133 | value -> Config.Reader.merge([check: [in: not value, out: not value]], user_opts) 134 | end 135 | 136 | user_opts = Map.new(user_opts) 137 | valid_keys = ~w/deps exports dirty_xrefs check type top_level?/a 138 | 139 | definition 140 | |> Map.merge(Map.take(user_opts, valid_keys)) 141 | |> add_errors( 142 | user_opts 143 | |> Map.drop(valid_keys) 144 | |> Enum.map(fn {key, value} -> {:unknown_option, name: key, value: value} end) 145 | ) 146 | end 147 | 148 | defp normalize_exports(%{exports: :all} = definition, boundary), 149 | do: normalize_exports(%{definition | exports: {:all, []}}, boundary) 150 | 151 | defp normalize_exports(%{exports: {:all, opts}} = definition, boundary), 152 | do: %{definition | exports: [{boundary, opts}]} 153 | 154 | defp normalize_exports(definition, boundary) do 155 | update_in( 156 | definition.exports, 157 | fn exports -> Enum.map(exports, &normalize_export(boundary, &1)) end 158 | ) 159 | end 160 | 161 | defp normalize_export(boundary, export) when is_atom(export), do: Module.concat(boundary, export) 162 | defp normalize_export(boundary, {export, opts}), do: {Module.concat(boundary, export), opts} 163 | 164 | defp normalize_check(definition) do 165 | definition.check 166 | |> update_in(&Map.new(Keyword.merge([in: true, out: true, aliases: false, apps: []], &1))) 167 | |> update_in([:check, :apps], &normalize_check_apps/1) 168 | |> validate(&if not &1.check.in and &1.exports != [], do: :exports_in_check_in_false) 169 | |> validate(&if not &1.check.out and &1.deps != [], do: :deps_in_check_out_false) 170 | |> validate(&if not &1.check.out and &1.check.apps != [], do: :apps_in_check_out_false) 171 | end 172 | 173 | defp normalize_check_apps(apps) do 174 | Enum.flat_map(apps, fn 175 | {_app, _type} = entry -> [entry] 176 | app when is_atom(app) -> [{app, :runtime}, {app, :compile}] 177 | end) 178 | end 179 | 180 | defp normalize_deps(definition) do 181 | update_in( 182 | definition.deps, 183 | &Enum.map( 184 | &1, 185 | fn 186 | {_dep, _type} = dep -> dep 187 | dep when is_atom(dep) -> {dep, :runtime} 188 | end 189 | ) 190 | ) 191 | end 192 | 193 | defp defaults do 194 | %{ 195 | deps: [], 196 | exports: [], 197 | dirty_xrefs: [], 198 | check: [], 199 | type: :relaxed, 200 | errors: [], 201 | top_level?: false 202 | } 203 | end 204 | 205 | defp project_defaults(user_opts) do 206 | if user_opts[:check][:out] == false do 207 | %{} 208 | else 209 | (Mix.Project.config()[:boundary][:default] || []) 210 | |> Keyword.take(~w/type check/a) 211 | |> Map.new() 212 | end 213 | end 214 | 215 | defp add_errors(definition, errors) do 216 | errors = Enum.map(errors, &full_error(&1, definition)) 217 | update_in(definition.errors, &Enum.concat(&1, errors)) 218 | end 219 | 220 | defp full_error(tag, definition) when is_atom(tag), do: full_error({tag, []}, definition) 221 | 222 | defp full_error({tag, data}, definition), 223 | do: {tag, data |> Map.new() |> Map.merge(Map.take(definition, ~w/file line/a))} 224 | 225 | defp validate(definition, check) do 226 | case check.(definition) do 227 | nil -> definition 228 | error -> add_errors(definition, [error]) 229 | end 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /lib/boundary/graph.ex: -------------------------------------------------------------------------------- 1 | defmodule Boundary.Graph do 2 | @moduledoc false 3 | 4 | @opaque t :: %{connections: %{node => Keyword.t()}, name: String.t(), nodes: %{node => Keyword.t()}, subgraphs: [t]} 5 | @type node_name :: String.t() 6 | 7 | @spec new(node_name) :: t() 8 | def new(name), do: %{connections: %{}, name: name, nodes: %{}, subgraphs: []} 9 | 10 | @spec add_node(t(), node_name(), Keyword.t()) :: t() 11 | def add_node(graph, node, opts \\ []) do 12 | Map.update!( 13 | graph, 14 | :nodes, 15 | fn nodes -> Map.update(nodes, node, opts, &Keyword.merge(&1, opts)) end 16 | ) 17 | end 18 | 19 | @spec add_dependency(t(), node_name, node_name, Keyword.t()) :: t() 20 | def add_dependency(graph, from, to, attributes \\ []) do 21 | connections = Map.update(graph.connections, from, %{to => attributes}, &Map.merge(&1, %{to => attributes})) 22 | %{graph | connections: connections} 23 | end 24 | 25 | @spec add_subgraph(t(), t()) :: t() 26 | def add_subgraph(graph, subgraph), 27 | do: %{graph | subgraphs: [subgraph | graph.subgraphs]} 28 | 29 | @spec dot(t(), Keyword.t()) :: node_name 30 | def dot(graph, opts \\ []) do 31 | {type, opts} = Keyword.pop(opts, :type, :digraph) 32 | {spaces, opts} = Keyword.pop(opts, :indent, 0) 33 | indent = String.duplicate(" ", spaces) 34 | 35 | graph_content = """ 36 | #{indent} label="#{graph.name}"; 37 | #{indent} labelloc=top; 38 | #{indent} rankdir=LR; 39 | #{opts_string(opts, indent)} 40 | #{nodes(graph, indent)} 41 | 42 | #{connections(graph, indent)} 43 | 44 | #{subgraphs(graph, spaces)} 45 | """ 46 | 47 | graph_content = format_dot(graph_content) 48 | 49 | header = 50 | case type do 51 | {:subgraph, index} -> "subgraph cluster_#{index}" 52 | _ -> to_string(type) 53 | end 54 | 55 | "#{indent}#{header} {\n#{graph_content}#{indent}}\n" 56 | end 57 | 58 | defp subgraphs(graph, spaces) do 59 | graph.subgraphs 60 | |> Enum.reverse() 61 | |> Enum.with_index() 62 | |> Enum.map_join( 63 | "\n", 64 | fn {subgraph, index} -> dot(subgraph, indent: spaces + 2, type: {:subgraph, index}) end 65 | ) 66 | end 67 | 68 | defp nodes(graph, tab) do 69 | Enum.map( 70 | graph.nodes, 71 | fn {node, opts} -> 72 | opts = Keyword.merge([shape: "box"], opts) 73 | ~s/#{tab} "#{node}"#{attributes(opts)};\n/ 74 | end 75 | ) 76 | end 77 | 78 | defp connections(graph, tab) do 79 | for( 80 | {from, connections} <- graph.connections, 81 | {to, attributes} <- connections, 82 | do: ~s/#{tab} "#{from}" -> "#{to}"#{attributes(attributes)};\n/ 83 | ) 84 | |> to_string() 85 | end 86 | 87 | defp opts_string(options, indent), do: Enum.map(options, fn {k, v} -> "#{indent} #{k}=#{v};\n" end) 88 | 89 | defp attributes([]), do: "" 90 | 91 | defp attributes(attributes), 92 | do: " [#{Enum.map_join(attributes, ", ", fn {k, v} -> "#{k}=#{v}" end)}]" 93 | 94 | defp format_dot(dot_string) do 95 | dot_string 96 | |> String.replace(~r/\n{3,}/, "\n\n") 97 | |> String.replace(~r/\n\n$/, "\n") 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/boundary/mix.ex: -------------------------------------------------------------------------------- 1 | defmodule Boundary.Mix do 2 | @moduledoc false 3 | 4 | use Boundary, 5 | deps: [Boundary, Mix], 6 | # needs to be exported because modules which `use Boundary` invoke `CompilerState` during compilation 7 | exports: [CompilerState] 8 | 9 | @spec app_name :: atom 10 | def app_name, do: Keyword.fetch!(Mix.Project.config(), :app) 11 | 12 | @spec load_app :: :ok 13 | def load_app do 14 | load_app_recursive(app_name()) 15 | load_compile_time_deps() 16 | :ok 17 | end 18 | 19 | @spec app_modules(Application.app()) :: [module] 20 | def app_modules(app), 21 | # we're currently supporting only Elixir modules 22 | do: Enum.filter(Application.spec(app, :modules) || [], &String.starts_with?(Atom.to_string(&1), "Elixir.")) 23 | 24 | @spec manifest_path(String.t()) :: String.t() 25 | def manifest_path(name), do: Path.join(Mix.Project.manifest_path(Mix.Project.config()), "compile.#{name}") 26 | 27 | @spec read_manifest(String.t()) :: term 28 | def read_manifest(name) do 29 | name |> manifest_path() |> File.read!() |> :erlang.binary_to_term() 30 | rescue 31 | _ -> nil 32 | end 33 | 34 | @spec write_manifest(String.t(), term) :: :ok 35 | def write_manifest(name, data) do 36 | path = manifest_path(name) 37 | File.mkdir_p!(Path.dirname(path)) 38 | File.write!(path, :erlang.term_to_binary(data)) 39 | end 40 | 41 | defp load_app_recursive(app_name, visited \\ MapSet.new()) do 42 | if MapSet.member?(visited, app_name) do 43 | visited 44 | else 45 | visited = MapSet.put(visited, app_name) 46 | 47 | visited = 48 | if Application.load(app_name) in [:ok, {:error, {:already_loaded, app_name}}] do 49 | Application.spec(app_name, :applications) 50 | |> Stream.concat(Application.spec(app_name, :included_applications)) 51 | |> Enum.reduce(visited, &load_app_recursive/2) 52 | else 53 | visited 54 | end 55 | 56 | visited 57 | end 58 | end 59 | 60 | defp load_compile_time_deps do 61 | Mix.Project.config() 62 | |> Keyword.get(:deps, []) 63 | |> Stream.filter(fn 64 | spec -> 65 | spec 66 | |> Tuple.to_list() 67 | |> Stream.filter(&is_list/1) 68 | |> Enum.any?(&(Keyword.get(&1, :runtime) == false)) 69 | end) 70 | |> Stream.map(fn spec -> elem(spec, 0) end) 71 | |> Enum.each(&load_app_recursive/1) 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/boundary/mix/classifier.ex: -------------------------------------------------------------------------------- 1 | defmodule Boundary.Mix.Classifier do 2 | @moduledoc false 3 | 4 | @spec new :: Boundary.classifier() 5 | def new, do: %{boundaries: %{}, modules: %{}} 6 | 7 | @spec delete(Boundary.classifier(), Application.app()) :: Boundary.classifier() 8 | def delete(classifier, app) do 9 | boundaries_to_delete = 10 | classifier.boundaries 11 | |> Map.values() 12 | |> Stream.filter(&(&1.app == app)) 13 | |> Enum.map(& &1.name) 14 | 15 | boundaries = Map.drop(classifier.boundaries, boundaries_to_delete) 16 | 17 | modules = 18 | for {_, boundary} = entry <- classifier.modules, 19 | Map.has_key?(boundaries, boundary), 20 | do: entry, 21 | into: %{} 22 | 23 | %{classifier | boundaries: boundaries, modules: modules} 24 | end 25 | 26 | @spec classify(Boundary.classifier(), Application.app(), Enumerable.t(module), [Boundary.t()]) :: 27 | Boundary.classifier() 28 | def classify(classifier, app, modules, boundaries) do 29 | trie = build_trie(boundaries) 30 | 31 | classifier = %{ 32 | classifier 33 | | boundaries: 34 | trie 35 | |> boundaries() 36 | |> Stream.map(fn 37 | %{top_level?: true} = boundary -> %{boundary | ancestors: []} 38 | %{top_level?: false} = boundary -> boundary 39 | end) 40 | |> Stream.map(&Map.delete(&1, :top_level?)) 41 | |> Enum.into(classifier.boundaries, &{&1.name, &1}) 42 | } 43 | 44 | boundary_defs = Boundary.Mix.CompilerState.boundary_defs(app) 45 | 46 | for module <- modules, 47 | boundary = find_boundary(trie, module, boundary_defs), 48 | reduce: classifier do 49 | classifier -> Map.update!(classifier, :modules, &Map.put(&1, module, boundary.name)) 50 | end 51 | end 52 | 53 | defp boundaries(trie, ancestors \\ []) do 54 | ancestors = if is_nil(trie.boundary), do: ancestors, else: [trie.boundary.name | ancestors] 55 | 56 | child_boundaries = 57 | trie.children 58 | |> Map.values() 59 | |> Enum.flat_map(&boundaries(&1, ancestors)) 60 | 61 | if is_nil(trie.boundary), 62 | do: child_boundaries, 63 | else: [Map.put(trie.boundary, :ancestors, tl(ancestors)) | child_boundaries] 64 | end 65 | 66 | defp build_trie(boundaries), do: Enum.reduce(boundaries, new_trie(), &add_boundary(&2, &1)) 67 | 68 | defp new_trie, do: %{boundary: nil, children: %{}} 69 | 70 | defp find_boundary(trie, module, boundary_defs) when is_atom(module) do 71 | case Boundary.Definition.classified_to(module, boundary_defs) do 72 | nil -> 73 | find_boundary(trie, Module.split(module), boundary_defs) 74 | 75 | classified_to -> 76 | # If we can't find `classified_to`, it's an error in definition (like e.g. classifying to a reclassified 77 | # boundary). This error has already been reported (see `Boundary.Definition.get/1`), and here we treat the 78 | # boundary as if it was not reclassified. 79 | find_boundary(trie, Module.split(classified_to.boundary), boundary_defs) || 80 | find_boundary(trie, Module.split(module), boundary_defs) 81 | end 82 | end 83 | 84 | defp find_boundary(_trie, [], _boundary_defs), do: nil 85 | 86 | defp find_boundary(trie, [part | rest], boundary_defs) do 87 | case Map.fetch(trie.children, part) do 88 | {:ok, child_trie} -> find_boundary(child_trie, rest, boundary_defs) || child_trie.boundary 89 | :error -> nil 90 | end 91 | end 92 | 93 | defp add_boundary(trie, boundary), 94 | do: add_boundary(trie, Module.split(boundary.name), boundary) 95 | 96 | defp add_boundary(trie, [], boundary), do: %{trie | boundary: boundary} 97 | 98 | defp add_boundary(trie, [part | rest], boundary) do 99 | Map.update!( 100 | trie, 101 | :children, 102 | fn children -> 103 | children 104 | |> Map.put_new_lazy(part, &new_trie/0) 105 | |> Map.update!(part, &add_boundary(&1, rest, boundary)) 106 | end 107 | ) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/boundary/mix/compiler_state.ex: -------------------------------------------------------------------------------- 1 | defmodule Boundary.Mix.CompilerState do 2 | @moduledoc false 3 | use GenServer 4 | 5 | @spec start_link(force: boolean) :: {:ok, pid} 6 | def start_link(opts \\ []) do 7 | pid = 8 | case GenServer.start_link(__MODULE__, opts, name: name()) do 9 | {:ok, pid} -> pid 10 | # this can happen in ElixirLS, since the process remains alive after the compilation run 11 | {:error, {:already_started, pid}} -> pid 12 | end 13 | 14 | :ets.delete_all_objects(seen_table()) 15 | 16 | {:ok, pid} 17 | end 18 | 19 | defp name, 20 | # The GenServer name (and ets tables) must contain app name, to properly work in umbrellas. 21 | do: :"#{__MODULE__}.#{app_name()}" 22 | 23 | @spec record_references(module, map) :: :ok 24 | def record_references(from, entry) do 25 | :ets.insert(references_table(), {from, entry}) 26 | :ok 27 | end 28 | 29 | @spec initialize_module(module) :: :ok 30 | def initialize_module(module) do 31 | if :ets.insert_new(seen_table(), {module}) do 32 | :ets.delete(references_table(), module) 33 | :ets.delete(modules_table(), module) 34 | end 35 | 36 | :ok 37 | end 38 | 39 | @spec flush([module]) :: :ok | {:error, any} 40 | def flush(app_modules) do 41 | app_modules = MapSet.new(app_modules) 42 | 43 | for module <- ets_keys(references_table()), 44 | not MapSet.member?(app_modules, module), 45 | table <- [references_table(), modules_table()], 46 | do: :ets.delete(table, module) 47 | 48 | app_modules 49 | |> Enum.map(&Task.async(fn -> compress_entries(&1) end)) 50 | |> Enum.each(&Task.await(&1, :infinity)) 51 | 52 | :ets.delete_all_objects(seen_table()) 53 | :ets.tab2file(references_table(), to_charlist(Boundary.Mix.manifest_path("boundary_references"))) 54 | :ets.tab2file(modules_table(), to_charlist(Boundary.Mix.manifest_path("boundary_modules"))) 55 | end 56 | 57 | @doc "Returns a lazy stream where each element is of type `t:Boundary.ref()`" 58 | @spec references :: Enumerable.t() 59 | def references do 60 | :ets.tab2list(references_table()) 61 | |> Enum.map(fn {from_module, info} -> Map.put(info, :from, from_module) end) 62 | end 63 | 64 | @doc """ 65 | Stores module meta. 66 | 67 | The data is stored in memory, and later flushed to the manifest file. 68 | """ 69 | @spec add_module_meta(module, any, any) :: :ok 70 | def add_module_meta(module, key, value) do 71 | with pid when is_pid(pid) <- GenServer.whereis(name()), 72 | do: :ets.insert(modules_table(), {module, {key, value}}) 73 | 74 | :ok 75 | end 76 | 77 | @doc """ 78 | Returns an enumerable stream of cached raw boundary definitions 79 | 80 | If no cache exists, `nil` is returned. 81 | """ 82 | @spec boundary_defs(Application.app()) :: Enumerable.t({module, %{atom => any}}) | nil 83 | def boundary_defs(app) do 84 | if metas = module_metas(app) do 85 | for {module, properties} <- metas, 86 | {:boundary_def, def} <- properties, 87 | into: %{}, 88 | do: {module, def} 89 | end 90 | end 91 | 92 | @doc """ 93 | Returns a mapset with all protocol implementation modules (define with `defimpl`) in the given app. 94 | 95 | If no cache exists, `nil` is returned. 96 | """ 97 | @spec protocol_impls(Application.app()) :: MapSet.t(module) | nil 98 | def protocol_impls(app) do 99 | if metas = module_metas(app) do 100 | for {module, properties} <- metas, 101 | {:protocol?, true} <- properties, 102 | into: MapSet.new(), 103 | do: module 104 | end 105 | end 106 | 107 | @spec encountered_modules(Application.app()) :: Enumerable.t(module) 108 | def encountered_modules(app), do: ets_keys(modules_table(app)) 109 | 110 | defp module_metas(app) do 111 | table = modules_table(app) 112 | 113 | if :ets.info(table) == :undefined do 114 | nil 115 | else 116 | table 117 | |> ets_keys() 118 | |> Stream.map(fn module -> {module, :ets.lookup_element(table, module, 2)} end) 119 | end 120 | end 121 | 122 | @impl GenServer 123 | def init(opts) do 124 | :ets.new(seen_table(), [:set, :public, :named_table, read_concurrency: true, write_concurrency: true]) 125 | 126 | with false <- Keyword.get(opts, :force, false), 127 | {:ok, table} <- :ets.file2tab(String.to_charlist(Boundary.Mix.manifest_path("boundary_references"))), 128 | do: table, 129 | else: (_ -> :ets.new(references_table(), [:named_table, :public, :duplicate_bag, write_concurrency: true])) 130 | 131 | with false <- Keyword.get(opts, :force, false), 132 | {:ok, table} <- :ets.file2tab(String.to_charlist(Boundary.Mix.manifest_path("boundary_modules"))), 133 | do: table, 134 | else: (_ -> :ets.new(modules_table(), [:named_table, :public, :duplicate_bag, write_concurrency: true])) 135 | 136 | {:ok, %{}} 137 | end 138 | 139 | defp ets_keys(table) do 140 | Stream.unfold( 141 | :ets.first(table), 142 | fn 143 | :"$end_of_table" -> nil 144 | key -> {key, :ets.next(table, key)} 145 | end 146 | ) 147 | end 148 | 149 | defp compress_entries(module) do 150 | :ets.take(references_table(), module) 151 | |> Enum.map(fn {^module, entry} -> entry end) 152 | |> drop_leading_aliases() 153 | |> dedup_entries() 154 | |> Enum.each(&:ets.insert(references_table(), {module, &1})) 155 | end 156 | 157 | defp drop_leading_aliases([entry, next_entry | rest]) do 158 | # For the same file/line/to combo remove leading alias reference. This is needed because `Foo.bar()` and `%Foo{}` will 159 | # generate two entries: alias reference to `Foo`, followed by the function call or the struct expansion. In this 160 | # case we want to keep only the second entry. 161 | if entry.type == :alias_reference and 162 | entry.to == next_entry.to and 163 | entry.file == next_entry.file and 164 | entry.line == next_entry.line, 165 | do: drop_leading_aliases([next_entry | rest]), 166 | else: [entry | drop_leading_aliases([next_entry | rest])] 167 | end 168 | 169 | defp drop_leading_aliases(other), do: other 170 | 171 | # Keep only one entry per file/line/to/mode combo 172 | defp dedup_entries(entries) do 173 | # Alias ref has lower prio because this check is optional. Therefore, if we have any call or struct expansion, we'll 174 | # prefer that. 175 | prios = %{call: 1, struct_expansion: 1, alias_reference: 2} 176 | 177 | entries 178 | |> Enum.group_by(&{&1.file, &1.line, &1.to, &1.mode}) 179 | |> Enum.map(fn {_key, entries} -> Enum.min_by(entries, &Map.fetch!(prios, &1.type)) end) 180 | |> Enum.sort_by(&{&1.file, &1.line}) 181 | end 182 | 183 | defp seen_table, do: :"#{__MODULE__}.#{app_name()}.Seen" 184 | defp references_table, do: :"#{__MODULE__}.#{app_name()}.References" 185 | defp modules_table(app \\ app_name()), do: :"#{__MODULE__}.#{app}.Modules" 186 | 187 | defp app_name, do: Keyword.fetch!(Mix.Project.config(), :app) 188 | end 189 | -------------------------------------------------------------------------------- /lib/boundary/mix/tasks/compile/boundary.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Compile.Boundary do 2 | # credo:disable-for-this-file Credo.Check.Readability.Specs 3 | 4 | use Boundary, classify_to: Boundary.Mix 5 | use Mix.Task.Compiler 6 | alias Boundary.Mix.CompilerState 7 | 8 | @moduledoc """ 9 | Verifies cross-module function calls according to defined boundaries. 10 | 11 | This compiler reports all cross-boundary function calls which are not permitted, according to 12 | the current definition of boundaries. For details on defining boundaries, see the docs for the 13 | `Boundary` module. 14 | 15 | ## Usage 16 | 17 | Once you have configured the boundaries, you need to include the compiler in `mix.exs`: 18 | 19 | ``` 20 | defmodule MySystem.MixProject do 21 | # ... 22 | 23 | def project do 24 | [ 25 | compilers: [:boundary] ++ Mix.compilers(), 26 | # ... 27 | ] 28 | end 29 | 30 | # ... 31 | end 32 | ``` 33 | 34 | When developing a library, it's advised to use this compiler only in `:dev` and `:test` 35 | environments: 36 | 37 | ``` 38 | defmodule Boundary.MixProject do 39 | # ... 40 | 41 | def project do 42 | [ 43 | compilers: extra_compilers(Mix.env()) ++ Mix.compilers(), 44 | # ... 45 | ] 46 | end 47 | 48 | # ... 49 | 50 | defp extra_compilers(:prod), do: [] 51 | defp extra_compilers(_env), do: [:boundary] 52 | end 53 | ``` 54 | 55 | ## Warnings 56 | 57 | Every invalid cross-boundary usage is reported as a compiler warning. Consider the following example: 58 | 59 | ``` 60 | defmodule MySystem.User do 61 | def auth() do 62 | MySystemWeb.Endpoint.url() 63 | end 64 | end 65 | ``` 66 | 67 | Assuming that calls from `MySystem` to `MySystemWeb` are not allowed, you'll get the following warning: 68 | 69 | ``` 70 | $ mix compile 71 | 72 | warning: forbidden reference to MySystemWeb 73 | (references from MySystem to MySystemWeb are not allowed) 74 | lib/my_system/user.ex:3 75 | See https://hexdocs.pm/boundary/Mix.Tasks.Compile.Boundary.html for details. 76 | ``` 77 | 78 | Since the compiler emits warnings, `mix compile` will still succeed, and you can normally start 79 | your system, even if some boundary rules are violated. The compiler doesn't force you to immediately 80 | fix these violations, which is a deliberate decision made to avoid disrupting the development flow. 81 | 82 | At the same time, it's worth enforcing boundaries on the CI. This can easily be done by providing 83 | the `--warnings-as-errors` option to `mix compile`. 84 | """ 85 | 86 | @recursive true 87 | 88 | @impl Mix.Task.Compiler 89 | def run(argv) do 90 | {opts, _rest, _errors} = OptionParser.parse(argv, strict: [force: :boolean, warnings_as_errors: :boolean]) 91 | 92 | CompilerState.start_link(Keyword.take(opts, [:force])) 93 | Mix.Task.Compiler.after_compiler(:elixir, &after_elixir_compiler/1) 94 | Mix.Task.Compiler.after_compiler(:app, &after_app_compiler(&1, opts)) 95 | 96 | tracers = Code.get_compiler_option(:tracers) 97 | Code.put_compiler_option(:tracers, [__MODULE__ | tracers]) 98 | 99 | {:ok, []} 100 | end 101 | 102 | @doc false 103 | def trace({remote, meta, to_module, _name, _arity}, env) 104 | when remote in ~w/remote_function imported_function remote_macro imported_macro/a do 105 | mode = if is_nil(env.function) or remote in ~w/remote_macro imported_macro/a, do: :compile, else: :runtime 106 | record(to_module, meta, env, mode, :call) 107 | end 108 | 109 | def trace({local, _meta, _to_module, _name, _arity}, env) 110 | when local in ~w/local_function local_macro/a, 111 | # We need to initialize module although we're not going to record the call, to correctly remove previously 112 | # recorded entries when the module is recompiled. 113 | do: initialize_module(env.module) 114 | 115 | def trace({:struct_expansion, meta, to_module, _keys}, env), 116 | do: record(to_module, meta, env, :compile, :struct_expansion) 117 | 118 | def trace({:alias_reference, meta, to_module}, env) do 119 | unless env.function == {:boundary, 1} do 120 | mode = if is_nil(env.function), do: :compile, else: :runtime 121 | record(to_module, meta, env, mode, :alias_reference) 122 | end 123 | 124 | :ok 125 | end 126 | 127 | def trace({:on_module, _bytecode, _ignore}, env) do 128 | CompilerState.add_module_meta(env.module, :protocol?, Module.defines?(env.module, {:__impl__, 1}, :def)) 129 | :ok 130 | end 131 | 132 | def trace(_event, _env), do: :ok 133 | 134 | defp record(to_module, meta, env, mode, type) do 135 | # We need to initialize module even if we're not going to record the call, to correctly remove previously 136 | # recorded entries when the module is recompiled. 137 | initialize_module(env.module) 138 | 139 | unless env.module in [nil, to_module] or system_module?(to_module) or 140 | not String.starts_with?(Atom.to_string(to_module), "Elixir.") do 141 | CompilerState.record_references( 142 | env.module, 143 | %{ 144 | from_function: env.function, 145 | to: to_module, 146 | mode: mode, 147 | type: type, 148 | file: Path.relative_to_cwd(env.file), 149 | line: Keyword.get(meta, :line, env.line) 150 | } 151 | ) 152 | end 153 | 154 | :ok 155 | end 156 | 157 | defp initialize_module(module), 158 | do: unless(is_nil(module), do: CompilerState.initialize_module(module)) 159 | 160 | # Building the list of "system modules", which we'll exclude from the traced data, to reduce the collected data and 161 | # processing time. 162 | system_apps = ~w/elixir stdlib kernel/a 163 | 164 | system_apps 165 | |> Stream.each(&Application.load/1) 166 | |> Stream.flat_map(&Application.spec(&1, :modules)) 167 | # We'll also include so called preloaded modules (e.g. `:erlang`, `:init`), which are not a part of any app. 168 | |> Stream.concat(:erlang.pre_loaded()) 169 | |> Enum.each(fn module -> defp system_module?(unquote(module)), do: true end) 170 | 171 | defp system_module?(_module), do: false 172 | 173 | defp after_elixir_compiler(outcome) do 174 | # Unloading the tracer after Elixir compiler, irrespective of the outcome. This ensures that the tracer is correctly 175 | # unloaded even if the compilation fails. 176 | tracers = Enum.reject(Code.get_compiler_option(:tracers), &(&1 == __MODULE__)) 177 | Code.put_compiler_option(:tracers, tracers) 178 | outcome 179 | end 180 | 181 | defp after_app_compiler(outcome, opts) do 182 | # Perform the boundary checks only on a successfully compiled app, to avoid false positives. 183 | with {status, diagnostics} when status in [:ok, :noop] <- outcome do 184 | # We're reloading the app to make sure we have the latest version. This fixes potential stale state in ElixirLS. 185 | Application.unload(Boundary.Mix.app_name()) 186 | Application.load(Boundary.Mix.app_name()) 187 | 188 | CompilerState.flush(Application.spec(Boundary.Mix.app_name(), :modules) || []) 189 | 190 | # Caching of the built view for non-user apps. A user app is the main app of the project, and all local deps 191 | # (in-umbrella and path deps). All other apps are library dependencies, and we're caching the boundary view of such 192 | # apps, because that view isn't changing, and we want to avoid loading modules of those apps on every compilation, 193 | # since that's very slow. 194 | user_apps = 195 | for {app, [_ | _] = opts} <- Keyword.get(Mix.Project.config(), :deps, []), 196 | Enum.any?(opts, &(&1 == {:in_umbrella, true} or match?({:path, _}, &1))), 197 | into: MapSet.new([Boundary.Mix.app_name()]), 198 | do: app 199 | 200 | view = Boundary.Mix.View.refresh(user_apps, Keyword.take(opts, ~w/force/a)) 201 | 202 | errors = check(view, CompilerState.references()) 203 | print_diagnostic_errors(errors) 204 | {status(errors, opts), diagnostics ++ errors} 205 | end 206 | end 207 | 208 | defp status([], _), do: :ok 209 | defp status([_ | _], opts), do: if(Keyword.get(opts, :warnings_as_errors, false), do: :error, else: :ok) 210 | 211 | defp print_diagnostic_errors(errors) do 212 | if errors != [], do: Mix.shell().info("") 213 | Enum.each(errors, &print_diagnostic_error/1) 214 | end 215 | 216 | defp print_diagnostic_error(error) do 217 | Mix.shell().info([severity(error.severity), error.message, location(error)]) 218 | end 219 | 220 | defp location(error) do 221 | if error.file != nil and error.file != "" do 222 | line = with tuple when is_tuple(tuple) <- error.position, do: elem(tuple, 0) 223 | pos = if line != nil, do: ":#{line}", else: "" 224 | "\n #{error.file}#{pos}\n" 225 | else 226 | "\n" 227 | end 228 | end 229 | 230 | defp severity(severity), do: [:bright, color(severity), "#{severity}: ", :reset] 231 | defp color(:error), do: :red 232 | defp color(:warning), do: :yellow 233 | 234 | defp check(application, entries) do 235 | Boundary.errors(application, entries) 236 | |> Stream.map(&to_diagnostic_error/1) 237 | |> Enum.sort_by(&{&1.file, &1.position}) 238 | rescue 239 | e in Boundary.Error -> 240 | [diagnostic(e.message, file: e.file, position: e.line)] 241 | end 242 | 243 | defp to_diagnostic_error({:unclassified_module, module}), 244 | do: diagnostic("#{inspect(module)} is not included in any boundary", file: module_source(module)) 245 | 246 | defp to_diagnostic_error({:unknown_dep, dep}) do 247 | diagnostic("unknown boundary #{inspect(dep.name)} is listed as a dependency", 248 | file: Path.relative_to_cwd(dep.file), 249 | position: dep.line 250 | ) 251 | end 252 | 253 | defp to_diagnostic_error({:check_in_false_dep, dep}) do 254 | diagnostic("boundary #{inspect(dep.name)} can't be a dependency because it has check.in set to false", 255 | file: Path.relative_to_cwd(dep.file), 256 | position: dep.line 257 | ) 258 | end 259 | 260 | defp to_diagnostic_error({:forbidden_dep, dep}) do 261 | diagnostic( 262 | "#{inspect(dep.name)} can't be listed as a dependency because it's not a sibling, a parent, or a dep of some ancestor", 263 | file: Path.relative_to_cwd(dep.file), 264 | position: dep.line 265 | ) 266 | end 267 | 268 | defp to_diagnostic_error({:unknown_export, export}) do 269 | diagnostic("unknown module #{inspect(export.name)} is listed as an export", 270 | file: Path.relative_to_cwd(export.file), 271 | position: export.line 272 | ) 273 | end 274 | 275 | defp to_diagnostic_error({:export_not_in_boundary, export}) do 276 | diagnostic("module #{inspect(export.name)} can't be exported because it's not a part of this boundary", 277 | file: Path.relative_to_cwd(export.file), 278 | position: export.line 279 | ) 280 | end 281 | 282 | defp to_diagnostic_error({:cycle, cycle}) do 283 | cycle = cycle |> Stream.map(&inspect/1) |> Enum.join(" -> ") 284 | diagnostic("dependency cycle found:\n#{cycle}\n") 285 | end 286 | 287 | defp to_diagnostic_error({:unknown_boundary, info}) do 288 | diagnostic("unknown boundary #{inspect(info.name)}", 289 | file: Path.relative_to_cwd(info.file), 290 | position: info.line 291 | ) 292 | end 293 | 294 | defp to_diagnostic_error({:cant_reclassify, info}) do 295 | diagnostic("only mix task and protocol implementation can be reclassified", 296 | file: Path.relative_to_cwd(info.file), 297 | position: info.line 298 | ) 299 | end 300 | 301 | defp to_diagnostic_error({:invalid_reference, error}) do 302 | reason = 303 | case error.type do 304 | :normal -> 305 | "(references from #{inspect(error.from_boundary)} to #{inspect(error.to_boundary)} are not allowed)" 306 | 307 | :runtime -> 308 | "(runtime references from #{inspect(error.from_boundary)} to #{inspect(error.to_boundary)} are not allowed)" 309 | 310 | :not_exported -> 311 | module = inspect(error.reference.to) 312 | "(module #{module} is not exported by its owner boundary #{inspect(error.to_boundary)})" 313 | 314 | :invalid_external_dep_call -> 315 | "(references from #{inspect(error.from_boundary)} to #{inspect(error.to_boundary)} are not allowed)" 316 | end 317 | 318 | docs_link = "See https://hexdocs.pm/boundary/Mix.Tasks.Compile.Boundary.html for details." 319 | message = "forbidden reference to #{inspect(error.reference.to)}\n #{reason}\n #{docs_link}" 320 | 321 | diagnostic(message, file: Path.relative_to_cwd(error.reference.file), position: error.reference.line) 322 | end 323 | 324 | defp to_diagnostic_error({:unknown_option, %{name: :ignore?, value: value} = data}) do 325 | diagnostic( 326 | "ignore?: #{value} is deprecated, use check: [in: #{not value}, out: #{not value}] instead", 327 | file: Path.relative_to_cwd(data.file), 328 | position: data.line 329 | ) 330 | end 331 | 332 | defp to_diagnostic_error({:unknown_option, data}) do 333 | diagnostic("unknown option #{inspect(data.name)}", 334 | file: Path.relative_to_cwd(data.file), 335 | position: data.line 336 | ) 337 | end 338 | 339 | defp to_diagnostic_error({:deps_in_check_out_false, data}) do 340 | diagnostic("deps can't be listed if check.out is set to false", 341 | file: Path.relative_to_cwd(data.file), 342 | position: data.line 343 | ) 344 | end 345 | 346 | defp to_diagnostic_error({:apps_in_check_out_false, data}) do 347 | diagnostic("check apps can't be listed if check.out is set to false", 348 | file: Path.relative_to_cwd(data.file), 349 | position: data.line 350 | ) 351 | end 352 | 353 | defp to_diagnostic_error({:exports_in_check_in_false, data}) do 354 | diagnostic("can't export modules if check.in is set to false", 355 | file: Path.relative_to_cwd(data.file), 356 | position: data.line 357 | ) 358 | end 359 | 360 | defp to_diagnostic_error({:invalid_type, data}) do 361 | diagnostic("invalid type", 362 | file: Path.relative_to_cwd(data.file), 363 | position: data.line 364 | ) 365 | end 366 | 367 | defp to_diagnostic_error({:invalid_ignores, boundary}) do 368 | diagnostic("can't disable checks in a sub-boundary", 369 | file: Path.relative_to_cwd(boundary.file), 370 | position: boundary.line 371 | ) 372 | end 373 | 374 | defp to_diagnostic_error({:ancestor_with_ignored_checks, boundary, ancestor}) do 375 | diagnostic("sub-boundary inside a boundary with disabled checks (#{inspect(ancestor.name)})", 376 | file: Path.relative_to_cwd(boundary.file), 377 | position: boundary.line 378 | ) 379 | end 380 | 381 | defp to_diagnostic_error({:unused_dirty_xref, boundary, xref}) do 382 | diagnostic( 383 | "module #{inspect(xref)} doesn't need to be included in the `dirty_xrefs` list for the boundary #{inspect(boundary.name)}", 384 | file: Path.relative_to_cwd(boundary.file), 385 | position: boundary.line 386 | ) 387 | end 388 | 389 | defp module_source(module) do 390 | module.module_info(:compile) 391 | |> Keyword.fetch!(:source) 392 | |> to_string() 393 | |> Path.relative_to_cwd() 394 | catch 395 | _, _ -> "" 396 | end 397 | 398 | def diagnostic(message, opts \\ []) do 399 | %Mix.Task.Compiler.Diagnostic{ 400 | compiler_name: "boundary", 401 | details: nil, 402 | file: nil, 403 | message: message, 404 | position: 0, 405 | severity: :warning 406 | } 407 | |> struct(opts) 408 | end 409 | end 410 | -------------------------------------------------------------------------------- /lib/boundary/mix/tasks/ex_doc_groups.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Boundary.ExDocGroups do 2 | @shortdoc "Creates a boundary.exs holding ex_doc module group defintions." 3 | @moduledoc """ 4 | Creates a `boundary.exs` holding ex_doc module group defintions. 5 | 6 | ## Integration with ExDoc 7 | 8 | The `boundary.exs` file can be integrated with ex_doc in your mix.exs: 9 | 10 | def project do 11 | [ 12 | …, 13 | aliases: aliases(), 14 | docs: docs() 15 | ] 16 | end 17 | 18 | defp aliases do 19 | [ 20 | …, 21 | docs: ["boundary.ex_doc_groups", "docs"] 22 | ] 23 | end 24 | 25 | defp docs do 26 | [ 27 | …, 28 | groups_for_modules: groups_for_modules() 29 | ] 30 | end 31 | 32 | defp groups_for_modules do 33 | {list, _} = Code.eval_file("boundary.exs") 34 | list 35 | end 36 | 37 | """ 38 | 39 | # credo:disable-for-this-file Credo.Check.Readability.Specs 40 | 41 | use Boundary, classify_to: Boundary.Mix 42 | use Mix.Task 43 | 44 | @impl Mix.Task 45 | def run(_argv) do 46 | Mix.Task.run("compile") 47 | Boundary.Mix.load_app() 48 | 49 | view = Boundary.Mix.View.build() 50 | modules = Boundary.Mix.app_modules(view.main_app) 51 | 52 | mapping = 53 | for module <- modules, 54 | boundary = Boundary.for_module(view, module), 55 | boundary.check.in or boundary.check.out do 56 | {module_name_to_group_key(boundary.name), module} 57 | end 58 | |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) 59 | |> Enum.sort_by(&elem(&1, 0)) 60 | |> Enum.map(fn {boundary, modules} -> 61 | modules = Enum.sort_by(modules, &Module.split/1) 62 | {boundary, modules} 63 | end) 64 | 65 | header = """ 66 | # Generated by `mix boundary.ex_doc_groups` 67 | """ 68 | 69 | File.write("boundary.exs", file_contents(header, mapping)) 70 | 71 | Mix.shell().info("\n* creating boundary.exs") 72 | end 73 | 74 | defp file_contents(header, data) do 75 | [Code.format_string!(header <> inspect(data, limit: :infinity)), "\n"] 76 | end 77 | 78 | defp module_name_to_group_key(name) do 79 | :"#{inspect(name)}" 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/boundary/mix/tasks/find_external_deps.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Boundary.FindExternalDeps do 2 | @shortdoc "Prints information about external dependencies of all application boundaries." 3 | 4 | @moduledoc """ 5 | Prints information about external dependencies of all application boundaries. 6 | 7 | Note that `:stdlib`, `:kernel`, `:elixir`, and `:boundary` will not be included in the output. 8 | """ 9 | 10 | # credo:disable-for-this-file Credo.Check.Readability.Specs 11 | 12 | use Boundary, classify_to: Boundary.Mix 13 | use Mix.Task 14 | 15 | alias Boundary.Mix.CompilerState 16 | 17 | @impl Mix.Task 18 | def run(_argv) do 19 | Mix.Task.run("compile") 20 | Boundary.Mix.load_app() 21 | 22 | view = Boundary.Mix.View.build() 23 | 24 | message = 25 | view 26 | |> find_external_deps() 27 | |> Enum.filter(fn {name, _external_deps} -> Boundary.fetch!(view, name).app == Boundary.Mix.app_name() end) 28 | |> Enum.sort() 29 | |> Stream.map(&message/1) 30 | |> Enum.join("\n") 31 | 32 | Mix.shell().info("\n" <> message) 33 | end 34 | 35 | defp message({boundary_name, external_deps}) do 36 | header = "#{[IO.ANSI.bright()]}#{inspect(boundary_name)}#{IO.ANSI.reset()}" 37 | 38 | if Enum.empty?(external_deps) do 39 | header <> " - no external deps\n" 40 | else 41 | """ 42 | #{header}: 43 | #{external_deps |> Enum.sort() |> Stream.map(&inspect/1) |> Enum.join(", ")} 44 | """ 45 | end 46 | end 47 | 48 | defp find_external_deps(boundary_view) do 49 | CompilerState.start_link() 50 | 51 | for reference <- CompilerState.references(), 52 | boundary = Boundary.for_module(boundary_view, reference.from), 53 | boundary.check.out, 54 | app = Boundary.app(boundary_view, reference.to), 55 | app not in [:boundary, Boundary.Mix.app_name(), nil], 56 | reduce: Enum.into(Boundary.all(boundary_view), %{}, &{&1.name, MapSet.new()}) do 57 | acc -> 58 | Map.update(acc, boundary.name, MapSet.new([app]), &MapSet.put(&1, app)) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/boundary/mix/tasks/spec.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Boundary.Spec do 2 | @shortdoc "Prints information about all boundaries in the application." 3 | @moduledoc "Prints information about all boundaries in the application." 4 | 5 | # credo:disable-for-this-file Credo.Check.Readability.Specs 6 | 7 | use Boundary, classify_to: Boundary.Mix 8 | use Mix.Task 9 | 10 | @impl Mix.Task 11 | def run(_argv) do 12 | Mix.Task.run("compile") 13 | Boundary.Mix.load_app() 14 | 15 | msg = 16 | Boundary.Mix.View.build() 17 | |> Boundary.all() 18 | |> Enum.sort_by(& &1.name) 19 | |> Stream.map(&boundary_info/1) 20 | |> Enum.join("\n") 21 | 22 | Mix.shell().info("\n" <> msg) 23 | end 24 | 25 | defp boundary_info(boundary) do 26 | """ 27 | #{inspect(boundary.name)} 28 | exports: #{exports(boundary)} 29 | deps: #{deps(boundary)} 30 | """ 31 | end 32 | 33 | defp deps(%{check: %{out: false}}), do: "not checked" 34 | 35 | defp deps(boundary) do 36 | boundary.deps 37 | |> Enum.sort() 38 | |> Stream.map(fn 39 | {dep, :runtime} -> inspect(dep) 40 | {dep, :compile} -> "#{inspect(dep)} (compile only)" 41 | end) 42 | |> Enum.join(", ") 43 | end 44 | 45 | defp exports(%{check: %{in: false}}), do: "not checked" 46 | 47 | defp exports(boundary) do 48 | boundary.exports 49 | |> Stream.map(&inspect/1) 50 | |> Enum.sort() 51 | |> Enum.join(", ") 52 | |> String.replace("#{inspect(boundary.name)}.", "") 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/boundary/mix/tasks/visualize.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Boundary.Visualize do 2 | @shortdoc "Generates a graphviz dot file for each non-empty boundary." 3 | @moduledoc "Generates a graphviz dot file for each non-empty boundary." 4 | 5 | use Boundary, classify_to: Boundary.Mix 6 | use Mix.Task 7 | 8 | alias Boundary.Graph 9 | 10 | @output_folder "boundary" 11 | 12 | @impl Mix.Task 13 | def run(_argv) do 14 | Mix.Task.run("compile") 15 | Boundary.Mix.load_app() 16 | 17 | File.mkdir(@output_folder) 18 | 19 | view = Boundary.Mix.View.build() 20 | 21 | view 22 | |> Boundary.all() 23 | |> Enum.group_by(&Boundary.parent(view, &1)) 24 | |> Enum.each(fn {main_boundary, boundaries} -> 25 | nodes = build_nodes(view, main_boundary, boundaries) 26 | edges = build_edges(view, main_boundary, boundaries) 27 | title = title(main_boundary) 28 | graph = graph(main_boundary, title, nodes, edges) 29 | 30 | file_path = format_file_path(main_boundary) 31 | File.write!(file_path, graph) 32 | end) 33 | 34 | Mix.shell().info([:green, "Files successfully generated in the `#{@output_folder}` folder."]) 35 | 36 | :ok 37 | end 38 | 39 | defp build_nodes(view, main_boundary, boundaries) do 40 | boundaries 41 | |> Stream.flat_map(&[&1.name | Enum.map(&1.deps, fn {name, _type} -> name end)]) 42 | |> Stream.uniq() 43 | |> Stream.filter(&include?(view, main_boundary, Boundary.fetch!(view, &1))) 44 | |> Enum.sort() 45 | end 46 | 47 | defp build_edges(view, main_boundary, boundaries) do 48 | for %{name: name, deps: deps} <- boundaries, 49 | {dep_name, mode} <- deps, 50 | include?(view, main_boundary, Boundary.fetch!(view, dep_name)), 51 | do: {name, dep_name, mode} 52 | end 53 | 54 | defp include?(view, main_boundary, boundary), 55 | do: boundary.app == Boundary.Mix.app_name() and Boundary.parent(view, boundary) == main_boundary 56 | 57 | defp format_file_path(boundary) do 58 | name = if is_nil(boundary), do: "app", else: inspect(boundary.name) 59 | Path.join([File.cwd!(), @output_folder, "#{name}.dot"]) 60 | end 61 | 62 | defp title(nil), do: "#{Boundary.Mix.app_name()} application" 63 | defp title(boundary), do: "#{inspect(boundary.name)} boundary" 64 | 65 | defp graph(main_boundary, title, nodes, edges) do 66 | Graph.new(title) 67 | |> add_nodes(main_boundary, nodes) 68 | |> add_edges(main_boundary, edges) 69 | |> Graph.dot() 70 | end 71 | 72 | defp add_nodes(graph, main_boundary, nodes), 73 | do: Enum.reduce(nodes, graph, &Graph.add_node(&2, node_name(main_boundary, &1))) 74 | 75 | defp add_edges(graph, main_boundary, edges) do 76 | Enum.reduce(edges, graph, fn {from, to, attributes}, graph -> 77 | Graph.add_dependency( 78 | graph, 79 | node_name(main_boundary, from), 80 | node_name(main_boundary, to), 81 | edge_attributes(attributes) 82 | ) 83 | end) 84 | end 85 | 86 | defp node_name(nil, module), do: inspect(module) 87 | defp node_name(main_boundary, module), do: String.replace(inspect(module), ~r/^#{inspect(main_boundary.name)}\./, "") 88 | 89 | defp edge_attributes(:runtime), do: [] 90 | defp edge_attributes(:compile), do: [label: "compile"] 91 | end 92 | -------------------------------------------------------------------------------- /lib/boundary/mix/tasks/visualize/funs.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file Credo.Check.Readability.Specs 2 | 3 | defmodule Mix.Tasks.Boundary.Visualize.Funs do 4 | @shortdoc "Visualizes cross-function dependencies in a single module." 5 | 6 | @moduledoc """ 7 | #{@shortdoc} 8 | 9 | Usage: 10 | 11 | mix boundary.visualize.funs SomeModule 12 | 13 | The graph is printed to the standard output using the [graphviz dot language](https://graphviz.org/doc/info/lang.html). 14 | """ 15 | 16 | use Boundary, classify_to: Boundary.Mix 17 | use Mix.Task 18 | 19 | alias Boundary.Graph 20 | 21 | @impl Mix.Task 22 | def run(argv) do 23 | unless match?([_], argv), 24 | do: Mix.raise("usage: mix boundary.visualize.functions SomeModule") 25 | 26 | tracers = Code.get_compiler_option(:tracers) 27 | Code.put_compiler_option(:tracers, [__MODULE__ | tracers]) 28 | 29 | :ets.new(__MODULE__, [:named_table, :public, :duplicate_bag, write_concurrency: true]) 30 | :persistent_term.put({__MODULE__, :module}, hd(argv)) 31 | 32 | previous_shell = Mix.shell() 33 | Mix.shell(Mix.Shell.Quiet) 34 | :persistent_term.put({__MODULE__, :shell}, previous_shell) 35 | 36 | # need to force recompile the project so we can collect traces 37 | Mix.Task.Compiler.after_compiler(:app, &after_compiler/1) 38 | Mix.Task.reenable("compile") 39 | Mix.Task.run("compile", ["--force"]) 40 | end 41 | 42 | @doc false 43 | def trace({local, _meta, callee_fun, _arity}, env) when local in ~w/local_function local_macro/a do 44 | {caller_fun, _arity} = env.function 45 | 46 | if inspect(env.module) == :persistent_term.get({__MODULE__, :module}) and caller_fun != callee_fun, 47 | do: :ets.insert(__MODULE__, {caller_fun, callee_fun}) 48 | 49 | :ok 50 | end 51 | 52 | def trace(_other, _env), do: :ok 53 | 54 | defp after_compiler(status) do 55 | Mix.shell(:persistent_term.get({__MODULE__, :shell})) 56 | Mix.shell().info(build_graph()) 57 | status 58 | end 59 | 60 | defp build_graph do 61 | name = "function calls inside #{:persistent_term.get({__MODULE__, :module})}" 62 | 63 | calls() 64 | |> Enum.reduce(Graph.new(name), fn {from, to}, graph -> 65 | graph 66 | |> Graph.add_node(from) 67 | |> Graph.add_node(to) 68 | |> Graph.add_dependency(from, to) 69 | end) 70 | |> Graph.dot() 71 | end 72 | 73 | defp calls, do: :ets.tab2list(__MODULE__) 74 | end 75 | -------------------------------------------------------------------------------- /lib/boundary/mix/tasks/visualize/mods.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Boundary.Visualize.Mods do 2 | @shortdoc "Visualizes cross-module dependencies in one or more boundaries." 3 | 4 | @moduledoc """ 5 | #{@shortdoc} 6 | 7 | Usage: 8 | 9 | mix boundary.visualize.mods Boundary1 Boundary2 ... 10 | 11 | The graph is printed to the standard output using the [graphviz dot language](https://graphviz.org/doc/info/lang.html). 12 | """ 13 | 14 | use Boundary, classify_to: Boundary.Mix 15 | use Mix.Task 16 | 17 | alias Boundary.Graph 18 | alias Boundary.Mix.CompilerState 19 | 20 | @impl Mix.Task 21 | def run(argv) do 22 | previous_shell = Mix.shell() 23 | Mix.shell(Mix.Shell.Quiet) 24 | Mix.Task.run("compile") 25 | Mix.shell(previous_shell) 26 | 27 | Boundary.Mix.load_app() 28 | 29 | view = Boundary.Mix.View.build() 30 | boundaries = Enum.map(argv, &Module.concat([&1])) 31 | 32 | state = 33 | for reference <- CompilerState.references(), 34 | boundary_from = Boundary.for_module(view, reference.from), 35 | not is_nil(boundary_from), 36 | boundary_from.name in boundaries, 37 | boundary_to = Boundary.for_module(view, reference.to), 38 | not is_nil(boundary_to), 39 | boundary_to.name in boundaries, 40 | reduce: %{main: Graph.new(""), subgraphs: %{}} do 41 | state -> 42 | state 43 | |> add_node(boundary_from.name, reference.from) 44 | |> add_node(boundary_to.name, reference.to) 45 | |> add_dependency(reference.from, reference.to) 46 | end 47 | 48 | Enum.reduce(Map.values(state.subgraphs), state.main, &Graph.add_subgraph(&2, &1)) 49 | |> Graph.dot() 50 | |> Mix.shell().info() 51 | end 52 | 53 | defp add_node(state, subgraph_name, node) do 54 | subgraph = 55 | state.subgraphs 56 | |> Map.get_lazy(subgraph_name, fn -> Graph.new("Boundary #{inspect(subgraph_name)}") end) 57 | |> Graph.add_node(inspect(node), label: List.last(Module.split(node))) 58 | 59 | Map.update!(state, :subgraphs, &Map.put(&1, subgraph_name, subgraph)) 60 | end 61 | 62 | defp add_dependency(state, caller, callee), 63 | do: Map.update!(state, :main, &Graph.add_dependency(&1, inspect(caller), inspect(callee))) 64 | end 65 | -------------------------------------------------------------------------------- /lib/boundary/mix/view.ex: -------------------------------------------------------------------------------- 1 | defmodule Boundary.Mix.View do 2 | @moduledoc false 3 | alias Boundary.Mix.{Classifier, CompilerState} 4 | 5 | @spec build() :: Boundary.view() 6 | def build do 7 | main_app = Boundary.Mix.app_name() 8 | 9 | module_to_app = 10 | for {app, _description, _vsn} <- Application.loaded_applications(), 11 | module <- app_modules(app, main_app), 12 | into: %{}, 13 | do: {module, app} 14 | 15 | classifier = classify(main_app, module_to_app) 16 | main_app_boundaries = classifier.boundaries |> Map.values() |> Enum.filter(&(&1.app == main_app)) 17 | 18 | %{ 19 | version: unquote(Mix.Project.config()[:version]), 20 | main_app: main_app, 21 | classifier: classifier, 22 | unclassified_modules: nil, 23 | module_to_app: module_to_app, 24 | external_deps: all_external_deps(main_app, main_app_boundaries, module_to_app), 25 | boundary_defs: nil, 26 | protocol_impls: nil 27 | } 28 | |> load_main_app_cache() 29 | |> then(&Map.update!(&1, :unclassified_modules, fn _ -> unclassified_modules(&1) end)) 30 | end 31 | 32 | defp load_main_app_cache(view) do 33 | boundary_defs = CompilerState.boundary_defs(view.main_app) 34 | protocol_impls = CompilerState.protocol_impls(view.main_app) 35 | %{view | boundary_defs: boundary_defs, protocol_impls: protocol_impls} 36 | end 37 | 38 | @spec refresh([Application.app()], force: boolean) :: Boundary.view() 39 | def refresh(user_apps, opts) do 40 | manifest_file = "boundary_view_v2" 41 | 42 | view = 43 | with false <- Keyword.get(opts, :force, false), 44 | view = Boundary.Mix.read_manifest(manifest_file), 45 | %{version: unquote(Mix.Project.config()[:version])} <- view, 46 | %{} = view <- do_refresh(view, user_apps) do 47 | view 48 | else 49 | _ -> 50 | Boundary.Mix.load_app() 51 | build() 52 | end 53 | 54 | stored_view = 55 | Enum.reduce( 56 | user_apps, 57 | %{view | unclassified_modules: MapSet.new(), boundary_defs: %{}, protocol_impls: %{}}, 58 | &drop_app(&2, &1) 59 | ) 60 | 61 | Boundary.Mix.write_manifest(manifest_file, stored_view) 62 | 63 | view 64 | end 65 | 66 | defp do_refresh(%{version: unquote(Mix.Project.config()[:version])} = view, apps) do 67 | view = load_main_app_cache(view) 68 | 69 | module_to_app = 70 | for {app, _description, _vsn} <- Application.loaded_applications(), 71 | module <- app_modules(app, view.main_app), 72 | into: view.module_to_app, 73 | do: {module, app} 74 | 75 | main_app_modules = main_app_modules(view.main_app) 76 | main_app_boundaries = load_app_boundaries(view.main_app, main_app_modules, module_to_app) 77 | 78 | if MapSet.equal?(view.external_deps, all_external_deps(view.main_app, main_app_boundaries, module_to_app)) do 79 | view = 80 | Enum.reduce( 81 | apps, 82 | %{view | module_to_app: module_to_app}, 83 | fn app, view -> 84 | app_modules = app_modules(app, view.main_app) 85 | 86 | module_to_app = for module <- app_modules, into: view.module_to_app, do: {module, app} 87 | app_boundaries = load_app_boundaries(app, app_modules, module_to_app) 88 | classifier = Classifier.classify(view.classifier, app, app_modules, app_boundaries) 89 | %{view | classifier: classifier, module_to_app: module_to_app} 90 | end 91 | ) 92 | 93 | unclassified_modules = unclassified_modules(view) 94 | %{view | unclassified_modules: unclassified_modules} 95 | else 96 | nil 97 | end 98 | end 99 | 100 | defp drop_app(view, app) do 101 | modules_to_delete = for {module, ^app} <- view.module_to_app, do: module 102 | module_to_app = Map.drop(view.module_to_app, modules_to_delete) 103 | classifier = Classifier.delete(view.classifier, app) 104 | %{view | classifier: classifier, module_to_app: module_to_app} 105 | end 106 | 107 | defp classify(main_app, module_to_app) do 108 | main_app_modules = main_app_modules(main_app) 109 | main_app_boundaries = load_app_boundaries(main_app, main_app_modules, module_to_app) 110 | 111 | classifier = classify_external_deps(main_app_boundaries, module_to_app) 112 | Classifier.classify(classifier, main_app, main_app_modules, main_app_boundaries) 113 | end 114 | 115 | defp classify_external_deps(main_app_boundaries, module_to_app) do 116 | Enum.reduce( 117 | load_external_boundaries(main_app_boundaries, module_to_app), 118 | Classifier.new(), 119 | &Classifier.classify(&2, &1.app, &1.modules, &1.boundaries) 120 | ) 121 | end 122 | 123 | defp all_external_deps(main_app, main_app_boundaries, module_to_app) do 124 | for boundary <- main_app_boundaries, 125 | {dep, _} <- boundary.deps, 126 | Map.get(module_to_app, dep) != main_app, 127 | into: MapSet.new(), 128 | do: dep 129 | end 130 | 131 | defp load_app_boundaries(app_name, modules, module_to_app) do 132 | boundary_defs = CompilerState.boundary_defs(app_name) 133 | 134 | for module <- modules, boundary = Boundary.Definition.get(module, boundary_defs) do 135 | check_apps = 136 | for {dep_name, _mode} <- boundary.deps, 137 | app = Map.get(module_to_app, dep_name), 138 | app not in [nil, app_name], 139 | reduce: boundary.check.apps do 140 | check_apps -> [{app, :compile}, {app, :runtime} | check_apps] 141 | end 142 | 143 | Map.merge(boundary, %{ 144 | name: module, 145 | implicit?: false, 146 | modules: [], 147 | check: %{boundary.check | apps: Enum.sort(Enum.uniq(check_apps))} 148 | }) 149 | end 150 | end 151 | 152 | defp load_external_boundaries(main_app_boundaries, module_to_app) do 153 | # fetch and index all deps 154 | all_deps = 155 | for user_boundary <- main_app_boundaries, 156 | {dep, _type} <- user_boundary.deps, 157 | into: %{}, 158 | do: {dep, user_boundary} 159 | 160 | # create app -> [boundary] mapping which will be used to determine implicit boundaries 161 | implicit_boundaries = 162 | for {dep, user_boundary} <- all_deps, 163 | boundary_app = Map.get(module_to_app, dep), 164 | reduce: %{} do 165 | acc -> Map.update(acc, boundary_app, [{dep, user_boundary}], &[{dep, user_boundary} | &1]) 166 | end 167 | 168 | Enum.map( 169 | for(boundary <- main_app_boundaries, {app, _} <- boundary.check.apps, into: MapSet.new(), do: app), 170 | fn app -> 171 | modules = Boundary.Mix.app_modules(app) 172 | 173 | boundaries = 174 | with [] <- load_app_boundaries(app, modules, module_to_app) do 175 | # app defines no boundaries -> we'll use implicit boundaries from all deps pointing to modules of this app 176 | implicit_boundaries 177 | |> Map.get(app, []) 178 | |> Enum.map(fn 179 | {dep, _user_boundary} -> 180 | app 181 | |> Boundary.Definition.normalize(dep, []) 182 | |> Map.merge(%{name: dep, implicit?: true, top_level?: true, exports: [dep]}) 183 | end) 184 | end 185 | 186 | %{app: app, modules: modules, boundaries: boundaries} 187 | end 188 | ) 189 | end 190 | 191 | defp unclassified_modules(view) do 192 | # gather unclassified modules of this app 193 | for module <- main_app_modules(view.main_app), 194 | not Map.has_key?(view.classifier.modules, module), 195 | not Boundary.protocol_impl?(view, module), 196 | into: MapSet.new(), 197 | do: module 198 | end 199 | 200 | defp app_modules(main_app, main_app), do: main_app_modules(main_app) 201 | defp app_modules(app, _main_app), do: Boundary.Mix.app_modules(app) 202 | 203 | defp main_app_modules(app) do 204 | # We take only explicitly defined modules which we've seen in the compilation tracer. This 205 | # allows us to drop some implicit modules, such as protocol implementations which are injected 206 | # via `@derive`. 207 | 208 | MapSet.intersection( 209 | # all app modules 210 | MapSet.new(Boundary.Mix.app_modules(app)), 211 | 212 | # encountered modules (explicitly defined via defmodule, defimpl, or some other macro) 213 | MapSet.new(CompilerState.encountered_modules(app)) 214 | ) 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Boundary.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.10.4" 5 | 6 | def project do 7 | [ 8 | app: :boundary, 9 | version: @version, 10 | elixir: "~> 1.10", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | compilers: Mix.compilers(), 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | docs: docs(), 16 | dialyzer: dialyzer(), 17 | package: package(), 18 | aliases: aliases() 19 | ] 20 | end 21 | 22 | # Run "mix help compile.app" to learn about applications. 23 | def application do 24 | [ 25 | extra_applications: [:logger] 26 | ] 27 | end 28 | 29 | # Run "mix help deps" to learn about dependencies. 30 | defp deps do 31 | [ 32 | {:dialyxir, "~> 1.0", only: :dev, runtime: false}, 33 | {:ex_doc, "~> 0.21", only: :dev, runtime: false}, 34 | {:credo, "~> 1.1", only: [:dev, :test], runtime: false} 35 | ] 36 | end 37 | 38 | defp elixirc_paths(:test), do: ~w(lib test/support) 39 | defp elixirc_paths(_), do: ~w(lib) 40 | 41 | defp docs() do 42 | [ 43 | main: "Boundary", 44 | extras: ["README.md"], 45 | source_url: "https://github.com/sasa1977/boundary/", 46 | source_ref: @version 47 | ] 48 | end 49 | 50 | defp package() do 51 | [ 52 | description: "Managing cross-module dependencies in Elixir projects.", 53 | maintainers: ["Saša Jurić"], 54 | licenses: ["MIT"], 55 | links: %{ 56 | "Github" => "https://github.com/sasa1977/boundary", 57 | "Changelog" => 58 | "https://github.com/sasa1977/boundary/blob/#{@version}/CHANGELOG.md##{String.replace(@version, ".", "")}" 59 | } 60 | ] 61 | end 62 | 63 | defp dialyzer() do 64 | [ 65 | plt_add_apps: [:mix], 66 | ignore_warnings: ".dialyzer_ignore" 67 | ] 68 | end 69 | 70 | defp aliases do 71 | [docs: ["docs", fn _ -> File.cp_r!("images", "doc/images") end]] 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 3 | "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, 4 | "dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"}, 5 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.33", "3c3fd9673bb5dcc9edc28dd90f50c87ce506d1f71b70e3de69aa8154bc695d44", [:mix], [], "hexpm", "2d526833729b59b9fdb85785078697c72ac5e5066350663e5be6a1182da61b8f"}, 7 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 8 | "ex_doc": {:hex, :ex_doc, "0.30.5", "aa6da96a5c23389d7dc7c381eba862710e108cee9cfdc629b7ec021313900e9e", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "88a1e115dcb91cefeef7e22df4a6ebbe4634fbf98b38adcbc25c9607d6d9d8e6"}, 9 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 10 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 11 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 15 | "stream_data": {:hex, :stream_data, "0.4.3", "62aafd870caff0849a5057a7ec270fad0eb86889f4d433b937d996de99e3db25", [:mix], [], "hexpm"}, 16 | } 17 | -------------------------------------------------------------------------------- /test/boundary/graph_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Boundary.GraphTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Boundary.Graph 5 | 6 | describe "dot/1" do 7 | test "generate dot output" do 8 | dot = 9 | Graph.new("test") 10 | |> Graph.add_node("A") 11 | |> Graph.add_node("B") 12 | |> Graph.add_node("C") 13 | |> Graph.add_dependency("A", "B") 14 | |> Graph.add_dependency("A", "C") 15 | |> Graph.add_dependency("B", "C") 16 | |> Graph.dot() 17 | 18 | assert dot == 19 | """ 20 | digraph { 21 | label="test"; 22 | labelloc=top; 23 | rankdir=LR; 24 | 25 | "A" [shape=box]; 26 | "B" [shape=box]; 27 | "C" [shape=box]; 28 | 29 | "A" -> "B"; 30 | "A" -> "C"; 31 | "B" -> "C"; 32 | } 33 | """ 34 | end 35 | 36 | test "generate dot output with options and one subgraph" do 37 | subgraph1 = 38 | Graph.new("subgraph_cluster_1") 39 | |> Graph.add_node("C") 40 | |> Graph.add_node("D") 41 | |> Graph.add_dependency("C", "D") 42 | 43 | subgraph2 = Graph.new("subgraph_cluster_2") 44 | 45 | dot = 46 | Graph.new("test") 47 | |> Graph.add_node("A") 48 | |> Graph.add_node("B") 49 | |> Graph.add_dependency("A", "B", label: "compile", test: "test") 50 | |> Graph.add_dependency("A", "C", label: "compile") 51 | |> Graph.add_subgraph(subgraph1) 52 | |> Graph.add_subgraph(subgraph2) 53 | |> Graph.dot(indent: 0, type: :digraph) 54 | 55 | assert dot == 56 | """ 57 | digraph { 58 | label="test"; 59 | labelloc=top; 60 | rankdir=LR; 61 | 62 | "A" [shape=box]; 63 | "B" [shape=box]; 64 | 65 | "A" -> "B" [label=compile, test=test]; 66 | "A" -> "C" [label=compile]; 67 | 68 | subgraph cluster_0 { 69 | label="subgraph_cluster_1"; 70 | labelloc=top; 71 | rankdir=LR; 72 | 73 | "C" [shape=box]; 74 | "D" [shape=box]; 75 | 76 | "C" -> "D"; 77 | } 78 | 79 | subgraph cluster_1 { 80 | label="subgraph_cluster_2"; 81 | labelloc=top; 82 | rankdir=LR; 83 | } 84 | } 85 | """ 86 | end 87 | 88 | test "generate dot output with options and 2 subgraphs" do 89 | subgraph1 = 90 | Graph.new("subgraph_cluster_1") 91 | |> Graph.add_node("D") 92 | |> Graph.add_node("E") 93 | |> Graph.add_dependency("D", "E") 94 | 95 | subgraph = 96 | Graph.new("subgraph_cluster_1") 97 | |> Graph.add_node("C") 98 | |> Graph.add_dependency("C", "D") 99 | |> Graph.add_subgraph(subgraph1) 100 | 101 | dot = 102 | Graph.new("test") 103 | |> Graph.add_node("A") 104 | |> Graph.add_node("B") 105 | |> Graph.add_dependency("A", "B", label: "compile", test: "test") 106 | |> Graph.add_dependency("A", "C", label: "compile") 107 | |> Graph.add_subgraph(subgraph) 108 | |> Graph.dot(indent: 0) 109 | 110 | assert dot == 111 | """ 112 | digraph { 113 | label="test"; 114 | labelloc=top; 115 | rankdir=LR; 116 | 117 | "A" [shape=box]; 118 | "B" [shape=box]; 119 | 120 | "A" -> "B" [label=compile, test=test]; 121 | "A" -> "C" [label=compile]; 122 | 123 | subgraph cluster_0 { 124 | label="subgraph_cluster_1"; 125 | labelloc=top; 126 | rankdir=LR; 127 | 128 | "C" [shape=box]; 129 | 130 | "C" -> "D"; 131 | 132 | subgraph cluster_0 { 133 | label="subgraph_cluster_1"; 134 | labelloc=top; 135 | rankdir=LR; 136 | 137 | "D" [shape=box]; 138 | "E" [shape=box]; 139 | 140 | "D" -> "E"; 141 | } 142 | } 143 | } 144 | """ 145 | end 146 | 147 | test "generate dot output without options" do 148 | dot = 149 | Graph.new("test") 150 | |> Graph.add_node("A") 151 | |> Graph.add_node("B") 152 | |> Graph.add_dependency("A", "B") 153 | |> Graph.dot() 154 | 155 | assert dot == 156 | """ 157 | digraph { 158 | label="test"; 159 | labelloc=top; 160 | rankdir=LR; 161 | 162 | "A" [shape=box]; 163 | "B" [shape=box]; 164 | 165 | "A" -> "B"; 166 | } 167 | """ 168 | end 169 | 170 | test "succeeds for an empty graph" do 171 | dot = Graph.dot(Graph.new("test")) 172 | 173 | assert dot == 174 | """ 175 | digraph { 176 | label="test"; 177 | labelloc=top; 178 | rankdir=LR; 179 | } 180 | """ 181 | end 182 | 183 | test "deduplicated dependencies" do 184 | dot = 185 | Graph.new("test") 186 | |> Graph.add_node("A") 187 | |> Graph.add_node("B") 188 | |> Graph.add_dependency("A", "B") 189 | |> Graph.add_dependency("A", "B") 190 | |> Graph.dot() 191 | 192 | assert dot == 193 | """ 194 | digraph { 195 | label="test"; 196 | labelloc=top; 197 | rankdir=LR; 198 | 199 | "A" [shape=box]; 200 | "B" [shape=box]; 201 | 202 | "A" -> "B"; 203 | } 204 | """ 205 | end 206 | 207 | test "add single node with no connections" do 208 | dot = 209 | Graph.new("test") 210 | |> Graph.add_node("A") 211 | |> Graph.add_node("B") 212 | |> Graph.add_node("C") 213 | |> Graph.add_dependency("A", "B") 214 | |> Graph.dot() 215 | 216 | assert dot == 217 | """ 218 | digraph { 219 | label="test"; 220 | labelloc=top; 221 | rankdir=LR; 222 | 223 | "A" [shape=box]; 224 | "B" [shape=box]; 225 | "C" [shape=box]; 226 | 227 | "A" -> "B"; 228 | } 229 | """ 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /test/mix/tasks/ex_doc_groups_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Boundary.ExDocGroupsTest do 2 | use ExUnit.Case, async: false 3 | alias Boundary.TestProject 4 | 5 | @module_setup """ 6 | defmodule Boundary1 do 7 | use Boundary, deps: [], exports: [] 8 | 9 | defmodule Foo do 10 | defmodule Bar do end 11 | end 12 | defmodule Bar do end 13 | defmodule Baz do end 14 | end 15 | 16 | defmodule Boundary2 do 17 | use Boundary, deps: [], exports: [] 18 | 19 | defmodule Foo do end 20 | end 21 | 22 | defmodule Boundary3 do 23 | use Boundary, deps: [], exports: [] 24 | 25 | defmodule Foo do end 26 | defmodule Bar do end 27 | end 28 | 29 | defmodule Boundary3.InnerBoundary do 30 | use Boundary, deps: [], exports: [] 31 | 32 | defmodule Foo do end 33 | end 34 | 35 | defmodule Ignored do 36 | use Boundary, check: [in: false, out: false] 37 | end 38 | """ 39 | 40 | test "the mix task produces the correct output" do 41 | Mix.shell(Mix.Shell.Process) 42 | Logger.disable(self()) 43 | 44 | TestProject.in_project(fn project -> 45 | File.write!( 46 | Path.join([project.path, "lib", "source.ex"]), 47 | @module_setup 48 | ) 49 | 50 | assert TestProject.run_task("boundary.ex_doc_groups").output =~ "* creating boundary.exs" 51 | end) 52 | end 53 | 54 | test "when evaled produces the correct data" do 55 | Mix.shell(Mix.Shell.Process) 56 | Logger.disable(self()) 57 | 58 | TestProject.in_project(fn project -> 59 | File.write!( 60 | Path.join([project.path, "lib", "source.ex"]), 61 | @module_setup 62 | ) 63 | 64 | TestProject.run_task("boundary.ex_doc_groups") 65 | 66 | {groups, _} = Code.eval_file("boundary.exs") 67 | 68 | assert [ 69 | Boundary1: [Boundary1, Boundary1.Bar, Boundary1.Baz, Boundary1.Foo, Boundary1.Foo.Bar], 70 | Boundary2: [Boundary2, Boundary2.Foo], 71 | Boundary3: [Boundary3, Boundary3.Bar, Boundary3.Foo], 72 | "Boundary3.InnerBoundary": [Boundary3.InnerBoundary, Boundary3.InnerBoundary.Foo] 73 | ] = groups 74 | end) 75 | end 76 | 77 | test "is formatted correctly" do 78 | Mix.shell(Mix.Shell.Process) 79 | Logger.disable(self()) 80 | 81 | TestProject.in_project(fn project -> 82 | File.write!( 83 | Path.join([project.path, "lib", "source.ex"]), 84 | @module_setup 85 | ) 86 | 87 | TestProject.run_task("boundary.ex_doc_groups") 88 | 89 | assert """ 90 | # Generated by `mix boundary.ex_doc_groups` 91 | [ 92 | Boundary1: [Boundary1, Boundary1.Bar, Boundary1.Baz, Boundary1.Foo, Boundary1.Foo.Bar], 93 | Boundary2: [Boundary2, Boundary2.Foo], 94 | Boundary3: [Boundary3, Boundary3.Bar, Boundary3.Foo], 95 | "Boundary3.InnerBoundary": [Boundary3.InnerBoundary, Boundary3.InnerBoundary.Foo] 96 | ] 97 | """ = File.read!("boundary.exs") 98 | end) 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/mix/tasks/find_external_deps_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Boundary.FindExternalDepsTest do 2 | use ExUnit.Case, async: false 3 | alias Boundary.TestProject 4 | 5 | test "produces the expected output" do 6 | Mix.shell(Mix.Shell.Process) 7 | Logger.disable(self()) 8 | 9 | TestProject.in_project(fn project -> 10 | File.write!( 11 | Path.join([project.path, "lib", "source.ex"]), 12 | """ 13 | defmodule Boundary1 do 14 | use Boundary 15 | 16 | def fun() do 17 | require Logger 18 | Logger.info("foo") 19 | end 20 | end 21 | 22 | defmodule Boundary2 do 23 | use Boundary 24 | end 25 | """ 26 | ) 27 | 28 | output = 29 | TestProject.run_task("boundary.find_external_deps").output 30 | |> String.split("\n") 31 | |> Enum.map_join("\n", &String.trim_trailing/1) 32 | 33 | assert output =~ 34 | """ 35 | #{[IO.ANSI.bright()]}Boundary1#{IO.ANSI.reset()}: 36 | :logger 37 | 38 | #{[IO.ANSI.bright()]}Boundary2#{IO.ANSI.reset()} - no external deps 39 | """ 40 | end) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/mix/tasks/spec_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Boundary.SpecTest do 2 | use ExUnit.Case, async: false 3 | alias Boundary.TestProject 4 | 5 | test "produces the expected output" do 6 | Mix.shell(Mix.Shell.Process) 7 | Logger.disable(self()) 8 | 9 | TestProject.in_project(fn project -> 10 | File.write!( 11 | Path.join([project.path, "lib", "source.ex"]), 12 | """ 13 | defmodule Boundary1 do 14 | use Boundary, deps: [Boundary2], exports: [Foo, Bar] 15 | 16 | defmodule Foo do end 17 | defmodule Bar do end 18 | end 19 | 20 | defmodule Boundary2 do 21 | use Boundary, deps: [], exports: [], check: [apps: [:logger]] 22 | end 23 | 24 | defmodule Ignored do 25 | use Boundary, check: [out: false, in: false] 26 | end 27 | """ 28 | ) 29 | 30 | output = 31 | TestProject.run_task("boundary.spec").output 32 | |> String.split("\n") 33 | |> Enum.map_join("\n", &String.trim_trailing/1) 34 | 35 | assert output =~ 36 | """ 37 | Boundary1 38 | exports: Bar, Foo 39 | deps: Boundary2 40 | 41 | Boundary2 42 | exports: 43 | deps: 44 | 45 | Ignored 46 | exports: not checked 47 | deps: not checked 48 | """ 49 | end) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/mix/tasks/visualize/funs_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Boundary.Visualize.FunsTest do 2 | use ExUnit.Case, async: false 3 | alias Boundary.TestProject 4 | 5 | test "produces the expected output" do 6 | Mix.shell(Mix.Shell.Quiet) 7 | Logger.disable(self()) 8 | 9 | TestProject.in_project(fn project -> 10 | File.write!( 11 | Path.join([project.path, "lib", "source.ex"]), 12 | """ 13 | defmodule MyMod do 14 | def foo do 15 | bar() 16 | bar() 17 | 18 | baz(1) 19 | end 20 | 21 | def bar, do: baz() 22 | 23 | def baz, do: :ok 24 | def baz(_), do: :ok 25 | end 26 | """ 27 | ) 28 | 29 | Mix.shell(Mix.Shell.Process) 30 | output = TestProject.run_task("boundary.visualize.funs", ["MyMod"]).output 31 | 32 | assert output == """ 33 | digraph { 34 | label="function calls inside MyMod"; 35 | labelloc=top; 36 | rankdir=LR; 37 | 38 | "foo" [shape=box]; 39 | "bar" [shape=box]; 40 | "baz" [shape=box]; 41 | 42 | "foo" -> "bar"; 43 | "foo" -> "baz"; 44 | "bar" -> "baz"; 45 | } 46 | """ 47 | end) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/mix/tasks/visualize/mods_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Boundary.Visualize.ModsTest do 2 | use ExUnit.Case, async: false 3 | alias Boundary.TestProject 4 | 5 | test "produces the expected output" do 6 | Mix.shell(Mix.Shell.Quiet) 7 | Logger.disable(self()) 8 | 9 | TestProject.in_project(fn project -> 10 | File.write!( 11 | Path.join([project.path, "lib", "source.ex"]), 12 | """ 13 | defmodule Foo do 14 | use Boundary, deps: [Bar] 15 | 16 | def fun1, do: Bar.Mod.fun() 17 | 18 | def fun2, do: :ok 19 | 20 | defmodule Mod1 do 21 | def fun, do: Mod2.fun() 22 | end 23 | 24 | defmodule Mod2 do 25 | def fun, do: :ok 26 | end 27 | end 28 | 29 | defmodule Bar do 30 | use Boundary, exports: [Mod] 31 | 32 | defmodule Mod do 33 | def fun, do: :ok 34 | end 35 | end 36 | """ 37 | ) 38 | 39 | TestProject.run_task("compile") 40 | Mix.shell(Mix.Shell.Process) 41 | 42 | assert TestProject.run_task("boundary.visualize.mods", ~w/Foo Bar/).output == 43 | """ 44 | digraph { 45 | label=""; 46 | labelloc=top; 47 | rankdir=LR; 48 | 49 | "Foo" -> "Bar.Mod"; 50 | 51 | subgraph cluster_0 { 52 | label="Boundary Foo"; 53 | labelloc=top; 54 | rankdir=LR; 55 | 56 | "Foo" [shape=box, label=Foo]; 57 | } 58 | 59 | subgraph cluster_1 { 60 | label="Boundary Bar"; 61 | labelloc=top; 62 | rankdir=LR; 63 | 64 | "Bar.Mod" [shape=box, label=Mod]; 65 | } 66 | } 67 | """ 68 | end) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/mix/tasks/visualize_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Boundary.VisualizeTest do 2 | use ExUnit.Case, async: false 3 | alias Boundary.TestProject 4 | 5 | test "produces the expected files output" do 6 | Mix.shell(Mix.Shell.Process) 7 | Logger.disable(self()) 8 | 9 | TestProject.in_project(fn project -> 10 | File.write!( 11 | Path.join([project.path, "lib", "source.ex"]), 12 | """ 13 | defmodule BlogEngine do 14 | use Boundary 15 | 16 | defmodule Repo do 17 | use Boundary 18 | end 19 | 20 | defmodule Accounts do 21 | use Boundary, deps: [Repo, {Mix, :compile}] 22 | end 23 | 24 | defmodule Articles do 25 | use Boundary, deps: [BlogEngine, Repo, Accounts] 26 | end 27 | end 28 | 29 | defmodule BlogEngineWeb do 30 | use Boundary, deps: [BlogEngine], exports: [] 31 | end 32 | 33 | defmodule BlogEngineApp do 34 | use Boundary, deps: [BlogEngineWeb, BlogEngine, {Mix, :compile}], exports: [] 35 | end 36 | """ 37 | ) 38 | 39 | TestProject.run_task("boundary.visualize") 40 | 41 | test_output_file( 42 | Path.join([project.path, "boundary", "app.dot"]), 43 | """ 44 | digraph { 45 | label="#{project.app} application"; 46 | labelloc=top; 47 | rankdir=LR; 48 | 49 | "BlogEngine" [shape=box]; 50 | "BlogEngineApp" [shape=box]; 51 | "BlogEngineWeb" [shape=box]; 52 | 53 | "BlogEngineApp" -> "BlogEngine"; 54 | "BlogEngineApp" -> "BlogEngineWeb"; 55 | "BlogEngineWeb" -> "BlogEngine"; 56 | } 57 | """ 58 | ) 59 | 60 | test_output_file( 61 | Path.join([project.path, "boundary", "BlogEngine.dot"]), 62 | """ 63 | digraph { 64 | label="BlogEngine boundary"; 65 | labelloc=top; 66 | rankdir=LR; 67 | 68 | "Accounts" [shape=box]; 69 | "Articles" [shape=box]; 70 | "Repo" [shape=box]; 71 | 72 | "Accounts" -> "Repo"; 73 | "Articles" -> "Accounts"; 74 | "Articles" -> "Repo"; 75 | } 76 | """ 77 | ) 78 | end) 79 | end 80 | 81 | defp test_output_file(path, content) do 82 | assert File.exists?(path) 83 | assert File.read!(path) =~ content 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /test/support/compiler_tester.ex: -------------------------------------------------------------------------------- 1 | defmodule Boundary.CompilerTester do 2 | @moduledoc """ 3 | This is a special helper made for the purpose of compiler tests. 4 | 5 | The challenge of compiler test is that we want to perform many mix compilations, which makes the tests very slow. To 6 | address this, this module provides a special `module_test` macro which allows us to define multiple tests that will 7 | evaluate the output of a single compilation. 8 | 9 | This approach has its set of problems, as can be seen from the `Mix.Tasks.Compile.BoundaryTest` code, but it gives us 10 | a solid compromise, allowing us to test the full feature, while keeping the test execution time fairly low. 11 | """ 12 | 13 | # credo:disable-for-this-file Credo.Check.Readability.Specs 14 | alias Boundary.TestProject 15 | 16 | defmacro __using__(_opts) do 17 | quote do 18 | @before_compile unquote(__MODULE__) 19 | 20 | Module.register_attribute(__MODULE__, :tests, accumulate: true, persist: true) 21 | import unquote(__MODULE__) 22 | alias Boundary.TestProject 23 | end 24 | end 25 | 26 | defmacro __before_compile__(_) do 27 | quote do 28 | defp __tests__, do: @tests 29 | end 30 | end 31 | 32 | defmacro compile_project(project) do 33 | quote do 34 | project = unquote(project) 35 | for {file, code} <- __tests__(), do: File.write!(Path.join([project.path, "lib", file]), code) 36 | 37 | TestProject.compile() 38 | end 39 | end 40 | 41 | defmacro module_test(desc, code, context \\ quote(do: _), do: block) do 42 | file = "file_#{:erlang.unique_integer([:positive, :monotonic])}.ex" 43 | 44 | quote do 45 | @tests {unquote(file), unquote(code)} 46 | 47 | test unquote(desc), unquote(context) = context do 48 | var!(warnings) = 49 | context.warnings 50 | |> Enum.filter(&(&1.file == "lib/#{unquote(file)}")) 51 | |> Enum.map(&Map.delete(&1, :file)) 52 | |> Enum.map(&%{&1 | message: &1.message}) 53 | 54 | # dummy expression to suppress warnings if `warnings` is not used 55 | _ = var!(warnings) 56 | 57 | unquote(block) 58 | end 59 | end 60 | end 61 | 62 | def unique_module_name, do: "Module#{:erlang.unique_integer([:positive, :monotonic])}" 63 | end 64 | -------------------------------------------------------------------------------- /test/support/test_project.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file Credo.Check.Readability.Specs 2 | defmodule Boundary.TestProject do 3 | @moduledoc false 4 | 5 | def in_project(opts \\ [], fun) do 6 | loaded_apps_before = Enum.into(Application.loaded_applications(), MapSet.new(), fn {app, _, _} -> app end) 7 | %{name: name, file: file} = Mix.Project.pop() 8 | tmp_path = Path.absname("tmp/#{:erlang.unique_integer(~w/positive monotonic/a)}") 9 | 10 | app_name = "test_project_#{:erlang.unique_integer(~w/positive monotonic/a)}" 11 | app = String.to_atom(app_name) 12 | project = %{app: app, path: Path.join(tmp_path, app_name)} 13 | 14 | try do 15 | File.rm_rf(project.path) 16 | 17 | Mix.Task.clear() 18 | :ok = Mix.Tasks.New.run([project.path]) 19 | reinitialize(project, opts) 20 | 21 | Mix.Project.in_project(app, project.path, [], fn _module -> fun.(project) end) 22 | after 23 | Mix.Project.push(name, file) 24 | 25 | Application.loaded_applications() 26 | |> Enum.into(MapSet.new(), fn {app, _, _} -> app end) 27 | |> MapSet.difference(loaded_apps_before) 28 | |> Enum.each(&Application.unload/1) 29 | 30 | File.rm_rf(tmp_path) 31 | end 32 | end 33 | 34 | def compile(opts \\ []) do 35 | run_task("compile", ["--return-errors" | opts]) 36 | end 37 | 38 | def run_task(task, args \\ []) do 39 | ref = make_ref() 40 | 41 | ExUnit.CaptureIO.capture_io(:stderr, fn -> 42 | Mix.Task.clear() 43 | send(self(), {ref, Mix.Task.run(task, args)}) 44 | end) 45 | 46 | receive do 47 | {^ref, result} -> 48 | {warnings, errors} = 49 | case result do 50 | :ok -> {[], []} 51 | {:ok, result} -> {result, []} 52 | {:error, errors} -> {[], Enum.map(errors, & &1.message)} 53 | end 54 | 55 | output = 56 | Stream.repeatedly(fn -> 57 | receive do 58 | {:mix_shell, :info, msg} -> msg 59 | after 60 | 0 -> nil 61 | end 62 | end) 63 | |> Enum.take_while(&(not is_nil(&1))) 64 | |> to_string 65 | 66 | %{output: output, warnings: warnings, errors: errors} 67 | after 68 | 0 -> raise("result not received") 69 | end 70 | end 71 | 72 | defp reinitialize(project, opts) do 73 | File.write!(Path.join(project.path, "mix.exs"), mix_exs(project.app, Keyword.get(opts, :mix_opts, []))) 74 | File.rm_rf(Path.join(project.path, "lib")) 75 | File.mkdir_p!(Path.join(project.path, "lib")) 76 | end 77 | 78 | defp mix_exs(project_name, opts) do 79 | """ 80 | defmodule #{Macro.camelize(to_string(project_name))}.MixProject do 81 | use Mix.Project 82 | 83 | def project do 84 | [ 85 | app: :#{project_name}, 86 | version: "0.1.0", 87 | elixir: "~> 1.10", 88 | start_permanent: Mix.env() == :prod, 89 | deps: deps(), 90 | compilers: #{inspect(Keyword.get(opts, :compilers, [:boundary]))} ++ Mix.compilers() 91 | ] ++ #{inspect(Keyword.get(opts, :project_opts, []))} 92 | end 93 | 94 | def application do 95 | [ 96 | extra_applications: [:logger] 97 | ] 98 | end 99 | 100 | defp deps do 101 | #{inspect([{:boundary, path: unquote(Path.absname("."))} | Keyword.get(opts, :deps, [])])} 102 | end 103 | end 104 | """ 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------