Repository: akoutmos/doctor Branch: master Commit: 0e3750ca205b Files: 50 Total size: 143.3 KB Directory structure: gitextract_0by5ihkr/ ├── .doctor.exs ├── .formatter.exs ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── master.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config/ │ └── config.exs ├── coveralls.json ├── lib/ │ ├── cli/ │ │ └── cli.ex │ ├── config.ex │ ├── docs.ex │ ├── doctor.ex │ ├── mix/ │ │ └── tasks/ │ │ ├── doctor.ex │ │ ├── doctor.explain.ex │ │ └── doctor.gen.config.ex │ ├── module_information.ex │ ├── module_report.ex │ ├── report_utils.ex │ ├── reporter.ex │ ├── reporters/ │ │ ├── full.ex │ │ ├── module_explain.ex │ │ ├── output_utils.ex │ │ ├── short.ex │ │ └── summary.ex │ └── specs.ex ├── mix.exs └── test/ ├── config_test.exs ├── configs/ │ ├── exceptions_moduledoc_not_required.exs │ └── exceptions_moduledoc_required.exs ├── mix_doctor_test.exs ├── module_information_test.exs ├── module_report_test.exs ├── report_utils_test.exs ├── sample_files/ │ ├── all_docs.ex │ ├── another_behaviour_module.ex │ ├── behaviour_module.ex │ ├── custom_behaviour_module.ex │ ├── derive_protocol.ex │ ├── exception.ex │ ├── implement_protocol.ex │ ├── nested_module.ex │ ├── no_docs.ex │ ├── no_struct_spec_module.ex │ ├── opaque_struct_spec_module.ex │ ├── partial_docs.ex │ ├── struct_spec_module.ex │ └── use_module.ex └── test_helper.exs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .doctor.exs ================================================ %Doctor.Config{ ignore_modules: [], ignore_paths: [ ~r(^test/sample_files/.*) ], min_module_doc_coverage: 80, min_module_spec_coverage: 0, min_overall_doc_coverage: 100, min_overall_moduledoc_coverage: 100, min_overall_spec_coverage: 0, exception_moduledoc_required: true, raise: false, reporter: Doctor.Reporters.Full, struct_type_spec_required: true, umbrella: false } ================================================ FILE: .formatter.exs ================================================ # Used by "mix format" [ line_length: 120, inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] ] ================================================ FILE: .github/FUNDING.yml ================================================ github: [akoutmos] ================================================ FILE: .github/workflows/master.yml ================================================ name: Doctor CI env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} on: push: branches: [master] pull_request: branches: [master] jobs: static_analysis: name: Static Analysis runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 - name: Set up Elixir uses: erlef/setup-beam@v1 with: elixir-version: "1.17" otp-version: "27" - name: Restore dependencies cache uses: actions/cache@v2 with: path: deps key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} restore-keys: ${{ runner.os }}-mix- - name: Install dependencies run: mix deps.get - name: Mix Formatter run: mix format --check-formatted - name: Check for compiler warnings run: mix compile --warnings-as-errors - name: Doctor documentation checks run: mix doctor unit_test: name: Run ExUnit tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 - name: Set up Elixir uses: erlef/setup-beam@v1 with: elixir-version: "1.17" otp-version: "27" - name: Restore dependencies cache uses: actions/cache@v2 with: path: deps key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} restore-keys: ${{ runner.os }}-mix- - name: Install dependencies run: mix deps.get - name: ExUnit tests env: MIX_ENV: test run: mix coveralls.github ================================================ FILE: .gitignore ================================================ # The directory Mix will write compiled artifacts to. /_build/ # If you run "mix test --cover", coverage assets end up here. /cover/ # The directory Mix downloads your dependencies sources to. /deps/ # Where 3rd-party dependencies like ExDoc output generated docs. /doc/ # Ignore .fetch files in case you like to edit your project deps locally. /.fetch # If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump # Also ignore archive artifacts (built via "mix archive.build"). *.ez # Ignore package tarball (built via "mix hex.build"). doctor-*.tar ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.22.0] - 2024-10-30 ### Changed - Fix deprecated Elixir `Logger.warn()` to `Logger.warning()`. - Changed minimum Elixir version to 1.14 ## [0.21.0] - 2022-11-19 ### Fixed - Recognize protocol implementations ## [0.20.0] - 2022-10-11 ### Fixed - Recognize `@opaque` struct typespecs. ## [0.19.0] - 2022-7-19 ### Fixed - `mix doctor.explain` now works in umbrella projects - Properly measure documentation coverage in nested modules - Properly measure documentation with `__using__` - Fix `@moduledoc` detection for older elixir versions ## [0.18.0] - 2021-5-27 - @doc false assumes no explicit spec and does not count against results - Support for using macro (thanks to @pnezis) - No reporting of missing docs for exception modules (thanks to @pnezis) ## [0.17.0] - 2021-1-11 - Bumped up the Elixir version due to use of Mix.Task.recursing/0 ## [0.16.0] - 2020-12-27 - Fixed spec coverage bug - Added ability to filter modules using Regex ## [0.15.0] - 2020-6-23 ### Added - Added `mix doctor.explain` command so that it is easier to debug why a particular module is failing validation ### Fixed - Modules with behaviours that are aliased were not being counted properly ## [0.14.0] - 2020-3-19 ### Added - Additional configuration option struct_type_spec_required that checks for struct module type specs ## [0.13.0] - 2020-5-20 ### Fixed - Fixed spec coverage for behavior callbacks ## [0.12.0] - 2020-3-19 ### Added - Ability to aggregate umbrella results into one report - Ability to pass custom path to config file - CLI docs via `mix help doctor` and `mix help doctor.gen.config` ## [0.11.0] - 2020-1-29 ### Added - Ability to pass in a file name as a string for ignore_paths ## [0.10.0] - 2019-11-20 ### Added - Ability to raise from Mix when an error is encountered ## [0.9.0] - 2019-11-11 ### Fixed - .doctor.exs file not found at root of umbrella project ## [0.8.0] - 2019-6-20 ### Fixed - Fixed Decimal math when module contains no doc coverage ## [0.7.0] - 2019-6-10 ### Added - Travis CI and tests ### Fixed - Incorrect reporting on failed modules ## [0.6.0] - 2019-6-5 ### Added - Short reporter ### Fixed - Incorrect spec coverage ## [0.5.0] - 2019-6-2 ### Changed - Fixed counting issue when there are multiple modules in a single file - Changed reporters around to be more DRY and share report calculation functionality - Added tests for Doctor reporting functionality ## [0.4.0] - 2019-1-23 ### Changed - Loaded application vs starting the application to avoid Ecto errors connecting to DB during Doctor validation ## [0.3.0] - 2018-11-30 ### Changed - Updated dependencies and fixed depreciation warning ## [0.2.0] - 2018-11-30 ### Fixed - Umbrella project exit status code ## [0.1.0] - 2018-10-04 ### Added - Initial release of Doctor. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Alexander Koutmos 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: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 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. ================================================ FILE: README.md ================================================ # Doctor [![Module Version](https://img.shields.io/hexpm/v/doctor.svg?style=for-the-badge)](https://hex.pm/packages/doctor) [![Doctor CI](https://img.shields.io/github/actions/workflow/status/akoutmos/doctor/master.yml?label=Build%20Status&style=for-the-badge&branch=master)](https://github.com/akoutmos/doctor/actions) [![Coverage Status](https://img.shields.io/coverallsCoverage/github/akoutmos/doctor.svg?branch=master&style=for-the-badge)](https://coveralls.io/github/akoutmos/doctor?branch=master) [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg?style=for-the-badge)](https://hexdocs.pm/doctor/) [![Total Download](https://img.shields.io/hexpm/dt/doctor.svg?style=for-the-badge)](https://hex.pm/packages/doctor) [![License](https://img.shields.io/hexpm/l/doctor.svg?style=for-the-badge)](https://github.com/akoutmos/doctor/blob/master/LICENSE) Ensure that your documentation is healthy with Doctor! This library contains a mix task that you can run against your project to generate a documentation coverage report. Items which are reported on include: the presence of module docs, which functions do/don't have docs, which functions do/don't have typespecs, and if your struct modules provide typespecs. You can generate a `.doctor.exs` config file to specify what thresholds are acceptable for your project. If documentation coverage drops below your specified thresholds, the `mix doctor` task will return a non zero exit status. The primary motivation with this tool is to have something simple which can be hooked up into CI to ensure that project documentation standards are respected and upheld. This is particular useful in a team environment when you want to maintain a minimum threshold for documentation coverage. ## Installation Adding `:doctor` to your list of dependencies in `mix.exs`: ```elixir def deps do [ {:doctor, "~> 0.22.0", only: :dev} ] end ``` Documentation can be found at [https://hexdocs.pm/doctor](https://hexdocs.pm/doctor). ## Comparison with other tools There are a few tools in the Elixir ecosystem that overlap slightly in functionality with Doctor. It is useful for you to know how Doctor differs from these tools and some use cases that Doctor serves. **Credo** [Credo](https://github.com/rrrene/credo) is a phenomenal library that can be used to perform a wide range of static analysis checks against your codebase. It can check for lingering `IO.inspect()` statements, it can check for unsafe atom conversions, and it can also check that the cyclomatic complexity of control statements is within a particular range to name a few. The one area where Doctor and Credo do overlap is that with either tool you have the capability to enforce that `@moduledoc` attributes are present in modules. Given that this is the only overlap between the two tools, I generally use both in my projects and perform both validations during CI/CD. **Inch** [Inch](https://github.com/rrrene/inch_ex) is another great tool written by René Föhring that is specifically catered to analyzing a project's documentation (very much like Doctor). Inch will scan your project's source files and check for the presence of function documentation and report back to you what grade it thinks your project has earned. Inch does not appear to support checking for function typespecs, returning non-zero status codes when validation fails, tuning thresholds via a configuration file, or checking for struct module typespecs. On the other hand, these were things that were important to me personally and so I wrote Doctor to fill that void. In a team context, I find Doctor to be invaluable in ensuring that a project maintains a certain level of documentation by failing CI/CD if certain thresholds have not been met. If I have misrepresented any of the aforementioned libraries...feel free to open up an issue :). ## Usage Doctor comes with 2 mix tasks. One to run the documentation coverage report, and another to generate a `.doctor.exs` config file. To run the doctor mix task and generate a report, run: `mix doctor`. To generate a `.doctor.exs` config file with defaults, run: `mix doctor.gen.config`. To get help documentation, run `mix help doctor` and `mix help doctor.gen.config`. The outputs of those help menus can be seen here: Running `mix help doctor` yields: ```terminal mix doctor Doctor is a command line utility that can be used to ensure that your project documentation remains healthy. For more in depth documentation on Doctor or to file bug/feature requests, please check out https://github.com/akoutmos/doctor. The mix doctor command supports the following CLI flags (all of these options and more are also configurable from your .doctor.exs file). The following CLI flags are supported: --full When generating a Doctor report of your project, use the Doctor.Reporters.Full reporter. --short When generating a Doctor report of your project, use the Doctor.Reporters.Short reporter. --summary When generating a Doctor report of your project, use the Doctor.Reporters.Summary reporter. --raise If any of your modules fails Doctor validation, then raise an error and return a non-zero exit status. --failed If set only the failed modules will be reported. Works with --full and --short options. --umbrella By default, in an umbrella project, each app will be evaluated independently against the specified thresholds in your .doctor.exs file. This flag changes that behavior by aggregating the results of all your umbrella apps, and then comparing those results to the configured thresholds. ``` Running `mix help doctor.gen.config` yields: ```terminal mix doctor.gen.config Doctor is a command line utility that can be used to ensure that your project documentation remains healthy. For more in depth documentation on Doctor or to file bug/feature requests, please check out https://github.com/akoutmos/doctor. The mix doctor.gen.config command can be used to create a .doctor.exs file with the default Doctor settings. The default file contents are: %Doctor.Config{ ignore_modules: [], ignore_paths: [], min_module_doc_coverage: 40, min_module_spec_coverage: 0, min_overall_doc_coverage: 50, min_overall_moduledoc_coverage: 100, min_overall_spec_coverage: 0, exception_moduledoc_required: true, raise: false, reporter: Doctor.Reporters.Full, struct_type_spec_required: true, umbrella: false } ``` ## Configuration Below is a sample `.doctor.exs` file with some sample values for the various fields: ```elixir %Doctor.Config{ ignore_modules: [], ignore_paths: [], min_module_doc_coverage: 40, min_module_spec_coverage: 0, min_overall_doc_coverage: 50, min_overall_moduledoc_coverage: 100, min_overall_spec_coverage: 0, exception_moduledoc_required: true, raise: false, reporter: Doctor.Reporters.Full, struct_type_spec_required: true, umbrella: false } ``` For the reporter field, the following reporters included with Doctor: - `Doctor.Reporters.Full` - `Doctor.Reporters.Short` - `Doctor.Reporters.Summary` ## Sample reports Report created for Doctor itself: ```text $ mix doctor Doctor file found. Loading configuration. --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Doc Cov Spec Cov Module File Functions No Docs No Specs Module Doc Struct Spec 100% 0% Doctor.CLI lib/cli/cli.ex 2 0 2 Yes N/A 100% 0% Doctor.Config lib/config.ex 3 0 3 Yes Yes 100% 0% Doctor.Docs lib/docs.ex 1 0 1 Yes Yes N/A N/A Doctor lib/doctor.ex 0 0 0 Yes N/A 100% 100% Mix.Tasks.Doctor lib/mix/tasks/doctor.ex 1 0 0 Yes N/A 100% 0% Mix.Tasks.Doctor.Gen.Config lib/mix/tasks/doctor.gen.config.ex 1 0 1 Yes N/A 100% 0% Doctor.ModuleInformation lib/module_information.ex 4 0 4 Yes Yes 100% 0% Doctor.ModuleReport lib/module_report.ex 1 0 1 Yes Yes 100% 0% Doctor.ReportUtils lib/report_utils.ex 9 0 9 Yes N/A N/A N/A Doctor.Reporter lib/reporter.ex 0 0 0 Yes N/A 100% 0% Doctor.Reporters.Full lib/reporters/full.ex 1 0 1 Yes N/A 100% 0% Doctor.Reporters.OutputUtils lib/reporters/output_utils.ex 1 0 1 Yes N/A 100% 0% Doctor.Reporters.Short lib/reporters/short.ex 1 0 1 Yes N/A 100% 0% Doctor.Reporters.Summary lib/reporters/summary.ex 1 0 1 Yes N/A 100% 0% Doctor.Specs lib/specs.ex 1 0 1 Yes Yes --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Summary: Passed Modules: 15 Failed Modules: 0 Total Doc Coverage: 100.0% Total Spec Coverage: 3.7% Doctor validation has passed! ``` Report created for Phoenix: ```text $ mix doctor Doctor file not found. Using defaults. -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Doc Cov Spec Cov Module File Functions No Docs No Specs Module Doc 100% 0% Mix.Phoenix lib/mix/phoenix.ex 18 0 18 YES 0% 0% Mix.Phoenix.Context lib/mix/phoenix/context.ex 6 6 6 YES 63% 0% Mix.Phoenix.Schema lib/mix/phoenix/schema.ex 8 3 8 YES 100% 0% Mix.Tasks.Compile.Phoenix lib/mix/tasks/compile.phoenix.ex 2 0 2 YES 100% 0% Mix.Tasks.Phx.Digest.Clean lib/mix/tasks/phx.digest.clean.ex 1 0 1 YES 100% 0% Mix.Tasks.Phx.Digest lib/mix/tasks/phx.digest.ex 1 0 1 YES 100% 0% Mix.Tasks.Phx lib/mix/tasks/phx.ex 1 0 1 YES 100% 0% Mix.Tasks.Phx.Gen.Cert lib/mix/tasks/phx.gen.cert.ex 2 0 2 YES 100% 0% Mix.Tasks.Phx.Gen.Channel lib/mix/tasks/phx.gen.channel.ex 1 0 1 YES 86% 14% Mix.Tasks.Phx.Gen.Context lib/mix/tasks/phx.gen.context.ex 7 1 6 YES 100% 17% Mix.Tasks.Phx.Gen.Embedded lib/mix/tasks/phx.gen.embedded.ex 6 0 5 YES 100% 0% Mix.Tasks.Phx.Gen.Html lib/mix/tasks/phx.gen.html.ex 4 0 4 YES 100% 0% Mix.Tasks.Phx.Gen.Json lib/mix/tasks/phx.gen.json.ex 4 0 4 YES 100% 0% Mix.Tasks.Phx.Gen.Presence lib/mix/tasks/phx.gen.presence.ex 1 0 1 YES 100% 14% Mix.Tasks.Phx.Gen.Schema lib/mix/tasks/phx.gen.schema.ex 7 0 6 YES 100% 0% Mix.Tasks.Phx.Gen.Secret lib/mix/tasks/phx.gen.secret.ex 1 0 1 YES 100% 0% Mix.Tasks.Phx.Routes lib/mix/tasks/phx.routes.ex 1 0 1 YES 100% 0% Mix.Tasks.Phx.Server lib/mix/tasks/phx.server.ex 1 0 1 YES 100% 0% Phoenix lib/phoenix.ex 3 0 3 YES 100% 17% Phoenix.Channel lib/phoenix/channel.ex 12 0 10 YES 100% 18% Phoenix.Channel.Server lib/phoenix/channel/server.ex 17 0 14 YES 100% 0% Phoenix.CodeReloader lib/phoenix/code_reloader.ex 2 0 2 YES 40% 0% Phoenix.CodeReloader.Proxy lib/phoenix/code_reloader/proxy.ex 5 3 5 YES 33% 0% Phoenix.CodeReloader.Server lib/phoenix/code_reloader/server.ex 6 4 6 YES 88% 25% Phoenix.Config lib/phoenix/config.ex 8 1 6 YES 100% 52% Phoenix.Controller lib/phoenix/controller.ex 42 0 20 YES 100% 0% Phoenix.Controller.Pipeline lib/phoenix/controller/pipeline.ex 6 0 6 YES 100% 100% Phoenix.Digester lib/phoenix/digester.ex 2 0 0 YES 100% 0% Phoenix.Endpoint lib/phoenix/endpoint.ex 25 0 25 YES 100% 0% Phoenix.Endpoint.Cowboy2Adapter lib/phoenix/endpoint/cowboy2_adapter.ex 3 0 3 YES 0% 0% Phoenix.Endpoint.Cowboy2Handler lib/phoenix/endpoint/cowboy2_handler.ex 5 5 5 YES 100% 0% Phoenix.Endpoint.CowboyAdapter lib/phoenix/endpoint/cowboy_adapter.ex 2 0 2 YES 0% 0% Phoenix.Endpoint.CowboyWebSocket lib/phoenix/endpoint/cowboy_websocket.ex 8 8 8 YES 100% 0% Phoenix.Endpoint.RenderErrors lib/phoenix/endpoint/render_errors.ex 3 0 3 YES 93% 0% Phoenix.Endpoint.Supervisor lib/phoenix/endpoint/supervisor.ex 15 1 15 YES 0% 0% Phoenix.Endpoint.Watcher lib/phoenix/endpoint/watcher.ex 2 2 2 YES NA NA Plug.Exception.Phoenix.ActionClauseErro lib/phoenix/exceptions.ex 0 0 0 NO NA NA Phoenix.NotAcceptableError lib/phoenix/exceptions.ex 0 0 0 YES 100% 0% Phoenix.MissingParamError lib/phoenix/exceptions.ex 1 0 1 YES 0% 0% Phoenix.ActionClauseError lib/phoenix/exceptions.ex 2 2 2 NO 60% 0% Phoenix.Logger lib/phoenix/logger.ex 5 2 5 YES 83% 100% Phoenix.Naming lib/phoenix/naming.ex 6 1 0 YES NA NA Phoenix.Param.Map lib/phoenix/param.ex 0 0 0 NO NA NA Phoenix.Param.Integer lib/phoenix/param.ex 0 0 0 NO NA NA Phoenix.Param.BitString lib/phoenix/param.ex 0 0 0 NO NA NA Phoenix.Param.Atom lib/phoenix/param.ex 0 0 0 NO NA NA Phoenix.Param.Any lib/phoenix/param.ex 0 0 0 NO 0% 0% Phoenix.Param lib/phoenix/param.ex 1 1 1 YES 100% 0% Phoenix.Presence lib/phoenix/presence.ex 17 0 17 YES NA NA Phoenix.Router.NoRouteError lib/phoenix/router.ex 0 0 0 YES 100% 0% Phoenix.Router lib/phoenix/router.ex 11 0 11 YES 100% 0% Phoenix.Router.ConsoleFormatter lib/phoenix/router/console_formatter.ex 1 0 1 YES 95% 0% Phoenix.Router.Helpers lib/phoenix/router/helpers.ex 20 1 20 YES 100% 0% Phoenix.Router.Resource lib/phoenix/router/resource.ex 1 0 1 YES 100% 20% Phoenix.Router.Route lib/phoenix/router/route.ex 5 0 4 YES 100% 0% Phoenix.Router.Scope lib/phoenix/router/scope.ex 9 0 9 YES NA NA Phoenix.Socket.InvalidMessageError lib/phoenix/socket.ex 0 0 0 YES 57% 0% Phoenix.Socket lib/phoenix/socket.ex 14 6 14 YES NA NA Phoenix.Socket.Reply lib/phoenix/socket/message.ex 0 0 0 YES 100% 0% Phoenix.Socket.Message lib/phoenix/socket/message.ex 1 0 1 YES NA NA Phoenix.Socket.Broadcast lib/phoenix/socket/message.ex 0 0 0 YES 50% 0% Phoenix.Socket.PoolSupervisor lib/phoenix/socket/pool_supervisor.ex 4 2 4 YES NA NA Phoenix.Socket.Serializer lib/phoenix/socket/serializer.ex 0 0 0 YES 0% 0% Phoenix.Socket.V1.JSONSerializer lib/phoenix/socket/serializers/v1_json_serializer.ex 3 3 3 YES 0% 0% Phoenix.Socket.V2.JSONSerializer lib/phoenix/socket/serializers/v2_json_serializer.ex 3 3 3 YES 100% 0% Phoenix.Socket.Transport lib/phoenix/socket/transport.ex 6 0 6 YES NA NA Phoenix.Template.UndefinedError lib/phoenix/template.ex 0 0 0 YES 100% 45% Phoenix.Template lib/phoenix/template.ex 11 0 6 YES 0% 0% Phoenix.Template.EExEngine lib/phoenix/template/eex_engine.ex 1 1 1 YES NA NA Phoenix.Template.Engine lib/phoenix/template/engine.ex 0 0 0 YES 0% 0% Phoenix.Template.ExsEngine lib/phoenix/template/exs_engine.ex 1 1 1 YES NA NA Phoenix.ChannelTest.NoopSerializer lib/phoenix/test/channel_test.ex 0 0 0 YES 100% 11% Phoenix.ChannelTest lib/phoenix/test/channel_test.ex 19 0 17 YES 100% 94% Phoenix.ConnTest lib/phoenix/test/conn_test.ex 17 0 1 YES 100% 0% Phoenix.Token lib/phoenix/token.ex 2 0 2 YES 67% 0% Phoenix.Transports.LongPoll lib/phoenix/transports/long_poll.ex 3 1 3 YES 50% 0% Phoenix.Transports.LongPoll.Server lib/phoenix/transports/long_poll_server.ex 4 2 4 YES 0% 0% Phoenix.Transports.WebSocket lib/phoenix/transports/websocket.ex 2 2 2 YES 100% 0% Phoenix.View lib/phoenix/view.ex 9 0 9 YES -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Summary: Passed Modules: 72 Failed Modules: 7 Total Doc Coverage: 85.1% Total Spec Coverage: 15.3% Doctor validation has passed! ``` ================================================ FILE: config/config.exs ================================================ # This file is responsible for configuring your application # and its dependencies with the aid of the Mix.Config module. import Config # This configuration is loaded before any dependency and is restricted # to this project. If another project depends on this project, this # file won't be loaded nor affect the parent project. For this reason, # if you want to provide default values for your application for # 3rd-party users, it should be done in your "mix.exs" file. # You can configure your application as: # # config :doctor, key: :value # # and access this configuration in your application as: # # Application.get_env(:doctor, :key) # # You can also configure a 3rd-party app: # # config :logger, level: :info # # It is also possible to import configuration files, relative to this # directory. For example, you can emulate configuration per environment # by uncommenting the line below and defining dev.exs, test.exs and such. # Configuration from the imported file will override the ones defined # here (which is why it is important to import them last). # # import_config "#{Mix.env()}.exs" ================================================ FILE: coveralls.json ================================================ { "minimum_coverage": 75, "skip_files": [ "test/sample_files/" ] } ================================================ FILE: lib/cli/cli.ex ================================================ defmodule Doctor.CLI do @moduledoc """ Provides the various CLI task entry points and CLI arg parsing. """ alias Mix.Project alias Doctor.{ModuleInformation, ModuleReport, ReportUtils} alias Doctor.Reporters.ModuleExplain @doc """ Given the CLI arguments, run the report on the project, """ def generate_module_report_list(args) do # Using the project's app name, fetch all the modules associated with the app Project.config() |> Keyword.get(:app) |> get_application_modules() |> Enum.filter(fn module -> String.starts_with?(to_string(module), "Elixir.") end) # Fetch the module information from the list of application modules |> Enum.map(&generate_module_entry/1) # Filter out any files/modules that were specified in the config |> Enum.reject(fn module_info -> filter_ignore_modules(module_info.module, args.ignore_modules) end) |> Enum.reject(fn module_info -> filter_ignore_paths(module_info.file_relative_path, args.ignore_paths) end) # Asynchronously get the user defined functions from the modules |> Enum.map(&async_fetch_user_defined_functions/1) |> Enum.map(&Task.await(&1, 15_000)) # Build report struct for each module |> Enum.sort(&(&1.file_relative_path < &2.file_relative_path)) |> Enum.map(&ModuleReport.build/1) end @doc """ Generate a report for a single project module. """ def generate_single_module_report(module_name, args) do Project.config() |> Keyword.get(:app) |> get_application_modules() |> Enum.find(:not_found, &(inspect(&1) == module_name)) |> case do :not_found -> :not_found module -> module |> generate_module_entry() |> async_fetch_user_defined_functions() |> Task.await(15_000) |> ModuleExplain.generate_report(args) end end @doc """ Given a list of individual module reports, process each item in the list with the configured reporter and return a pass or fail boolean """ def process_module_report_list(module_report_list, args) do # Invoke the configured module reporter and return whether Doctor validation passed/failed args.reporter.generate_report(module_report_list, args) ReportUtils.doctor_report_passed?(module_report_list, args) end defp generate_module_entry(module) do module |> Code.fetch_docs() |> ModuleInformation.build(module) end defp async_fetch_user_defined_functions(%ModuleInformation{} = module_info) do Task.async(fn -> module_info |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() end) end defp get_application_modules(application) do # Compile and load the application Mix.Task.run("compile") Application.load(application) # Get all the modules in the application {:ok, modules} = :application.get_key(application, :modules) modules end defp filter_ignore_paths(file_relative_path, ignore_paths) do ignore_paths |> Enum.reduce_while(false, fn pattern, _acc -> compare_ignore_path(file_relative_path, pattern) end) end defp compare_ignore_path(file_relative_path, %Regex{} = ignore_pattern) do if Regex.match?(ignore_pattern, file_relative_path) do {:halt, true} else {:cont, false} end end defp compare_ignore_path(file_relative_path, ignore_string) when is_bitstring(ignore_string) do if file_relative_path == ignore_string do {:halt, true} else {:cont, false} end end defp compare_ignore_path(_, ignore_value), do: raise("Encountered invalid ignore_paths entry: #{inspect(ignore_value)}") defp filter_ignore_modules(module, ignore_modules) do ignore_modules |> Enum.reduce_while(false, fn pattern, _acc -> compare_ignore_module(module, pattern) end) end defp compare_ignore_module(module, %Regex{} = ignore_pattern) do module_as_string = module |> Atom.to_string() |> String.trim_leading("Elixir.") if Regex.match?(ignore_pattern, module_as_string) do {:halt, true} else {:cont, false} end end defp compare_ignore_module(module, ignore_module) when is_atom(ignore_module) do if module == ignore_module do {:halt, true} else {:cont, false} end end defp compare_ignore_module(_, ignore_value), do: raise("Encountered invalid ignore_module entry: #{inspect(ignore_value)}") end ================================================ FILE: lib/config.ex ================================================ defmodule Doctor.Config do @moduledoc """ This module defines a struct which houses all the configuration data for Doctor. """ @config_file ".doctor.exs" require Logger alias __MODULE__ @typedoc """ * `:min_module_doc_coverage` - Minimum ratio of @doc vs public functions per module. * `:min_overall_doc_coverage` - Minimum ratio of @doc vs public functions across the codebase. * `:min_overall_moduledoc_coverage` - Minimum ratio of @moduledoc to modules across the codebase. * `:moduledoc_required` - If true, `:min_overall_moduledoc_coverage` is automatically set to 100%. Deprecated. """ @type t :: %Config{ ignore_modules: [Regex.t() | String.t()], ignore_paths: [Regex.t() | module()], min_module_doc_coverage: integer() | float(), min_module_spec_coverage: integer() | float(), min_overall_doc_coverage: integer() | float(), min_overall_moduledoc_coverage: integer() | float(), min_overall_spec_coverage: integer() | float(), moduledoc_required: boolean(), exception_moduledoc_required: boolean() | nil, raise: boolean(), reporter: module(), struct_type_spec_required: boolean(), umbrella: boolean(), failed: false } defstruct ignore_modules: [], ignore_paths: [], min_module_doc_coverage: 40, min_module_spec_coverage: 0, min_overall_doc_coverage: 50, min_overall_moduledoc_coverage: 100, min_overall_spec_coverage: 0, moduledoc_required: nil, exception_moduledoc_required: true, raise: false, reporter: Doctor.Reporters.Full, struct_type_spec_required: true, umbrella: false, failed: false @doc """ Create a new Config struct from a map, keyword list or preexisting Config. """ @spec new(keyword | map) :: Config.t() def new(attrs \\ %{}) do config = case attrs do %Config{} = c -> c map_or_keyword -> struct(Config, map_or_keyword) end interpret_moduledoc_required(config) end @doc """ Returns true if a specific module should fail validation if it lacks a moduledoc." """ @spec moduledoc_required?(t) :: boolean def moduledoc_required?(%{min_overall_moduledoc_coverage: 100}), do: true def moduledoc_required?(_), do: false @doc """ Get the configuration defaults as a string """ def config_defaults_as_string do config = quote do: unquote(%Config{}) config |> Macro.to_string() |> Code.format_string!() |> to_string() |> String.replace(~r/\s+moduledoc_required:.*/, "") end @doc """ Get the configuration file name """ def config_file, do: @config_file # If `:moduledoc_required` is defined in the config, warn the user about the # deprecation. In a future version, the struct key and associated # backwards-compatibility code could be removed. @spec interpret_moduledoc_required(Config.t()) :: Config.t() defp interpret_moduledoc_required(%{moduledoc_required: nil} = config) do config end defp interpret_moduledoc_required(%{moduledoc_required: true} = config) do warn_deprecation(true, 100) %{config | min_overall_moduledoc_coverage: 100} end defp interpret_moduledoc_required(%{moduledoc_required: false} = config) do warn_deprecation(false, 0) %{config | min_overall_moduledoc_coverage: 0} end defp warn_deprecation(_bool, val) do Logger.warning(""" :moduledoc_required in #{Config.config_file()} is a deprecated option. \ Now running with the equivalent :min_overall_moduledoc_coverage #{val} \ but you should replace the deprecated option with the new one to avoid \ this warning.\ """) end end ================================================ FILE: lib/docs.ex ================================================ defmodule Doctor.Docs do @moduledoc """ This module defines a struct which houses all the documentation data for module functions. """ alias __MODULE__ @type t :: %Docs{ kind: atom(), name: atom(), arity: integer(), doc: map() } defstruct ~w(kind name arity doc)a @doc """ Build the Docs struct from the results of Code.fetch_docs/0 """ def build({{kind, name, arity}, _annotation, _signature, doc, _metadata}) do %Docs{ kind: kind, name: name, arity: arity, doc: doc } end end ================================================ FILE: lib/doctor.ex ================================================ defmodule Doctor do @moduledoc """ Doctor is a utility which aims to provide insights into the health of your project's documentation. In addition to be a useful development time tool, Doctor can also be useful during CI/CD and can block merges and releases if the documentation coverage is not up to spec. Doctor comes with sane defaults out of the box, but if you wish to customize its settings, feel free to create your own .doctor.exs file. """ end ================================================ FILE: lib/mix/tasks/doctor.ex ================================================ defmodule Mix.Tasks.Doctor do @moduledoc """ Doctor is a command line utility that can be used to ensure that your project documentation remains healthy. For more in depth documentation on Doctor or to file bug/feature requests, please check out https://github.com/akoutmos/doctor. The `mix doctor` command supports the following CLI flags (all of these options and more are also configurable from your `.doctor.exs` file). The following CLI flags are supported: ``` --config_file Provide a relative or absolute path to a `.doctor.exs` file to use during the execution of the mix command. --full When generating a Doctor report of your project, use the Doctor.Reporters.Full reporter. --short When generating a Doctor report of your project, use the Doctor.Reporters.Short reporter. --summary When generating a Doctor report of your project, use the Doctor.Reporters.Summary reporter. --raise If any of your modules fails Doctor validation, then raise an error and return a non-zero exit status. --failed If set, only the failed modules will be reported. Works with --full and --short options. --umbrella By default, in an umbrella project, each app will be evaluated independently against the specified thresholds in your .doctor.exs file. This flag changes that behavior by aggregating the results of all your umbrella apps, and then comparing those results to the configured thresholds. ``` """ use Mix.Task alias Doctor.{CLI, Config} alias Doctor.Reporters.{Full, Short, Summary} @shortdoc "Documentation coverage report" @recursive true @umbrella_accumulator Doctor.Umbrella @impl true def run(args) do cli_arg_opts = parse_cli_args(args) config_file_opts = load_config_file(cli_arg_opts) # Aggregate all of the various options sources # Precedence order is: # default < config file < cli args config = config_file_opts |> Map.merge(cli_arg_opts) |> Config.new() if config.umbrella do run_umbrella(config) else run_default(config) end end defp run_umbrella(config) do module_report_list = CLI.generate_module_report_list(config) acc_pid = case Process.whereis(@umbrella_accumulator) do nil -> init_umbrella_acc(config) pid -> pid end Agent.update(acc_pid, fn acc -> acc ++ module_report_list end) :ok end defp run_default(config) do result = config |> CLI.generate_module_report_list() |> CLI.process_module_report_list(config) unless result do System.at_exit(fn _ -> exit({:shutdown, 1}) end) if config.raise do Mix.raise("Doctor validation has failed and raised an error") end end :ok end defp init_umbrella_acc(config) do {:ok, pid} = Agent.start_link(fn -> [] end, name: @umbrella_accumulator) System.at_exit(fn _ -> module_report_list = Agent.get(pid, & &1) Agent.stop(pid) result = CLI.process_module_report_list(module_report_list, config) unless result do if config.raise do Mix.raise("Doctor validation has failed and raised an error") end exit({:shutdown, 1}) end end) pid end defp load_config_file(%{config_file_path: file_path} = _cli_args) do full_path = Path.expand(file_path) if File.exists?(full_path) do Mix.shell().info("Doctor file found. Loading configuration.") {config, _bindings} = Code.eval_file(full_path) config else Mix.shell().error("Doctor file not found at path \"#{full_path}\". Using defaults.") %{} end end defp load_config_file(_) do # If we are performing this operation on an umbrella app then look to # the project root for the config file file = if Mix.Task.recursing?() do Path.join(["..", "..", Config.config_file()]) else Config.config_file() end if File.exists?(file) do Mix.shell().info("Doctor file found. Loading configuration.") {config, _bindings} = Code.eval_file(file) config else Mix.shell().info("Doctor file not found. Using defaults.") %{} end end defp parse_cli_args(args) do {parsed_args, _args, _invalid} = OptionParser.parse(args, strict: [ full: :boolean, short: :boolean, summary: :boolean, raise: :boolean, failed: :boolean, umbrella: :boolean, config_file: :string ] ) parsed_args |> Enum.reduce(%{}, fn {:full, true}, acc -> Map.merge(acc, %{reporter: Full}) {:short, true}, acc -> Map.merge(acc, %{reporter: Short}) {:summary, true}, acc -> Map.merge(acc, %{reporter: Summary}) {:raise, true}, acc -> Map.merge(acc, %{raise: true}) {:failed, true}, acc -> Map.merge(acc, %{failed: true}) {:umbrella, true}, acc -> Map.merge(acc, %{umbrella: true}) {:config_file, file_path}, acc -> Map.merge(acc, %{config_file_path: file_path}) _unexpected_arg, acc -> acc end) end end ================================================ FILE: lib/mix/tasks/doctor.explain.ex ================================================ defmodule Mix.Tasks.Doctor.Explain do @moduledoc """ Figuring out why a particular module failed Doctor validation can sometimes be a bit difficult when the relevant information is embedded within a table with other validation results. The `mix doctor.explain` command has only a single required argument. That argument is the name of the module that you wish to get a detailed report of. For example you could run the following from the terminal: ``` $ mix doctor.explain MyApp.Some.Module ``` To generate a report like this: ``` Doctor file found. Loading configuration. Function @doc @spec ------------------------------- generate_report/2 ✗ ✗ Module Results: Doc Coverage: 0.0% --> Your config has a 'min_module_doc_coverage' value of 80 Spec Coverage: 0.0% Has Module Doc: ✓ Has Struct Spec: N/A ``` In addition, the following CLI flags are supported (similarly to the `mix doctor` command): ``` --config-file Provide a relative or absolute path to a `.doctor.exs` file to use during the execution of the mix command. --raise If any of your modules fails Doctor validation, then raise an error and return a non-zero exit status. ``` To use these command line args you would do something like so: ``` $ mix doctor.explain --raise --config_file /some/path/to/some/.doctor.exs MyApp.Some.Module ``` Note that `mix doctor.explain` takes a module name instead of a file path since you can define multiple modules in a single file. """ use Mix.Task alias Doctor.{CLI, Config} @shortdoc "Debug why a particular module is failing validation" @recursive true @umbrella_accumulator Doctor.Umbrella @impl true def run(args) do {cli_arg_opts, args} = parse_cli_args(args) config_file_opts = load_config_file(cli_arg_opts) # Aggregate all of the various options sources # Precedence order is: # default < config file < cli args config = config_file_opts |> Map.merge(cli_arg_opts) |> Config.new() # Get the module name from args module_name = case args do [module] -> module _error -> raise "Invalid Argument: mix doctor.explain takes only a single module name as an argument" end if config.umbrella do run_umbrella(module_name, config) else run_default(module_name, config) end end defp run_umbrella(module_name, config) do acc_pid = case Process.whereis(@umbrella_accumulator) do nil -> init_umbrella_acc(module_name, config) pid -> pid end case CLI.generate_single_module_report(module_name, config) do :not_found -> :ok result -> Agent.update(acc_pid, fn %{result: acc_result} -> %{found: true, result: acc_result && result} end) end :ok end defp run_default(module_name, config) do case CLI.generate_single_module_report(module_name, config) do :not_found -> raise "Could not find module #{inspect(module_name)} in application" result -> unless result do System.at_exit(fn _ -> exit({:shutdown, 1}) end) if config.raise do Mix.raise("Doctor validation has failed and raised an error") end end end :ok end defp init_umbrella_acc(module_name, config) do {:ok, pid} = Agent.start_link(fn -> %{found: false, result: true} end, name: @umbrella_accumulator) System.at_exit(fn _ -> acc = Agent.get(pid, & &1) Agent.stop(pid) report_umbrella_result(acc, module_name, config) end) pid end defp report_umbrella_result(%{found: false}, module_name, _config) do raise "Could not find module #{inspect(module_name)} in application" end defp report_umbrella_result(%{result: true}, _module_name, _config), do: :ok defp report_umbrella_result(_acc, _module_name, config) do if config.raise do Mix.raise("Doctor validation has failed and raised an error") end exit({:shutdown, 1}) end defp load_config_file(%{config_file_path: file_path} = _cli_args) do full_path = Path.expand(file_path) if File.exists?(full_path) do Mix.shell().info("Doctor file found. Loading configuration.") {config, _bindings} = Code.eval_file(full_path) config else Mix.shell().error("Doctor file not found at path \"#{full_path}\". Using defaults.") %{} end end defp load_config_file(_) do # If we are performing this operation on an umbrella app then look to # the project root for the config file file = if Mix.Task.recursing?() do Path.join(["..", "..", Config.config_file()]) else Config.config_file() end if File.exists?(file) do Mix.shell().info("Doctor file found. Loading configuration.") {config, _bindings} = Code.eval_file(file) config else Mix.shell().info("Doctor file not found. Using defaults.") %{} end end defp parse_cli_args(args) do {parsed_args, args, _invalid} = OptionParser.parse(args, strict: [ raise: :boolean, config_file: :string ] ) parsed_args = parsed_args |> Enum.reduce(%{}, fn {:raise, true}, acc -> Map.merge(acc, %{raise: true}) {:config_file, file_path}, acc -> Map.merge(acc, %{config_file_path: file_path}) _unexpected_arg, acc -> acc end) {parsed_args, args} end end ================================================ FILE: lib/mix/tasks/doctor.gen.config.ex ================================================ defmodule Mix.Tasks.Doctor.Gen.Config do @moduledoc """ Doctor is a command line utility that can be used to ensure that your project documentation remains healthy. For more in depth documentation on Doctor or to file bug/feature requests, please check out https://github.com/akoutmos/doctor. The `mix doctor.gen.config` command can be used to create a `.doctor.exs` file with the default Doctor settings. The default file contents are: ``` %Doctor.Config{ ignore_modules: [], ignore_paths: [], min_module_doc_coverage: 40, min_module_spec_coverage: 0, min_overall_doc_coverage: 50, min_overall_moduledoc_coverage: 100, min_overall_spec_coverage: 0, exception_moduledoc_required: true, raise: false, reporter: Doctor.Reporters.Full, struct_type_spec_required: true, umbrella: false } ``` """ use Mix.Task alias Mix.Shell.IO alias Doctor.Config @shortdoc "Creates a .doctor.exs config file with defaults" @doc """ This Mix task generates a .doctor.exs configuration file """ @impl true def run(_args) do create_file = if File.exists?(Config.config_file()) do IO.yes?("An existing Doctor config file already exists. Overwrite?") else true end if create_file do create_config_file() IO.info("Successfully created .doctor.exs file.") else IO.info("Did not create .doctor.exs file.") end end defp create_config_file do File.cwd!() |> Path.join(Config.config_file()) |> File.write(Config.config_defaults_as_string()) end end ================================================ FILE: lib/module_information.ex ================================================ defmodule Doctor.ModuleInformation do @moduledoc """ This module defines a struct which houses all the documentation data for an entire module. """ alias __MODULE__ alias Doctor.{Docs, Specs} @type t :: %ModuleInformation{ module: module(), behaviours: [module()], file_full_path: String.t(), file_relative_path: String.t(), file_ast: list(), docs_version: atom(), module_doc: map(), metadata: map(), docs: [%Docs{}], specs: list(), user_defined_functions: [{atom(), integer(), atom() | boolean()}], struct_type_spec: atom() | boolean(), properties: Keyword.t() } defstruct ~w( module file_full_path file_relative_path file_ast docs_version module_doc metadata docs specs user_defined_functions behaviours struct_type_spec properties )a @doc """ Breaks down the docs format entry returned from Code.fetch_docs(MODULE) """ def build({docs_version, _annotation, _language, _format, module_doc, metadata, docs}, module) do {:ok, module_specs} = Code.Typespec.fetch_specs(module) %ModuleInformation{ module: module, behaviours: get_module_behaviours(module), file_full_path: get_full_file_path(module), file_relative_path: get_relative_file_path(module), file_ast: nil, docs_version: docs_version, module_doc: module_doc, metadata: metadata, docs: Enum.map(docs, &Docs.build/1), specs: Enum.map(module_specs, &Specs.build/1), user_defined_functions: nil, struct_type_spec: contains_struct_type_spec?(module), properties: [ is_exception: is_exception?(module), is_protocol_implementation: is_protocol_implementation?(module) ] } end @doc """ Given the provided module, read the file from which the module was generated and convert the file to an AST. """ def load_file_ast(%ModuleInformation{} = module_info) do ast = module_info.file_full_path |> File.read!() |> Code.string_to_quoted!() %{module_info | file_ast: ast} end @doc """ Checks the provided module for a __struct__ function which is injected into the module whenever you use `defstruct` """ def contains_struct_type_spec?(module) do cond do is_exception?(module) -> :not_struct is_struct?(module) -> {:ok, specs} = Code.Typespec.fetch_types(module) Enum.any?(specs, fn {:type, {:t, _, _}} -> true {:opaque, {:t, _, _}} -> true _ -> false end) true -> :not_struct end end defp is_struct?(module) do function_exported?(module, :__struct__, 0) or function_exported?(module, :__struct__, 1) end defp is_exception?(module) when is_atom(module) do function_exported?(module, :__struct__, 0) and :__exception__ in Map.keys(module.__struct__()) end defp is_protocol_implementation?(module) when is_atom(module) do function_exported?(module, :__impl__, 1) end @doc """ Given a ModuleInformation struct with the AST loaded, fetch all of the author defined functions """ def load_user_defined_functions(%ModuleInformation{} = module_info) do {_ast, %{modules: modules}} = Macro.traverse( module_info.file_ast, %{modules: %{}, stack: []}, &parse_ast_node_for_defmodules/2, &pop_module_stack/2 ) {_ast, %{functions: functions}} = modules |> Map.get(module_info.module) |> Macro.traverse( %{functions: [], last_impl: :none, nesting_level: 0}, &parse_ast_node_for_def/2, &unnest/2 ) %{module_info | user_defined_functions: Enum.uniq(functions)} |> load_using_docs_and_specs(modules) end defp load_using_docs_and_specs(%ModuleInformation{} = module_info, modules) do {_ast, using} = modules |> Map.get(module_info.module) |> Macro.prewalk(%{using: :none}, &parse_ast_for_using/2) acc = %{ last_doc: :none, last_spec: :none, using_docs: [], using_specs: [] } {_ast, extra} = using[:using] |> Macro.prewalk(acc, &parse_ast_using_node/2) %{ module_info | specs: module_info.specs ++ extra.using_specs, docs: module_info.docs ++ extra.using_docs } end defp get_module_behaviours(module) do {_module, bin, _beam_file_path} = :code.get_object_code(module) case :beam_lib.chunks(bin, [:attributes]) do {:ok, {^module, attributes}} -> attributes |> Keyword.get(:attributes, []) |> Keyword.get(:behaviour, []) _ -> [] end end defp get_full_file_path(module) do module.module_info() |> Keyword.get(:compile) |> Keyword.get(:source) |> to_string() end defp get_relative_file_path(module) do module |> get_full_file_path() |> Path.relative_to(File.cwd!()) end defp parse_ast_node_for_def({definition, _defmodule_line, _body} = ast, %{nesting_level: level} = acc) when definition in [:defimpl, :defmodule, :defprotocol] do {ast, Map.put(acc, :nesting_level, level + 1)} end defp parse_ast_node_for_def(ast, %{nesting_level: level} = acc) when level > 1 do {ast, acc} end defp parse_ast_node_for_def({:@, _line_number, [{:doc, _, [false]}]} = ast, acc) do updated_acc = Map.put(acc, :last_impl, false) {ast, updated_acc} end defp parse_ast_node_for_def({:@, _line_number, [{:impl, _, impl_def}]} = ast, acc) do normalized_impl = normalize_impl(impl_def) updated_acc = Map.put(acc, :last_impl, normalized_impl) {ast, updated_acc} end defp parse_ast_node_for_def( {:def, _def_line, [{:when, _line_when, [{function_name, _function_line, args}, _guard]}, _do_block]} = ast, %{last_impl: impl} = acc ) do function_arity = get_function_arity(args) updated_acc = update_acc_for_def(acc, function_name, function_arity, impl) {ast, updated_acc} end defp parse_ast_node_for_def( {:def, _def_line, [{function_name, _function_line, args}, _do_block]} = ast, %{last_impl: impl} = acc ) do function_arity = get_function_arity(args) updated_acc = update_acc_for_def(acc, function_name, function_arity, impl) {ast, updated_acc} end defp parse_ast_node_for_def( {:def, _def_line, [{function_name, _function_line, args}]} = ast, %{last_impl: impl} = acc ) do function_arity = get_function_arity(args) updated_acc = update_acc_for_def(acc, function_name, function_arity, impl) {ast, updated_acc} end defp parse_ast_node_for_def(ast, acc) do {ast, acc} end defp unnest({definition, _defmodule_line, _body} = ast, %{nesting_level: level} = acc) when definition in [:defmodule, :defprotocol] do {ast, Map.put(acc, :nesting_level, level - 1)} end defp unnest(ast, acc) do {ast, acc} end defp update_acc_for_def(acc, function_name, function_arity, last_impl) do impl = case last_impl do :none -> acc[:functions] |> Enum.filter(fn {name, arity, _impl} -> name == function_name and arity == function_arity end) |> Enum.at(0, {function_name, function_arity, :none}) |> Kernel.elem(2) last_impl -> last_impl end acc |> Map.put(:last_impl, :none) |> Map.update(:functions, [], fn functions -> [{function_name, function_arity, impl} | functions] end) end defp normalize_impl([value]) when is_boolean(value) do value end defp normalize_impl([{:__aliases__, _, module}]) do Module.concat(module) end defp normalize_impl(value) do value end defp parse_ast_node_for_defmodules( {definition, _defmodule_line, [{:__aliases__, _line_num, module}, _do_block]} = ast, %{modules: modules, stack: stack} = acc ) when definition in [:defmodule, :defprotocol] do parent = List.first(stack) full_module_name = Module.concat(List.wrap(parent) ++ module) updated_acc = acc |> Map.put(:modules, Map.put(modules, full_module_name, ast)) |> Map.put(:stack, [full_module_name | stack]) {ast, updated_acc} end defp parse_ast_node_for_defmodules(ast, acc) do {ast, acc} end defp pop_module_stack( {definition, _defmodule_line, _body} = ast, %{stack: [_current | rest]} = acc ) when definition in [:defmodule, :defprotocol] do {ast, Map.put(acc, :stack, rest)} end defp pop_module_stack(ast, acc) do {ast, acc} end defp get_function_arity(nil), do: 0 defp get_function_arity(args), do: length(args) defp parse_ast_for_using({:defmacro, _macro_line, [{:__using__, _line, _args}, do_block]} = ast, _acc), do: {ast, %{using: do_block}} defp parse_ast_for_using(ast, acc), do: {ast, acc} defp parse_ast_using_node( {:@, _doc_line, [{:doc, _line, [doc]}]} = ast, acc ), do: {ast, Map.put(acc, :last_doc, doc)} defp parse_ast_using_node( {:@, _spec_line, [{:spec, _line, _spec_info}]} = ast, acc ), do: {ast, Map.put(acc, :last_spec, true)} defp parse_ast_using_node( {:def, _def_line, [{:when, _line_when, [{function_name, _function_line, args}, _guard]}, _do_block]} = ast, acc ) do {ast, update_acc_for_using(function_name, args, acc)} end defp parse_ast_using_node( {:def, _def_line, [{function_name, _function_line, args}, _do_block]} = ast, acc ) do {ast, update_acc_for_using(function_name, args, acc)} end defp parse_ast_using_node( {:def, _def_line, [{function_name, _function_line, args}]} = ast, acc ) do {ast, update_acc_for_using(function_name, args, acc)} end defp parse_ast_using_node(ast, acc), do: {ast, acc} defp update_acc_for_using(function_name, args, acc) do function_arity = get_function_arity(args) function_spec = if acc.last_spec != :none do [%Doctor.Specs{arity: function_arity, name: function_name}] else [] end function_doc = if acc.last_doc != :none do [ %Doctor.Docs{ arity: function_arity, doc: %{"en" => acc.last_doc}, kind: :function, name: function_name } ] else [] end %{ last_doc: :none, last_spec: :none, using_docs: acc.using_docs ++ function_doc, using_specs: acc.using_specs ++ function_spec } end end ================================================ FILE: lib/module_report.ex ================================================ defmodule Doctor.ModuleReport do @moduledoc """ This module exposes a struct which encapsulates all the results for a doctor report. Whether the module has a moduledoc, what the doc coverage is, the number of author defined functions, and so on. """ alias __MODULE__ alias Doctor.ModuleInformation @type t :: %ModuleReport{ doc_coverage: Decimal.t(), spec_coverage: Decimal.t(), file: String.t(), module: String.t(), functions: integer(), missed_docs: integer(), missed_specs: integer(), has_module_doc: boolean(), has_struct_type_spec: atom() | boolean(), is_protocol_implementation: boolean(), properties: Keyword.t() } defstruct ~w( doc_coverage spec_coverage file module functions missed_docs missed_specs has_module_doc has_struct_type_spec is_protocol_implementation properties )a @doc """ Given a ModuleInformation struct with the necessary fields completed, build the report. """ def build(%ModuleInformation{} = module_info) do %ModuleReport{ doc_coverage: calculate_doc_coverage(module_info), spec_coverage: calculate_spec_coverage(module_info), file: module_info.file_relative_path, module: generate_module_name(module_info.module), functions: length(module_info.user_defined_functions), missed_docs: calculate_missed_docs(module_info), missed_specs: calculate_missed_specs(module_info), has_module_doc: has_module_doc?(module_info), has_struct_type_spec: module_info.struct_type_spec, is_protocol_implementation: is_protocol_implementation?(module_info), properties: module_info.properties } end defp generate_module_name(module) do module |> Module.split() |> Enum.join(".") end defp calculate_missed_docs(module_info) do function_arity_list = Enum.map(module_info.user_defined_functions, fn {function, arity, _impl} -> {function, arity} end) docs_arity_list = Enum.map(module_info.docs, fn doc -> {doc.name, doc.arity} end) functions_not_in_docs = Enum.count(function_arity_list, fn fun -> fun not in docs_arity_list end) functions_without_docs = Enum.count(module_info.docs, fn doc -> {doc.name, doc.arity} in function_arity_list and doc.doc == :none end) functions_not_in_docs + functions_without_docs end defp calculate_doc_coverage(module_info) do total = length(module_info.user_defined_functions) missed = calculate_missed_docs(module_info) if total > 0 do (total - missed) |> Decimal.div(total) |> Decimal.mult(100) else nil end end defp calculate_missed_specs(module_info) do function_specs = module_info.specs |> Enum.map(fn spec -> {spec.name, spec.arity} end) Enum.count(module_info.user_defined_functions, fn {function, arity, impl} -> cond do {function, arity} in function_specs -> false is_boolean(impl) and impl and module_info.behaviours != [] -> false is_atom(impl) and impl != :none and module_info.behaviours != [] -> false true -> true end end) end defp calculate_spec_coverage(module_info) do total = length(module_info.user_defined_functions) missed = calculate_missed_specs(module_info) if total > 0 do (total - missed) |> Decimal.div(total) |> Decimal.mult(100) else nil end end defp has_module_doc?(module_info) do module_info.module_doc not in [:none, %{}] end defp is_protocol_implementation?(module_info) do Keyword.get(module_info.properties, :is_protocol_implementation) end end ================================================ FILE: lib/report_utils.ex ================================================ defmodule Doctor.ReportUtils do @moduledoc """ This module provides some utility functions for use in report generators. """ alias Doctor.{Config, ModuleReport} @doc """ Given a list of module reports, count the total number of functions """ def count_total_functions(module_report_list) do module_report_list |> Enum.reduce(0, fn module_report, acc -> module_report.functions + acc end) end @doc """ Given a list of module reports, count the total number of documented functions """ def count_total_documented_functions(module_report_list) do module_report_list |> Enum.reduce(0, fn module_report, acc -> module_documented_functions = module_report.functions - module_report.missed_docs module_documented_functions + acc end) end @doc """ Given a list of module reports, count the total number of speced functions """ def count_total_speced_functions(module_report_list) do module_report_list |> Enum.reduce(0, fn module_report, acc -> module_speced_functions = module_report.functions - module_report.missed_specs module_speced_functions + acc end) end @doc """ Given a list of module reports, count the total number of passed modules """ def count_total_passed_modules(module_report_list, %Config{} = config) do module_report_list |> Enum.count(fn module_report -> module_passed_validation?(module_report, config) end) end @doc """ Given a list of module reports, count the total number of failed modules """ def count_total_failed_modules(module_report_list, %Config{} = config) do module_report_list |> Enum.count(fn module_report -> not module_passed_validation?(module_report, config) end) end @doc """ Calculate the overall doc coverage in the codebase """ def calc_overall_doc_coverage(module_report_list) do total_functions = count_total_functions(module_report_list) documented_functions = count_total_documented_functions(module_report_list) if total_functions > 0 do documented_functions |> Decimal.div(total_functions) |> Decimal.mult(100) else Decimal.new(0) end end @doc """ Calculate the ratio of modules which have a moduledoc. """ def calc_overall_moduledoc_coverage(module_report_list) do {all_modules, with_moduledoc} = Enum.reduce(module_report_list, {0, 0}, fn %{is_protocol_implementation: true}, {acc_all, acc_with} -> {acc_all, acc_with} %{has_module_doc: true}, {acc_all, acc_with} -> {acc_all + 1, acc_with + 1} %{has_module_doc: false}, {acc_all, acc_with} -> {acc_all + 1, acc_with} end) with_moduledoc |> Decimal.div(all_modules) |> Decimal.mult(100) end @doc """ Calculate the overall spec coverage in the codebase """ def calc_overall_spec_coverage(module_report_list) do total_functions = count_total_functions(module_report_list) speced_functions = count_total_speced_functions(module_report_list) if total_functions > 0 do speced_functions |> Decimal.div(total_functions) |> Decimal.mult(100) else Decimal.new(0) end end @doc """ Checks whether the provided module passed validation """ def module_passed_validation?( %ModuleReport{ doc_coverage: doc_coverage, spec_coverage: spec_coverage, has_struct_type_spec: has_struct_type_spec } = module_report, %Config{} = config ) do doc_cov = calc_coverage_pass(doc_coverage, config.min_module_doc_coverage) spec_cov = calc_coverage_pass(spec_coverage, config.min_module_spec_coverage) passed_module_doc = valid_module_doc?(module_report, config) passed_struct_type_spec = if config.struct_type_spec_required and has_struct_type_spec != :not_struct, do: has_struct_type_spec, else: true doc_cov and spec_cov and passed_struct_type_spec and passed_module_doc end defp valid_module_doc?(%ModuleReport{is_protocol_implementation: true}, _config) do true end defp valid_module_doc?(%ModuleReport{properties: properties} = module_report, config) do if Keyword.get(properties, :is_exception) do if config.exception_moduledoc_required do module_report.has_module_doc else true end else if Config.moduledoc_required?(config), do: module_report.has_module_doc, else: true end end @doc """ Check whether Doctor overall has passed or failed validation """ def doctor_report_passed?(module_report_list, config) do [] == doctor_report_errors(module_report_list, config) end @doc """ Check whether Doctor overall has passed or failed validation """ @spec doctor_report_errors([Doctor.ModuleReport.t()], Config.t()) :: [String.t()] def doctor_report_errors(module_report_list, %Config{} = config) do msg = fn true, _msg -> [] false, msg -> [msg] end all_modules = module_report_list |> Enum.reduce_while([], fn module_report, _acc -> if module_passed_validation?(module_report, config) do {:cont, []} else {:halt, ["one or more highlighted modules above is unhealthy"]} end end) overall_doc_cov = module_report_list |> calc_overall_doc_coverage() |> Decimal.to_float() |> Kernel.>=(config.min_overall_doc_coverage) |> msg.("overall @doc coverage is below #{config.min_overall_doc_coverage}") overall_moduledoc_cov = module_report_list |> calc_overall_moduledoc_coverage() |> Decimal.to_float() |> Kernel.>=(config.min_overall_moduledoc_coverage) |> msg.("overall @moduledoc coverage is below #{config.min_overall_moduledoc_coverage}") overall_spec_cov = module_report_list |> calc_overall_spec_coverage() |> Decimal.to_float() |> Kernel.>=(config.min_overall_spec_coverage) |> msg.("overall @spec coverage is below #{config.min_overall_spec_coverage}") all_modules ++ overall_doc_cov ++ overall_moduledoc_cov ++ overall_spec_cov end defp calc_coverage_pass(coverage, threshold) when not is_nil(coverage) do Decimal.to_float(coverage) >= threshold end defp calc_coverage_pass(_coverage, _threshold), do: true end ================================================ FILE: lib/reporter.ex ================================================ defmodule Doctor.Reporter do @moduledoc """ Defines the behaviour for a reporter """ @type module_reports :: [Doctor.ModuleReport.t()] @callback generate_report(module_reports, any()) :: :ok | :error end ================================================ FILE: lib/reporters/full.ex ================================================ defmodule Doctor.Reporters.Full do @moduledoc """ This reporter generates a full documentation coverage report and lists all the files in the project along with whether they pass or fail. """ @behaviour Doctor.Reporter alias Doctor.{Reporters.OutputUtils, ReportUtils} alias Elixir.IO.ANSI @doc_cov_width 9 @spec_cov_width 10 @module_width 41 @file_width 58 @functions_width 11 @missed_docs_width 9 @missed_specs_width 10 @module_doc_width 12 @struct_type_spec_width 11 @doc """ Generate a full Doctor report and print to STDOUT """ @impl true def generate_report(module_reports, args) do print_divider() print_header() Enum.each(module_reports, fn module_report -> doc_cov = massage_coverage(module_report.doc_coverage) spec_cov = massage_coverage(module_report.spec_coverage) module_doc = massage_module_doc(module_report) struct_type_spec = massage_struct_type_spec(module_report.has_struct_type_spec) output_line = OutputUtils.generate_table_line([ {doc_cov, @doc_cov_width}, {spec_cov, @spec_cov_width}, {module_report.module, @module_width}, {module_report.file, @file_width}, {module_report.functions, @functions_width}, {module_report.missed_docs, @missed_docs_width}, {module_report.missed_specs, @missed_specs_width}, {module_doc, @module_doc_width}, {struct_type_spec, @struct_type_spec_width, 0} ]) if ReportUtils.module_passed_validation?(module_report, args) do unless args.failed do Mix.shell().info(output_line) end else Mix.shell().info(ANSI.red() <> output_line <> ANSI.reset()) end end) overall_errors = ReportUtils.doctor_report_errors(module_reports, args) overall_passed = ReportUtils.count_total_passed_modules(module_reports, args) overall_failed = ReportUtils.count_total_failed_modules(module_reports, args) overall_doc_coverage = ReportUtils.calc_overall_doc_coverage(module_reports) overall_moduledoc_coverage = ReportUtils.calc_overall_moduledoc_coverage(module_reports) overall_spec_coverage = ReportUtils.calc_overall_spec_coverage(module_reports) print_footer( overall_errors, overall_passed, overall_failed, overall_doc_coverage, overall_moduledoc_coverage, overall_spec_coverage ) end defp print_header() do output_header = OutputUtils.generate_table_line([ {"Doc Cov", @doc_cov_width}, {"Spec Cov", @spec_cov_width}, {"Module", @module_width}, {"File", @file_width}, {"Functions", @functions_width}, {"No Docs", @missed_docs_width}, {"No Specs", @missed_specs_width}, {"Module Doc", @module_doc_width}, {"Struct Spec", @struct_type_spec_width, 0} ]) Mix.shell().info(output_header) end defp print_divider do "-" |> String.duplicate(171) |> Mix.shell().info() end defp print_footer(errors, passed, failed, doc_coverage, moduledoc_coverage, spec_coverage) do doc_coverage = Decimal.round(doc_coverage, 1) moduledoc_coverage = Decimal.round(moduledoc_coverage, 1) spec_coverage = Decimal.round(spec_coverage, 1) print_divider() Mix.shell().info("Summary:\n") Mix.shell().info("Passed Modules: #{passed}") Mix.shell().info("Failed Modules: #{failed}") Mix.shell().info("Total Doc Coverage: #{doc_coverage}%") Mix.shell().info("Total Moduledoc Coverage: #{moduledoc_coverage}%") Mix.shell().info("Total Spec Coverage: #{spec_coverage}%\n") msg = case errors do [] -> "Doctor validation has passed!" [err] -> """ #{ANSI.red()}Doctor validation has failed because #{err}.#{ANSI.reset()} """ errs -> """ #{ANSI.red()}Doctor validation has failed because: * #{Enum.map_join(errs, ".\n * ", &String.capitalize(&1))}.\ #{ANSI.reset()} """ end Mix.shell().info(msg) end defp massage_coverage(coverage) do if coverage do "#{Decimal.round(coverage)}%" else "N/A" end end defp massage_module_doc(%{is_protocol_implementation: true}), do: "N/A" defp massage_module_doc(%{has_module_doc: true}), do: "Yes" defp massage_module_doc(%{has_module_doc: false}), do: "No" defp massage_struct_type_spec(:not_struct), do: "N/A" defp massage_struct_type_spec(true), do: "Yes" defp massage_struct_type_spec(false), do: "No" end ================================================ FILE: lib/reporters/module_explain.ex ================================================ defmodule Doctor.Reporters.ModuleExplain do @moduledoc """ This module produces a report for a single project module. This is useful when you need to figure out exactly why a particular module failed validation. The only validations that are taken into account during this report are single module validations. In other words, the only thing that is checked are things that pertain to a single module like: - `min_module_doc_coverage` - `min_module_spec_coverage` - `moduledoc_required` - `exception_moduledoc_required` - `struct_type_spec_required` """ alias Doctor.{Config, Docs, Specs} alias Doctor.{ModuleInformation, ModuleReport} alias Doctor.Reporters.OutputUtils @doc """ Generate the output for a single module report """ def generate_report(%ModuleInformation{} = module_information, %Config{} = config) do module_report = ModuleReport.build(module_information) user_defined_functions = module_information.user_defined_functions module_docs = module_information.docs module_specs = module_information.specs # Get max function name length # 13 is picked as the starting acc as that is the length of "Function Name" # which is the column header max_length = Enum.reduce(user_defined_functions, 13, fn {function, _arity, _impl}, acc -> length = function |> Atom.to_string() |> String.length() if length > acc, do: length, else: acc end) |> Kernel.+(5) # Print table header generate_header(max_length) OutputUtils.print_divider(max_length + 11) # Print per function information Enum.each(user_defined_functions, fn {function, arity, impl} -> function_name = function |> Atom.to_string() |> Kernel.<>("/#{arity}") |> OutputUtils.gen_fixed_width_string(max_length) has_doc = function |> has_doc(arity, impl, module_docs) |> OutputUtils.print_pass_or_fail() |> OutputUtils.gen_fixed_width_string(6) has_spec = function |> has_spec(arity, impl, module_specs) |> OutputUtils.print_pass_or_fail() |> OutputUtils.gen_fixed_width_string(6) Mix.shell().info("#{function_name}#{has_doc}#{has_spec}") end) # Print module summary info Mix.shell().info("\nModule Results:") print_doc_coverage(module_report, config) print_spec_coverage(module_report, config) print_module_doc(module_report, config) print_struct_spec(module_report, config) # Determine whether the module passed or failed valid_module?(module_report, config) end defp valid_module?(%ModuleReport{is_protocol_implementation: true}, _config), do: true defp valid_module?(module_report, config) do valid_struct_spec?(module_report, config) and valid_moduledoc?(module_report, config) and valid_doc_coverage?(module_report, config) and valid_spec_coverage?(module_report, config) end defp valid_struct_spec?(module_report, config) do (config.struct_type_spec_required and module_report.has_struct_type_spec == :not_struct) or module_report.has_struct_type_spec end defp valid_moduledoc?(%ModuleReport{is_protocol_implementation: true}, _config), do: true defp valid_moduledoc?(module_report, config) do (not config.exception_moduledoc_required and module_report.properties[:is_exception]) or (Config.moduledoc_required?(config) and module_report.has_module_doc) end defp valid_doc_coverage?(%ModuleReport{is_protocol_implementation: true}, _config), do: true defp valid_doc_coverage?(module_report, config) do doc_coverage(module_report) >= config.min_module_doc_coverage end defp valid_spec_coverage?(module_report, config) do spec_coverage(module_report) >= config.min_module_spec_coverage end defp doc_coverage(module_report) do module_report.doc_coverage |> Decimal.round(1) |> Decimal.to_float() end defp spec_coverage(module_report) do module_report.spec_coverage |> Decimal.round(1) |> Decimal.to_float() end defp print_struct_spec(%ModuleReport{} = module_report, %Config{} = config) do if valid_struct_spec?(module_report, config) do OutputUtils.print_success( " Has Struct Spec: #{OutputUtils.print_pass_or_fail(module_report.has_struct_type_spec)}" ) else OutputUtils.print_error( " Has Struct Spec: #{OutputUtils.print_pass_or_fail(module_report.has_struct_type_spec)} --> Your config has a 'struct_type_spec_required' value of true" ) end end defp print_module_doc(%ModuleReport{is_protocol_implementation: true}, %Config{} = _config) do OutputUtils.print_success(" Has Module Doc: N/A") end defp print_module_doc(%ModuleReport{} = module_report, %Config{} = config) do if valid_moduledoc?(module_report, config) do OutputUtils.print_success(" Has Module Doc: #{OutputUtils.print_pass_or_fail(module_report.has_module_doc)}") else config_option = case module_report.properties[:is_exception] do true -> "an 'exception_moduledoc_required'" _ -> "a 'moduledoc_required'" end OutputUtils.print_error( " Has Module Doc: #{OutputUtils.print_pass_or_fail(module_report.has_module_doc)} --> Your config has #{config_option} value of true" ) end end defp print_doc_coverage(%ModuleReport{is_protocol_implementation: true}, %Config{} = _config) do OutputUtils.print_success(" Doc Coverage: N/A") end defp print_doc_coverage(%ModuleReport{} = module_report, %Config{} = config) do doc_coverage = doc_coverage(module_report) if doc_coverage >= config.min_module_doc_coverage do OutputUtils.print_success(" Doc Coverage: #{doc_coverage}%") else OutputUtils.print_error( " Doc Coverage: #{doc_coverage}% --> Your config has a 'min_module_doc_coverage' value of #{config.min_module_doc_coverage}" ) end end defp print_spec_coverage(%ModuleReport{is_protocol_implementation: true}, %Config{} = _config) do OutputUtils.print_success(" Spec Coverage: N/A") end defp print_spec_coverage(%ModuleReport{} = module_report, %Config{} = config) do spec_coverage = spec_coverage(module_report) if spec_coverage >= config.min_module_spec_coverage do OutputUtils.print_success(" Spec Coverage: #{spec_coverage}%") else OutputUtils.print_error( " Spec Coverage: #{spec_coverage}% --> Your config has a 'min_module_spec_coverage' value of #{config.min_module_spec_coverage}" ) end end defp generate_header(function_name_length) do output_line = OutputUtils.generate_table_line([ {"Function", function_name_length}, {"@doc", 6}, {"@spec", 7} ]) Mix.shell().info("\n#{output_line}") end defp has_doc(function, arity, :none, module_docs) do Enum.any?(module_docs, fn %Docs{arity: ^arity, name: ^function, doc: doc} when doc != :none -> true _ -> false end) end defp has_doc(_, _, _, _) do true end defp has_spec(function, arity, :none, module_specs) do Enum.any?(module_specs, fn %Specs{arity: ^arity, name: ^function} -> true _ -> false end) end defp has_spec(_, _, _, _) do true end end ================================================ FILE: lib/reporters/output_utils.ex ================================================ defmodule Doctor.Reporters.OutputUtils do @moduledoc """ This module provides convenience functions for use when generating reports """ alias Elixir.IO.ANSI @doc """ Generate a line in a table with the given width and padding. Expects a list with either a 2 or 3 element tuple. """ def generate_table_line(line_data) do line_data |> Enum.reduce("", fn {value, width}, acc -> "#{acc}#{gen_fixed_width_string(value, width)}" {value, width, padding}, acc -> "#{acc}#{gen_fixed_width_string(value, width, padding)}" end) end @doc """ Prints a divider of a given length """ def print_divider(length) do "-" |> String.duplicate(length) |> Mix.shell().info() end @doc """ Prints a checkmark of an X if true of false is provided respectively """ def print_pass_or_fail(true), do: "\u2713" def print_pass_or_fail(false), do: "\u2717" def print_pass_or_fail(:not_struct), do: "N/A" @doc """ Prints a string in red """ def print_error(string), do: Mix.shell().info(ANSI.red() <> string <> ANSI.reset()) @doc """ Prints a string in green """ def print_success(string), do: Mix.shell().info(ANSI.green() <> string <> ANSI.reset()) @doc """ Generate a string with a configure amount of width and padding """ def gen_fixed_width_string(value, width, padding \\ 2) def gen_fixed_width_string(value, width, padding) when is_atom(value) do value |> Atom.to_string() |> gen_fixed_width_string(width, padding) end def gen_fixed_width_string(value, width, padding) when is_integer(value) do value |> Integer.to_string() |> gen_fixed_width_string(width, padding) end def gen_fixed_width_string(value, width, padding) do sub_string_length = width - (padding + 1) value |> String.slice(0..sub_string_length) |> String.pad_trailing(width) end end ================================================ FILE: lib/reporters/short.ex ================================================ defmodule Doctor.Reporters.Short do @moduledoc """ This reporter generates a full documentation coverage report and lists all the files in the project along with whether they pass or fail. """ @behaviour Doctor.Reporter alias Elixir.IO.ANSI alias Doctor.{Reporters.OutputUtils, ReportUtils} @doc_cov_width 9 @spec_cov_width 10 @module_width 41 @functions_width 11 @module_doc_width 12 @struct_type_spec_width 11 @doc """ Generate a short Doctor report and print to STDOUT """ @impl true def generate_report(module_reports, args) do print_divider() print_header() Enum.each(module_reports, fn module_report -> doc_cov = massage_coverage(module_report.doc_coverage) spec_cov = massage_coverage(module_report.spec_coverage) module_doc = massage_module_doc(module_report) struct_type_spec = massage_struct_type_spec(module_report.has_struct_type_spec) output_line = OutputUtils.generate_table_line([ {doc_cov, @doc_cov_width}, {spec_cov, @spec_cov_width}, {module_report.functions, @functions_width}, {module_report.module, @module_width}, {module_doc, @module_doc_width}, {struct_type_spec, @struct_type_spec_width, 0} ]) if ReportUtils.module_passed_validation?(module_report, args) do unless args.failed do Mix.shell().info(output_line) end else Mix.shell().info(ANSI.red() <> output_line <> ANSI.reset()) end end) overall_pass = ReportUtils.doctor_report_passed?(module_reports, args) overall_passed = ReportUtils.count_total_passed_modules(module_reports, args) overall_failed = ReportUtils.count_total_failed_modules(module_reports, args) overall_doc_coverage = ReportUtils.calc_overall_doc_coverage(module_reports) overall_moduledoc_coverage = ReportUtils.calc_overall_moduledoc_coverage(module_reports) overall_spec_coverage = ReportUtils.calc_overall_spec_coverage(module_reports) print_footer( overall_pass, overall_passed, overall_failed, overall_doc_coverage, overall_moduledoc_coverage, overall_spec_coverage ) end defp print_header() do output_header = OutputUtils.generate_table_line([ {"Doc Cov", @doc_cov_width}, {"Spec Cov", @spec_cov_width}, {"Functions", @functions_width}, {"Module", @module_width}, {"Module Doc", @module_doc_width}, {"Struct Spec", @struct_type_spec_width, 0} ]) Mix.shell().info(output_header) end defp print_divider do "-" |> String.duplicate(94) |> Mix.shell().info() end defp print_footer(pass, passed, failed, doc_coverage, moduledoc_coverage, spec_coverage) do doc_coverage = Decimal.round(doc_coverage, 1) moduledoc_coverage = Decimal.round(moduledoc_coverage, 1) spec_coverage = Decimal.round(spec_coverage, 1) print_divider() Mix.shell().info("Summary:\n") Mix.shell().info("Passed Modules: #{passed}") Mix.shell().info("Failed Modules: #{failed}") Mix.shell().info("Total Doc Coverage: #{doc_coverage}%") Mix.shell().info("Total Moduledoc Coverage: #{moduledoc_coverage}%") Mix.shell().info("Total Spec Coverage: #{spec_coverage}%\n") if pass do Mix.shell().info("Doctor validation has passed!") else Mix.shell().info(ANSI.red() <> "Doctor validation has failed!" <> ANSI.reset()) end end defp massage_coverage(coverage) do if coverage do "#{Decimal.round(coverage)}%" else "N/A" end end defp massage_module_doc(%{is_protocol_implementation: true}), do: "N/A" defp massage_module_doc(%{has_module_doc: true}), do: "Yes" defp massage_module_doc(%{has_module_doc: false}), do: "No" defp massage_struct_type_spec(:not_struct), do: "N/A" defp massage_struct_type_spec(true), do: "Yes" defp massage_struct_type_spec(false), do: "No" end ================================================ FILE: lib/reporters/summary.ex ================================================ defmodule Doctor.Reporters.Summary do @moduledoc """ This reporter generates a short summary documentation coverage report and lists overall how many modules passed/failed. """ @behaviour Doctor.Reporter alias Elixir.IO.ANSI alias Doctor.ReportUtils @doc """ Generate a short summary Doctor report and print to STDOUT """ @impl true def generate_report(module_reports, args) do overall_pass = ReportUtils.doctor_report_passed?(module_reports, args) overall_passed = ReportUtils.count_total_passed_modules(module_reports, args) overall_failed = ReportUtils.count_total_failed_modules(module_reports, args) overall_doc_coverage = ReportUtils.calc_overall_doc_coverage(module_reports) overall_moduledoc_coverage = ReportUtils.calc_overall_moduledoc_coverage(module_reports) overall_spec_coverage = ReportUtils.calc_overall_spec_coverage(module_reports) print_footer( overall_pass, overall_passed, overall_failed, overall_doc_coverage, overall_moduledoc_coverage, overall_spec_coverage ) end defp print_divider do "-" |> String.duplicate(45) |> Mix.shell().info() end defp print_footer(pass, passed, failed, doc_coverage, moduledoc_coverage, spec_coverage) do doc_coverage = Decimal.round(doc_coverage, 1) moduledoc_coverage = Decimal.round(moduledoc_coverage, 1) spec_coverage = Decimal.round(spec_coverage, 1) print_divider() Mix.shell().info("Summary:\n") Mix.shell().info("Passed Modules: #{passed}") Mix.shell().info("Failed Modules: #{failed}") Mix.shell().info("Total Doc Coverage: #{doc_coverage}%") Mix.shell().info("Total Moduledoc Coverage: #{moduledoc_coverage}%") Mix.shell().info("Total Spec Coverage: #{spec_coverage}%\n") if pass do Mix.shell().info("Doctor validation has passed!") else Mix.shell().info(ANSI.red() <> "Doctor validation has failed!" <> ANSI.reset()) end end end ================================================ FILE: lib/specs.ex ================================================ defmodule Doctor.Specs do @moduledoc """ This module defines a struct which houses all the documentation data for function specs. """ alias __MODULE__ @type t :: %Specs{ name: atom(), arity: integer() } defstruct ~w(name arity)a @doc """ Build a spec definition for each result from Code.Typespec.fetch_specs/1 """ def build({{name, arity}, _spec}) do %Specs{ name: name, arity: arity } end end ================================================ FILE: mix.exs ================================================ defmodule Doctor.MixProject do use Mix.Project @source_url "https://github.com/akoutmos/doctor" def project do [ app: :doctor, version: "0.22.0", elixir: "~> 1.14", name: "Doctor", source_url: @source_url, homepage_url: "https://hex.pm/packages/doctor", description: "Simple utility to create documentation coverage reports", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, docs: [ main: "readme", extras: ["README.md", "CHANGELOG.md"] ], package: package(), deps: deps(), test_coverage: [tool: ExCoveralls], preferred_cli_env: [ coveralls: :test, "coveralls.detail": :test, "coveralls.post": :test, "coveralls.html": :test, "coveralls.github": :test ] ] end def application do [ extra_applications: [:logger] ] end defp elixirc_paths(:test), do: ["lib", "test/sample_files"] defp elixirc_paths(_), do: ["lib"] defp package() do [ name: "doctor", files: ~w(lib mix.exs README.md LICENSE CHANGELOG.md), licenses: ["MIT"], links: %{ "GitHub" => @source_url, "Changelog" => "https://hexdocs.pm/doctor/changelog.html", "Sponsor" => "https://github.com/sponsors/akoutmos" } ] end defp deps do [ # Production dependencies {:decimal, "~> 2.0"}, # Development and testing dependencies {:ex_doc, "~> 0.34", only: :dev, runtime: false}, {:excoveralls, "~> 0.14", only: :test, runtime: false} ] end end ================================================ FILE: test/config_test.exs ================================================ defmodule Doctor.ConfigTest do use ExUnit.Case, async: true alias Doctor.Config test "config_defaults_as_string" do assert %Doctor.Config{ exception_moduledoc_required: true, failed: false, ignore_modules: [], ignore_paths: [], min_module_doc_coverage: 40, min_module_spec_coverage: 0, min_overall_doc_coverage: 50, min_overall_moduledoc_coverage: 100, min_overall_spec_coverage: 0, raise: false, reporter: Doctor.Reporters.Full, struct_type_spec_required: true, umbrella: false } == Config.config_defaults_as_string() |> Code.eval_string() |> elem(0) end end ================================================ FILE: test/configs/exceptions_moduledoc_not_required.exs ================================================ %Doctor.Config{ ignore_modules: [], ignore_paths: [], min_module_doc_coverage: 80, min_module_spec_coverage: 0, min_overall_doc_coverage: 100, min_overall_moduledoc_coverage: 100, min_overall_spec_coverage: 0, exception_moduledoc_required: false, raise: false, reporter: Doctor.Reporters.Full, struct_type_spec_required: true, umbrella: false } ================================================ FILE: test/configs/exceptions_moduledoc_required.exs ================================================ %Doctor.Config{ ignore_modules: [], ignore_paths: [], min_module_doc_coverage: 80, min_module_spec_coverage: 0, min_overall_doc_coverage: 100, min_overall_moduledoc_coverage: 100, min_overall_spec_coverage: 0, exception_moduledoc_required: true, raise: false, reporter: Doctor.Reporters.Full, struct_type_spec_required: true, umbrella: false } ================================================ FILE: test/mix_doctor_test.exs ================================================ defmodule Mix.Tasks.DoctorTest do use ExUnit.Case, async: false setup_all do original_shell = Mix.shell() Mix.shell(Mix.Shell.Process) on_exit(fn -> Mix.shell(original_shell) end) end describe "mix doctor" do test "should output the full report when no params are provided" do Mix.Tasks.Doctor.run([]) remove_at_exit_hook() doctor_output = get_shell_output() assert doctor_output == [ ["Doctor file found. Loading configuration."], [ "---------------------------------------------------------------------------------------------------------------------------------------------------------------------------" ], [ "Doc Cov Spec Cov Module File Functions No Docs No Specs Module Doc Struct Spec" ], [ "100% 0% Doctor.CLI lib/cli/cli.ex 3 0 3 Yes N/A " ], [ "100% 50% Doctor.Config lib/config.ex 4 0 2 Yes Yes " ], [ "100% 0% Doctor.Docs lib/docs.ex 1 0 1 Yes Yes " ], [ "N/A N/A Doctor lib/doctor.ex 0 0 0 Yes N/A " ], [ "100% 100% Mix.Tasks.Doctor lib/mix/tasks/doctor.ex 1 0 0 Yes N/A " ], [ "100% 100% Mix.Tasks.Doctor.Explain lib/mix/tasks/doctor.explain.ex 1 0 0 Yes N/A " ], [ "100% 100% Mix.Tasks.Doctor.Gen.Config lib/mix/tasks/doctor.gen.config.ex 1 0 0 Yes N/A " ], [ "100% 0% Doctor.ModuleInformation lib/module_information.ex 4 0 4 Yes Yes " ], [ "100% 0% Doctor.ModuleReport lib/module_report.ex 1 0 1 Yes Yes " ], [ "100% 9% Doctor.ReportUtils lib/report_utils.ex 11 0 10 Yes N/A " ], [ "N/A N/A Doctor.Reporter lib/reporter.ex 0 0 0 Yes N/A " ], [ "100% 100% Doctor.Reporters.Full lib/reporters/full.ex 1 0 0 Yes N/A " ], [ "100% 0% Doctor.Reporters.ModuleExplain lib/reporters/module_explain.ex 1 0 1 Yes N/A " ], [ "100% 0% Doctor.Reporters.OutputUtils lib/reporters/output_utils.ex 6 0 6 Yes N/A " ], [ "100% 100% Doctor.Reporters.Short lib/reporters/short.ex 1 0 0 Yes N/A " ], [ "100% 100% Doctor.Reporters.Summary lib/reporters/summary.ex 1 0 0 Yes N/A " ], [ "100% 0% Doctor.Specs lib/specs.ex 1 0 1 Yes Yes " ], [ "---------------------------------------------------------------------------------------------------------------------------------------------------------------------------" ], ["Summary:\n"], ["Passed Modules: 17"], ["Failed Modules: 0"], ["Total Doc Coverage: 100.0%"], ["Total Moduledoc Coverage: 100.0%"], ["Total Spec Coverage: 23.7%\n"], ["Doctor validation has passed!"] ] end test "should output the summary report along with an error when an invalid doctor file path is provided" do Mix.Tasks.Doctor.run(["--summary", "--config-file", "./not_a_real_file.exs"]) remove_at_exit_hook() [[first_line] | rest_doctor_output] = get_shell_output() assert first_line =~ "Doctor file not found at path" assert first_line =~ "not_a_real_file.exs" assert rest_doctor_output == [ ["---------------------------------------------"], ["Summary:\n"], ["Passed Modules: 28"], ["Failed Modules: 8"], ["Total Doc Coverage: 82.9%"], ["Total Moduledoc Coverage: 76.5%"], ["Total Spec Coverage: 42.1%\n"], ["\e[31mDoctor validation has failed!\e[0m"] ] end test "should not report exceptions missing docs if `exception_moduledoc_required` is set to `false`" do Mix.Tasks.Doctor.run(["--summary", "--config-file", "./test/configs/exceptions_moduledoc_not_required.exs"]) remove_at_exit_hook() doctor_output = get_shell_output() assert doctor_output == [ ["Doctor file found. Loading configuration."], ["---------------------------------------------"], ["Summary:\n"], ["Passed Modules: 28"], ["Failed Modules: 8"], ["Total Doc Coverage: 82.9%"], ["Total Moduledoc Coverage: 76.5%"], ["Total Spec Coverage: 42.1%\n"], ["\e[31mDoctor validation has failed!\e[0m"] ] end test "should output the failed modules and the summary report when --failed is provided" do Mix.Tasks.Doctor.run([ "--short", "--failed", "--config-file", "./test/configs/exceptions_moduledoc_not_required.exs" ]) remove_at_exit_hook() doctor_output = get_shell_output() assert doctor_output == [ ["Doctor file found. Loading configuration."], ["----------------------------------------------------------------------------------------------"], ["Doc Cov Spec Cov Functions Module Module Doc Struct Spec"], [ "\e[31mN/A N/A 0 Doctor.AnotherBehaviourModule.Behaviour No N/A \e[0m" ], [ "\e[31m100% 100% 1 Doctor.AnotherBehaviourModule No N/A \e[0m" ], [ "\e[31m0% 0% 7 Doctor.NoDocs No N/A \e[0m" ], [ "\e[31mN/A N/A 0 Doctor.NoStructSpecModule No No \e[0m" ], [ "\e[31mN/A N/A 0 Doctor.OpaqueStructSpecModule No Yes \e[0m" ], [ "\e[31m57% 57% 7 Doctor.PartialDocs No N/A \e[0m" ], [ "\e[31mN/A N/A 0 Doctor.StructSpecModule No Yes \e[0m" ], [ "\e[31m50% 50% 4 Doctor.UseModule Yes N/A \e[0m" ], ["----------------------------------------------------------------------------------------------"], ["Summary:\n"], ["Passed Modules: 28"], ["Failed Modules: 8"], ["Total Doc Coverage: 82.9%"], ["Total Moduledoc Coverage: 76.5%"], ["Total Spec Coverage: 42.1%\n"], ["\e[31mDoctor validation has failed!\e[0m"] ] end test "should output the summary report when a doctor file path is provided" do Mix.Tasks.Doctor.run(["--summary", "--config-file", "./.doctor.exs"]) remove_at_exit_hook() doctor_output = get_shell_output() assert doctor_output == [ ["Doctor file found. Loading configuration."], ["---------------------------------------------"], ["Summary:\n"], ["Passed Modules: 17"], ["Failed Modules: 0"], ["Total Doc Coverage: 100.0%"], ["Total Moduledoc Coverage: 100.0%"], ["Total Spec Coverage: 23.7%\n"], ["Doctor validation has passed!"] ] end test "should output the summary report with the correct output if given the --summary flag" do Mix.Tasks.Doctor.run(["--summary"]) remove_at_exit_hook() doctor_output = get_shell_output() assert doctor_output == [ ["Doctor file found. Loading configuration."], ["---------------------------------------------"], ["Summary:\n"], ["Passed Modules: 17"], ["Failed Modules: 0"], ["Total Doc Coverage: 100.0%"], ["Total Moduledoc Coverage: 100.0%"], ["Total Spec Coverage: 23.7%\n"], ["Doctor validation has passed!"] ] end test "should output the short report with the correct output if given the --short flag" do Mix.Tasks.Doctor.run(["--short"]) remove_at_exit_hook() doctor_output = get_shell_output() assert doctor_output == [ ["Doctor file found. Loading configuration."], ["----------------------------------------------------------------------------------------------"], ["Doc Cov Spec Cov Functions Module Module Doc Struct Spec"], ["100% 0% 3 Doctor.CLI Yes N/A "], ["100% 50% 4 Doctor.Config Yes Yes "], ["100% 0% 1 Doctor.Docs Yes Yes "], ["N/A N/A 0 Doctor Yes N/A "], ["100% 100% 1 Mix.Tasks.Doctor Yes N/A "], ["100% 100% 1 Mix.Tasks.Doctor.Explain Yes N/A "], ["100% 100% 1 Mix.Tasks.Doctor.Gen.Config Yes N/A "], ["100% 0% 4 Doctor.ModuleInformation Yes Yes "], ["100% 0% 1 Doctor.ModuleReport Yes Yes "], ["100% 9% 11 Doctor.ReportUtils Yes N/A "], ["N/A N/A 0 Doctor.Reporter Yes N/A "], ["100% 100% 1 Doctor.Reporters.Full Yes N/A "], ["100% 0% 1 Doctor.Reporters.ModuleExplain Yes N/A "], ["100% 0% 6 Doctor.Reporters.OutputUtils Yes N/A "], ["100% 100% 1 Doctor.Reporters.Short Yes N/A "], ["100% 100% 1 Doctor.Reporters.Summary Yes N/A "], ["100% 0% 1 Doctor.Specs Yes Yes "], ["----------------------------------------------------------------------------------------------"], ["Summary:\n"], ["Passed Modules: 17"], ["Failed Modules: 0"], ["Total Doc Coverage: 100.0%"], ["Total Moduledoc Coverage: 100.0%"], ["Total Spec Coverage: 23.7%\n"], ["Doctor validation has passed!"] ] end test "should output the full report with the correct output if given the --full flag" do Mix.Tasks.Doctor.run(["--full"]) remove_at_exit_hook() doctor_output = get_shell_output() assert doctor_output == [ ["Doctor file found. Loading configuration."], [ "---------------------------------------------------------------------------------------------------------------------------------------------------------------------------" ], [ "Doc Cov Spec Cov Module File Functions No Docs No Specs Module Doc Struct Spec" ], [ "100% 0% Doctor.CLI lib/cli/cli.ex 3 0 3 Yes N/A " ], [ "100% 50% Doctor.Config lib/config.ex 4 0 2 Yes Yes " ], [ "100% 0% Doctor.Docs lib/docs.ex 1 0 1 Yes Yes " ], [ "N/A N/A Doctor lib/doctor.ex 0 0 0 Yes N/A " ], [ "100% 100% Mix.Tasks.Doctor lib/mix/tasks/doctor.ex 1 0 0 Yes N/A " ], [ "100% 100% Mix.Tasks.Doctor.Explain lib/mix/tasks/doctor.explain.ex 1 0 0 Yes N/A " ], [ "100% 100% Mix.Tasks.Doctor.Gen.Config lib/mix/tasks/doctor.gen.config.ex 1 0 0 Yes N/A " ], [ "100% 0% Doctor.ModuleInformation lib/module_information.ex 4 0 4 Yes Yes " ], [ "100% 0% Doctor.ModuleReport lib/module_report.ex 1 0 1 Yes Yes " ], [ "100% 9% Doctor.ReportUtils lib/report_utils.ex 11 0 10 Yes N/A " ], [ "N/A N/A Doctor.Reporter lib/reporter.ex 0 0 0 Yes N/A " ], [ "100% 100% Doctor.Reporters.Full lib/reporters/full.ex 1 0 0 Yes N/A " ], [ "100% 0% Doctor.Reporters.ModuleExplain lib/reporters/module_explain.ex 1 0 1 Yes N/A " ], [ "100% 0% Doctor.Reporters.OutputUtils lib/reporters/output_utils.ex 6 0 6 Yes N/A " ], [ "100% 100% Doctor.Reporters.Short lib/reporters/short.ex 1 0 0 Yes N/A " ], [ "100% 100% Doctor.Reporters.Summary lib/reporters/summary.ex 1 0 0 Yes N/A " ], [ "100% 0% Doctor.Specs lib/specs.ex 1 0 1 Yes Yes " ], [ "---------------------------------------------------------------------------------------------------------------------------------------------------------------------------" ], ["Summary:\n"], ["Passed Modules: 17"], ["Failed Modules: 0"], ["Total Doc Coverage: 100.0%"], ["Total Moduledoc Coverage: 100.0%"], ["Total Spec Coverage: 23.7%\n"], ["Doctor validation has passed!"] ] end end describe "mix doctor.explain" do test "exception module with missing doc if `exception_moduledoc_required` is set to `true`" do Mix.Tasks.Doctor.Explain.run([ "--config-file", "./test/configs/exceptions_moduledoc_required.exs", "Doctor.Exception" ]) remove_at_exit_hook() doctor_output = get_shell_output() assert doctor_output == [ ["Doctor file found. Loading configuration."], ["\nFunction @doc @spec "], ["-----------------------------"], ["exception/1 ✓ ✓ "], ["\nModule Results:"], ["\e[32m Doc Coverage: 100.0%\e[0m"], ["\e[32m Spec Coverage: 100.0%\e[0m"], ["\e[31m Has Module Doc: ✗ --> Your config has an 'exception_moduledoc_required' value of true\e[0m"], ["\e[32m Has Struct Spec: N/A\e[0m"] ] end test "exception module with missing doc if `exception_moduledoc_required` is set to `false`" do Mix.Tasks.Doctor.Explain.run([ "--config-file", "./test/configs/exceptions_moduledoc_not_required.exs", "Doctor.Exception" ]) remove_at_exit_hook() doctor_output = get_shell_output() assert doctor_output == [ ["Doctor file found. Loading configuration."], ["\nFunction @doc @spec "], ["-----------------------------"], ["exception/1 ✓ ✓ "], ["\nModule Results:"], ["\e[32m Doc Coverage: 100.0%\e[0m"], ["\e[32m Spec Coverage: 100.0%\e[0m"], ["\e[32m Has Module Doc: ✗\e[0m"], ["\e[32m Has Struct Spec: N/A\e[0m"] ] end test "module with using macro and various inline functions" do Mix.Tasks.Doctor.Explain.run([ "--config-file", "./test/configs/exceptions_moduledoc_not_required.exs", "Doctor.UseModule" ]) remove_at_exit_hook() doctor_output = get_shell_output() assert doctor_output == [ ["Doctor file found. Loading configuration."], ["\nFunction @doc @spec "], ["----------------------------------------"], ["fun_without_spec_and_doc/0 ✗ ✗ "], ["fun_with_spec/0 ✗ ✓ "], ["fun_with_doc/0 ✓ ✗ "], ["fun_with_doc_and_spec/0 ✓ ✓ "], ["\nModule Results:"], ["\e[31m Doc Coverage: 50.0% --> Your config has a 'min_module_doc_coverage' value of 80\e[0m"], ["\e[32m Spec Coverage: 50.0%\e[0m"], ["\e[32m Has Module Doc: ✓\e[0m"], ["\e[32m Has Struct Spec: N/A\e[0m"] ] end end defp get_shell_output() do {:messages, message_mailbox} = Process.info(self(), :messages) Enum.map(message_mailbox, fn {:mix_shell, :info, message} -> message {:mix_shell, :error, message} -> message end) end defp remove_at_exit_hook() do at_exit_hooks = :elixir_config.get(:at_exit) filtered_hooks = Enum.reject(at_exit_hooks, fn hook -> function_info = Function.info(hook) Keyword.get(function_info, :module) in [Mix.Tasks.Doctor, Mix.Tasks.Doctor.Explain] end) :elixir_config.put(:at_exit, filtered_hooks) end end ================================================ FILE: test/module_information_test.exs ================================================ defmodule Doctor.ModuleInformationTest do use ExUnit.Case alias Doctor.{ModuleInformation, ModuleReport} describe "build/2" do test "should find all of the docs for a module where all docs are present" do full_func_list = [:func_1, :func_2, :func_3, :func_4, :func_5, :func_5, :func_6] module_information = Doctor.AllDocs |> Code.fetch_docs() |> ModuleInformation.build(Doctor.AllDocs) docs = module_information.docs |> Enum.map(fn func_doc -> func_doc.name end) |> Enum.sort() specs = module_information.specs |> Enum.map(fn func_spec -> func_spec.name end) |> Enum.sort() assert is_map(module_information.module_doc) assert module_information.file_ast == nil assert module_information.file_relative_path == "test/sample_files/all_docs.ex" assert specs == full_func_list assert docs == full_func_list end test "should report behaviour functions properly" do module_report = Doctor.AnotherBehaviourModule |> Code.fetch_docs() |> ModuleInformation.build(Doctor.AnotherBehaviourModule) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.missed_specs == 0 assert module_report.missed_docs == 0 end end describe "load_user_defined_functions/1" do test "should load user defined functions from AST" do module_information = Doctor.AllDocs |> Code.fetch_docs() |> ModuleInformation.build(Doctor.AllDocs) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() assert module_information != nil assert Enum.sort(module_information.user_defined_functions) == [ {:func_1, 1, :none}, {:func_2, 1, :none}, {:func_3, 1, :none}, {:func_4, 1, :none}, {:func_5, 2, :none}, {:func_5, 3, :none}, {:func_6, 1, :none} ] end test "parent of nested module should ignore functions from nested modules" do module_information = Doctor.ParentModule |> Code.fetch_docs() |> ModuleInformation.build(Doctor.ParentModule) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() assert module_information.user_defined_functions == [{:outer, 0, :none}] end test "nested module should include its functions" do module_information = Doctor.ParentModule.Nested |> Code.fetch_docs() |> ModuleInformation.build(Doctor.ParentModule.Nested) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() assert module_information.user_defined_functions == [{:inner, 0, :none}] end end end ================================================ FILE: test/module_report_test.exs ================================================ defmodule Doctor.ModuleReportTest do use ExUnit.Case alias Doctor.{ModuleInformation, ModuleReport} test "build/1 should build the correct report struct for a file with full coverage" do module_report = Doctor.AllDocs |> Code.fetch_docs() |> ModuleInformation.build(Doctor.AllDocs) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 7 assert module_report.has_module_doc assert module_report.missed_docs == 0 assert module_report.missed_specs == 0 assert module_report.module == "Doctor.AllDocs" assert module_report.doc_coverage == Decimal.new("100") assert module_report.properties == [is_exception: false, is_protocol_implementation: false] end test "build/1 should build the correct report struct for a file with partial coverage" do module_report = Doctor.PartialDocs |> Code.fetch_docs() |> ModuleInformation.build(Doctor.PartialDocs) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 7 refute module_report.has_module_doc assert module_report.missed_docs == 3 assert module_report.missed_specs == 3 assert module_report.module == "Doctor.PartialDocs" assert module_report.doc_coverage == Decimal.new("57.14285714285714285714285714") assert module_report.properties == [is_exception: false, is_protocol_implementation: false] end test "build/1 should build the correct report struct for a file that implements behaviour callbacks" do module_report = Doctor.BehaviourModule |> Code.fetch_docs() |> ModuleInformation.build(Doctor.BehaviourModule) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 3 assert module_report.has_module_doc assert module_report.missed_docs == 0 assert module_report.missed_specs == 0 assert module_report.module == "Doctor.BehaviourModule" assert module_report.doc_coverage == Decimal.new("100") assert module_report.properties == [is_exception: false, is_protocol_implementation: false] end test "build/1 should build the correct report struct for a file that implements behaviour callbacks with multiple clauses" do module_report = Doctor.FooBar |> Code.fetch_docs() |> ModuleInformation.build(Doctor.FooBar) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 6 assert module_report.has_module_doc assert module_report.missed_docs == 1 assert module_report.missed_specs == 3 assert module_report.module == "Doctor.FooBar" assert module_report.doc_coverage == Decimal.new("83.33333333333333333333333333") assert module_report.spec_coverage == Decimal.new("50.0") assert module_report.properties == [is_exception: false, is_protocol_implementation: false] end test "build/1 should build the correct report struct for a file with no coverage" do module_report = Doctor.NoDocs |> Code.fetch_docs() |> ModuleInformation.build(Doctor.NoDocs) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 7 refute module_report.has_module_doc assert module_report.missed_docs == 7 assert module_report.missed_specs == 7 assert module_report.module == "Doctor.NoDocs" assert module_report.doc_coverage == Decimal.new("0") assert module_report.properties == [is_exception: false, is_protocol_implementation: false] end test "build/1 should build the correct report struct for a file with struct specs" do module_report = Doctor.StructSpecModule |> Code.fetch_docs() |> ModuleInformation.build(Doctor.StructSpecModule) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 0 refute module_report.has_module_doc assert module_report.has_struct_type_spec assert module_report.missed_docs == 0 assert module_report.missed_specs == 0 assert module_report.module == "Doctor.StructSpecModule" assert module_report.doc_coverage == nil assert module_report.properties == [is_exception: false, is_protocol_implementation: false] end test "build/1 should build the correct report struct for a file with no struct specs" do module_report = Doctor.NoStructSpecModule |> Code.fetch_docs() |> ModuleInformation.build(Doctor.NoStructSpecModule) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 0 refute module_report.has_module_doc refute module_report.has_struct_type_spec assert module_report.missed_docs == 0 assert module_report.missed_specs == 0 assert module_report.module == "Doctor.NoStructSpecModule" assert module_report.doc_coverage == nil assert module_report.properties == [is_exception: false, is_protocol_implementation: false] end test "build/1 should build the correct report struct for a file with an opaque struct spec" do module_report = Doctor.OpaqueStructSpecModule |> Code.fetch_docs() |> ModuleInformation.build(Doctor.OpaqueStructSpecModule) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 0 refute module_report.has_module_doc assert module_report.has_struct_type_spec assert module_report.missed_docs == 0 assert module_report.missed_specs == 0 assert module_report.module == "Doctor.OpaqueStructSpecModule" assert module_report.doc_coverage == nil assert module_report.properties == [is_exception: false, is_protocol_implementation: false] end test "build/1 should build the correct report for an exception" do module_report = Doctor.Exception |> Code.fetch_docs() |> ModuleInformation.build(Doctor.Exception) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 1 refute module_report.has_module_doc assert module_report.has_struct_type_spec assert module_report.missed_docs == 0 assert module_report.missed_specs == 0 assert module_report.module == "Doctor.Exception" assert module_report.doc_coverage == Decimal.new("100") assert module_report.properties == [is_exception: true, is_protocol_implementation: false] end test "build/1 should build the correct report for a module with __using__ macro" do module_report = Doctor.UseModule |> Code.fetch_docs() |> ModuleInformation.build(Doctor.UseModule) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 4 assert module_report.has_module_doc assert module_report.has_struct_type_spec == :not_struct assert module_report.missed_docs == 2 assert module_report.missed_specs == 2 assert module_report.module == "Doctor.UseModule" assert module_report.doc_coverage == Decimal.new("50.0") assert module_report.spec_coverage == Decimal.new("50.0") assert module_report.properties == [is_exception: false, is_protocol_implementation: false] end test "build/1 should build the correct report for a module with a nested module" do module_report = Doctor.ParentModule |> Code.fetch_docs() |> ModuleInformation.build(Doctor.ParentModule) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 1 assert module_report.has_module_doc assert module_report.has_struct_type_spec == :not_struct assert module_report.missed_docs == 0 assert module_report.missed_specs == 0 assert module_report.module == "Doctor.ParentModule" assert module_report.doc_coverage == Decimal.new("100") assert module_report.spec_coverage == Decimal.new("100") assert module_report.properties == [is_exception: false, is_protocol_implementation: false] end test "build/1 should build the correct report for a nested module" do module_report = Doctor.ParentModule.Nested |> Code.fetch_docs() |> ModuleInformation.build(Doctor.ParentModule.Nested) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.functions == 1 assert module_report.has_module_doc assert module_report.has_struct_type_spec == :not_struct assert module_report.missed_docs == 0 assert module_report.missed_specs == 0 assert module_report.module == "Doctor.ParentModule.Nested" assert module_report.doc_coverage == Decimal.new("100") assert module_report.spec_coverage == Decimal.new("100") assert module_report.properties == [is_exception: false, is_protocol_implementation: false] end test "build/1 should build the correct report for a protocol derivation" do module_report = Inspect.Doctor.DeriveProtocol |> Code.fetch_docs() |> ModuleInformation.build(Inspect.Doctor.DeriveProtocol) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.is_protocol_implementation == true assert module_report.functions == 0 assert module_report.has_module_doc == true assert module_report.has_struct_type_spec == :not_struct assert module_report.missed_docs == 0 assert module_report.missed_specs == 0 assert module_report.module == "Inspect.Doctor.DeriveProtocol" assert module_report.doc_coverage == nil assert module_report.spec_coverage == nil assert module_report.properties == [is_exception: false, is_protocol_implementation: true] end test "build/1 should build the correct report for a protocol implementation" do module_report = Inspect.Doctor.ImplementProtocol |> Code.fetch_docs() |> ModuleInformation.build(Inspect.Doctor.ImplementProtocol) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() assert module_report.is_protocol_implementation == true assert module_report.functions == 0 assert module_report.has_module_doc == true assert module_report.has_struct_type_spec == :not_struct assert module_report.missed_docs == 0 assert module_report.missed_specs == 0 assert module_report.module == "Inspect.Doctor.ImplementProtocol" assert module_report.doc_coverage == nil assert module_report.spec_coverage == nil assert module_report.properties == [is_exception: false, is_protocol_implementation: true] end end ================================================ FILE: test/report_utils_test.exs ================================================ defmodule Doctor.ReportUtilsTest do use ExUnit.Case import ExUnit.CaptureLog alias Doctor.{ModuleInformation, ModuleReport, ReportUtils} setup do reports = [Doctor.AllDocs, Doctor.PartialDocs, Doctor.NoDocs] |> Enum.map(fn module -> report = module |> Code.fetch_docs() |> ModuleInformation.build(module) |> ModuleInformation.load_file_ast() |> ModuleInformation.load_user_defined_functions() |> ModuleReport.build() {module, report} end) |> Map.new() %{reports: reports} end test "count_total_functions/1 should return the correct number of functions across a list of module reports", %{reports: reports} do assert reports |> Map.values() |> ReportUtils.count_total_functions() == 21 end test "count_total_documented_functions/1 should return the correct number of documented functions across a list of module reports", %{reports: reports} do assert reports |> Map.values() |> ReportUtils.count_total_documented_functions() == 11 end test "count_total_speced_functions/1 should return the correct number of speced functions across a list of module reports", %{reports: reports} do assert reports |> Map.values() |> ReportUtils.count_total_speced_functions() == 11 end test "count_total_passed_modules/1 should return the correct number of failed modules from a list of module reports if moduledoc config true", %{reports: reports} do config = %Doctor.Config{min_overall_moduledoc_coverage: 100} assert reports |> Map.values() |> ReportUtils.count_total_passed_modules(config) == 1 end test "count_total_passed_modules/1 should return the correct number of failed modules from a list of module reports if config threshold set low", %{reports: reports} do config = %Doctor.Config{min_overall_doc_coverage: 20, min_overall_moduledoc_coverage: 100} assert reports |> Map.values() |> ReportUtils.count_total_passed_modules(config) == 1 end test "count_total_failed_modules/1 should return the correct number of failed modules from a list of module reports if moduledoc config true", %{reports: reports} do config = %Doctor.Config{min_overall_moduledoc_coverage: 100} assert reports |> Map.values() |> ReportUtils.count_total_failed_modules(config) == 2 end test "count_total_failed_modules/1 should return the correct number of failed modules from a list of module reports if config threshold set low", %{reports: reports} do config = %Doctor.Config{min_overall_doc_coverage: 20, min_overall_moduledoc_coverage: 100} assert reports |> Map.values() |> ReportUtils.count_total_failed_modules(config) == 2 end test "calc_overall_doc_coverage/1 should return the correct percentage a list of module reports", %{reports: reports} do assert reports |> Map.values() |> ReportUtils.calc_overall_doc_coverage() == Decimal.new("52.38095238095238095238095238") end test "calc_overall_spec_coverage/1 should return the correct percentage a list of module reports", %{reports: reports} do assert reports |> Map.values() |> ReportUtils.calc_overall_spec_coverage() == Decimal.new("52.38095238095238095238095238") end test "doctor_report_passed?/2 should return false if the report fails given required moduledocs", %{ reports: reports } do config = %Doctor.Config{min_overall_moduledoc_coverage: 100} refute reports |> Map.values() |> ReportUtils.doctor_report_passed?(config) end test "doctor_report_passed?/2 should return false if the report fails given high threshold", %{ reports: reports } do config = %Doctor.Config{ min_module_doc_coverage: 0, min_overall_moduledoc_coverage: 0, min_overall_doc_coverage: 80 } refute reports |> Map.values() |> ReportUtils.doctor_report_passed?(config) end test "doctor_report_passed?/2 should return false if the report fails given low threshold", %{ reports: reports } do {config, warn_msg} = with_log(fn -> Doctor.Config.new( moduledoc_required: false, min_module_doc_coverage: 0, min_overall_doc_coverage: 50 ) end) assert warn_msg =~ ":moduledoc_required in .doctor.exs is a deprecated option." assert reports |> Map.values() |> ReportUtils.doctor_report_passed?(config) end end ================================================ FILE: test/sample_files/all_docs.ex ================================================ defmodule Doctor.AllDocs do @moduledoc "This is a module doc" @spec func_1(integer()) :: integer() @doc "Function doc 1" def func_1(input) do input + 1 end @spec func_2(integer()) :: integer() @doc """ Function doc 2 """ def func_2(input), do: input + 2 @spec func_3(integer()) :: integer() @doc "Function doc 3" def func_3(input) when is_integer(input) do input + 3 end @spec func_4(integer()) :: integer() @doc "Function doc 4" def func_4(input) when is_integer(input), do: input + 4 @spec func_5(integer(), integer()) :: integer() @doc "Function doc 5 with 2 args" def func_5(input_1, input_2) do func_5(input_1, input_2, 5) end @spec func_5(integer(), integer(), integer()) :: integer() @doc "Function doc 5 with 3 args" def func_5(input_1, input_2, input_3) do input_1 + input_2 + input_3 end @spec func_6(String.t()) :: String.t() @doc "Function doc 6" def func_6("match" = input), do: input def func_6("matches" = input), do: input def func_6("matcher" = input), do: input def func_6("matching" = input), do: input def func_6(_), do: "no match" end ================================================ FILE: test/sample_files/another_behaviour_module.ex ================================================ defmodule Doctor.AnotherBehaviourModule.Behaviour do @callback func() :: String.t() end defmodule Doctor.AnotherBehaviourModule do @behaviour Doctor.AnotherBehaviourModule.Behaviour @impl Doctor.AnotherBehaviourModule.Behaviour def func, do: "Hello world" end ================================================ FILE: test/sample_files/behaviour_module.ex ================================================ defmodule Doctor.BehaviourModule do @moduledoc """ This is a GenServer module that has 100% code coverage """ use GenServer @impl true def init(stack) do {:ok, stack} end @impl GenServer @doc "Something or other" def handle_call(:pop, _from, [head | tail]) do {:reply, head, tail} end def handle_call(:nop, _from, state) do {:reply, state} end @impl true def handle_cast({:push, element}, state) do {:noreply, [element | state]} end end ================================================ FILE: test/sample_files/custom_behaviour_module.ex ================================================ defmodule Doctor.FooBarBehaviour do @moduledoc """ A custom behaviour module """ @doc """ The famous foo function """ @callback foo(mode :: atom()) :: integer() @doc """ And the infamous bar function """ @callback bar(mode :: atom()) :: integer() @callback bar(mode :: atom(), param :: integer()) :: integer() end defmodule Doctor.FooBar do @moduledoc """ Implementation of the FooBarBehaviour """ @behaviour Doctor.FooBarBehaviour def foo(:five), do: 5 # This should not @impl true def foo(:one), do: 1 # neither this def foo(:two), do: 2 @impl Doctor.FooBarBehaviour def bar(:one), do: 1 def bar(:two), do: 2 def bar(:three), do: 3 # This should raise both a missing spec and a missing doc def bar(:test, value), do: value # This should pass @impl Doctor.FooBarBehaviour def bar(:bar, value), do: value # This should raise both a missing doc and spec def bar(:noop, _value1, _value2), do: 0 end ================================================ FILE: test/sample_files/derive_protocol.ex ================================================ defmodule Doctor.DeriveProtocol do @moduledoc """ An Example Derivation of a Protocol """ @derive Inspect defstruct [:foo, :bar] @type t :: %__MODULE__{foo: String.t(), bar: non_neg_integer} end ================================================ FILE: test/sample_files/exception.ex ================================================ defmodule Doctor.Exception do defexception [:message] @impl true def exception(value) do msg = "doctor exception: #{inspect(value)}" %Doctor.Exception{message: msg} end end ================================================ FILE: test/sample_files/implement_protocol.ex ================================================ defmodule Doctor.ImplementProtocol do @moduledoc """ An Example Implementation of a Protocol """ defstruct [:foo, :bar] @type t :: %__MODULE__{foo: String.t(), bar: non_neg_integer} defimpl Inspect do import Inspect.Algebra def inspect(struct, opts) do doc = struct |> Map.from_struct() |> Map.to_list() |> to_doc(opts) concat(["#ExampleDefImpl<", doc, ">"]) end end end ================================================ FILE: test/sample_files/nested_module.ex ================================================ defmodule Doctor.ParentModule do @moduledoc """ A module containing another module """ defmodule Nested do @moduledoc """ A nested module """ @doc """ A function in the nested module """ @spec inner :: :ok def inner, do: :ok end @doc """ A function in the outer module """ @spec outer :: :ok def outer, do: :ok end ================================================ FILE: test/sample_files/no_docs.ex ================================================ defmodule Doctor.NoDocs do def func_1(input) do input + 1 end def func_2(input), do: input + 2 def func_3(input) when is_integer(input) do input + 3 end def func_4(input) when is_integer(input), do: input + 4 def func_5(input_1, input_2) do func_5(input_1, input_2, 5) end def func_5(input_1, input_2, input_3) do input_1 + input_2 + input_3 end def func_6("match" = input), do: input def func_6("matches" = input), do: input def func_6("matcher" = input), do: input def func_6("matching" = input), do: input def func_6(_), do: "no match" end ================================================ FILE: test/sample_files/no_struct_spec_module.ex ================================================ defmodule Doctor.NoStructSpecModule do defstruct ~w(name arity)a end ================================================ FILE: test/sample_files/opaque_struct_spec_module.ex ================================================ defmodule Doctor.OpaqueStructSpecModule do defstruct ~w(name arity)a @opaque t :: %__MODULE__{} end ================================================ FILE: test/sample_files/partial_docs.ex ================================================ defmodule Doctor.PartialDocs do def func_1(input) do input + 1 end def func_2(input), do: input + 2 def func_3(input) when is_integer(input) do input + 3 end @spec func_4(integer()) :: integer() @doc "Function doc 4" def func_4(input) when is_integer(input), do: input + 4 @spec func_5(integer(), integer()) :: integer() @doc "Function doc 5 with 2 args" def func_5(input_1, input_2) do func_5(input_1, input_2, 5) end @spec func_5(integer(), integer(), integer()) :: integer() @doc "Function doc 5 with 3 args" def func_5(input_1, input_2, input_3) do input_1 + input_2 + input_3 end @spec func_6(String.t()) :: String.t() @doc "Function doc 6" def func_6("match" = input), do: input def func_6("matches" = input), do: input def func_6("matcher" = input), do: input def func_6("matching" = input), do: input def func_6(_), do: "no match" end ================================================ FILE: test/sample_files/struct_spec_module.ex ================================================ defmodule Doctor.StructSpecModule do @type t :: %__MODULE__{ name: atom(), arity: integer() } defstruct ~w(name arity)a end ================================================ FILE: test/sample_files/use_module.ex ================================================ defmodule Doctor.UseModule do @moduledoc """ A module with __using__ macro """ defmacro __using__(_opts) do quote do @doc """ Returns :ok """ @spec fun_with_doc_and_spec() :: :ok def fun_with_doc_and_spec, do: :ok @doc """ Sample function """ def fun_with_doc, do: :ok @spec fun_with_spec() :: :ok def fun_with_spec, do: :ok def fun_without_spec_and_doc, do: :ok end end end ================================================ FILE: test/test_helper.exs ================================================ ExUnit.start()