lib/mix/tasks/foundry.compliance.check.ex

defmodule Mix.Tasks.Foundry.Compliance.Check do
  @shortdoc "Check RG-* requirement implementation coverage and test linkage (INV-007)"

  @moduledoc """
  Reads all regulation files in `docs/regulations/`, extracts RG-* requirements,
  then verifies each one has at least one `implementation:` pointer to a module
  or test, and that a corresponding ExUnit test exists tagged `:compliance`.

  Emits a `Foundry.Compliance.CheckResult` as JSON.

  ## Usage

      mix foundry.compliance.check
      mix foundry.compliance.check --json
      mix foundry.compliance.check --filter=RG-UK   # filter by prefix

  ## Requirement syntax in regulation files

  Requirements are parsed from Markdown files in `docs/regulations/`. Each
  requirement must follow this structure:

      ### RG-UK-014
      **Summary:** Withdrawals must be processed to original payment method
      **Implementation:** `IgamingRef.Finance.WithdrawalTransfer`
      **Test tag:** `:rg_uk_014`

  The parser is lenient — it looks for the `RG-` prefix and reads the surrounding
  lines for implementation and test tag declarations.

  ## Status determination

  - `:implemented` — has implementation pointer AND a passing tagged test
  - `:partial` — has implementation pointer but no tagged test (or test failing)
  - `:unimplemented` — has no implementation pointer
  - `:planned` — explicitly declared as planned in the regulation file

  ## CI gate

  Exits non-zero only if any requirement is `:unimplemented` on a project that
  has `coverage_gate: true` in its manifest. Planned and partial requirements
  do not fail CI — they are surfaced as warnings in the compliance dashboard.
  """

  use Mix.Task

  alias Foundry.Compliance.CheckResult
  alias Foundry.Compliance.CheckResult.{Requirement, Summary}

  @impl Mix.Task
  def run(args) do
    app = Mix.Project.config()[:app]
    Application.put_env(app, :foundry_tasks_only, true)

    Mix.Task.run("app.start")

    project_root = File.cwd!()
    filter = parse_filter(args)
    manifest = load_manifest(project_root)

    requirements =
      project_root
      |> find_regulation_files()
      |> Enum.flat_map(&parse_requirements(&1, project_root))
      |> filter_requirements(filter)
      |> Enum.map(&enrich_with_test_status(&1, project_root))
      |> Enum.sort_by(& &1.id)

    summary = build_summary(requirements)

    result = %CheckResult{
      requirements: requirements,
      summary: summary,
      generated_at: DateTime.utc_now() |> DateTime.to_iso8601()
    }

    IO.puts(Jason.encode!(result, pretty: true))

    # Exit non-zero if coverage_gate: true and any unimplemented requirements
    if manifest[:coverage_gate] do
      unimplemented = Enum.count(requirements, &(&1.status == :unimplemented))

      if unimplemented > 0 do
        Mix.shell().error("#{unimplemented} unimplemented requirement(s). Coverage gate failed.")
        exit({:shutdown, 1})
      end
    end
  end

  # ---------------------------------------------------------------------------
  # Private — file discovery
  # ---------------------------------------------------------------------------

  defp find_regulation_files(project_root) do
    regs_dir = Path.join(project_root, "docs/regulations")

    case File.ls(regs_dir) do
      {:ok, files} ->
        files
        |> Enum.filter(&String.ends_with?(&1, ".md"))
        |> Enum.map(&Path.join(regs_dir, &1))

      _ ->
        []
    end
  end

  # ---------------------------------------------------------------------------
  # Private — parsing
  # ---------------------------------------------------------------------------

  defp parse_requirements(file_path, _project_root) do
    case File.read(file_path) do
      {:error, _} ->
        []

      {:ok, content} ->
        content
        |> String.split("\n")
        |> parse_lines(file_path)
    end
  end

  # State machine parser — walks lines looking for RG-* headings then reads
  # Summary, Implementation, Test tag, and Status from the following lines.
  defp parse_lines(lines, _file_path) do
    lines
    |> Enum.with_index()
    |> Enum.chunk_while(
      nil,
      fn {line, _idx}, acc ->
        cond do
          # New requirement heading
          Regex.match?(~r/^#+\s+RG-[A-Z]+-\d+/, line) ->
            id = Regex.run(~r/RG-[A-Z]+-\d+/, line) |> hd()
            {:cont, %Requirement{id: id, summary: "", status: :unimplemented}}

          # Summary line
          Regex.match?(~r/\*\*Summary:\*\*/, line) and is_struct(acc, Requirement) ->
            summary = Regex.replace(~r/\*\*Summary:\*\*\s*/, line, "") |> String.trim()
            {:cont, %{acc | summary: summary}}

          # Implementation pointer
          Regex.match?(~r/\*\*Implementation:\*\*/, line) and is_struct(acc, Requirement) ->
            mods =
              Regex.scan(~r/`([A-Z][A-Za-z0-9.]+)`/, line)
              |> Enum.map(fn [_, m] -> m end)

            status = if mods != [], do: :partial, else: :unimplemented
            {:cont, %{acc | implementing_modules: mods, status: status}}

          # Test tag
          Regex.match?(~r/\*\*Test tag:\*\*/, line) and is_struct(acc, Requirement) ->
            tags =
              Regex.scan(~r/`:([a-z_\d]+)`/, line)
              |> Enum.map(fn [_, t] -> t end)

            {:cont, %{acc | test_tags: tags}}

          # Explicit planned status
          Regex.match?(~r/\*\*Status:\*\*\s*planned/, line) and is_struct(acc, Requirement) ->
            {:cont, %{acc | status: :planned}}

          # Blank line after content — flush current requirement
          line == "" and is_struct(acc, Requirement) ->
            {:cont, acc, nil}

          true ->
            {:cont, acc}
        end
      end,
      fn
        nil -> {:cont, []}
        req -> {:cont, req, nil}
      end
    )
    |> Enum.reject(&is_nil/1)
    |> Enum.filter(&match?(%Requirement{}, &1))
  end

  # ---------------------------------------------------------------------------
  # Private — test status enrichment
  # ---------------------------------------------------------------------------

  defp enrich_with_test_status(%Requirement{test_tags: []} = req, _), do: req

  defp enrich_with_test_status(%Requirement{} = req, project_root) do
    # Look for the last CI run result by checking test result files.
    # In absence of a CI result file, we check if the test file exists.
    test_dir = Path.join(project_root, "test")

    tag_pattern = req.test_tags |> Enum.map(&":#{&1}") |> Enum.join("|")

    test_exists =
      case File.ls(test_dir) do
        {:ok, _} ->
          # Recursively search for tagged test files
          find_tagged_tests(test_dir, tag_pattern)

        _ ->
          false
      end

    # Check for a CI result file at .foundry/ci_results/<tag>.json
    ci_result = read_ci_result(project_root, req.test_tags)

    req
    |> Map.put(:last_test_passed, ci_result[:passed])
    |> Map.put(:last_test_run, ci_result[:run_at])
    |> then(fn r ->
      if test_exists and r.status == :partial do
        %{r | status: :implemented}
      else
        r
      end
    end)
  end

  defp find_tagged_tests(test_dir, tag_pattern) do
    Path.wildcard(Path.join([test_dir, "**", "*_test.exs"]))
    |> Enum.any?(fn path ->
      case File.read(path) do
        {:ok, content} -> content =~ ~r/#{tag_pattern}/
        _ -> false
      end
    end)
  end

  defp read_ci_result(project_root, tags) do
    tags
    |> Enum.find_value(fn tag ->
      path = Path.join([project_root, ".foundry", "ci_results", "#{tag}.json"])

      case File.read(path) do
        {:ok, content} ->
          case Jason.decode(content) do
            {:ok, data} -> %{passed: data["passed"], run_at: data["run_at"]}
            _ -> nil
          end

        _ ->
          nil
      end
    end)
    |> Kernel.||(%{passed: nil, run_at: nil})
  end

  # ---------------------------------------------------------------------------
  # Private — helpers
  # ---------------------------------------------------------------------------

  defp filter_requirements(reqs, nil), do: reqs

  defp filter_requirements(reqs, prefix) do
    Enum.filter(reqs, &String.starts_with?(&1.id, prefix))
  end

  defp build_summary(requirements) do
    %Summary{
      total: length(requirements),
      implemented: Enum.count(requirements, &(&1.status == :implemented)),
      partial: Enum.count(requirements, &(&1.status == :partial)),
      unimplemented: Enum.count(requirements, &(&1.status == :unimplemented)),
      planned: Enum.count(requirements, &(&1.status == :planned)),
      passing_tests: Enum.count(requirements, &(&1.last_test_passed == true)),
      failing_tests: Enum.count(requirements, &(&1.last_test_passed == false))
    }
  end

  defp parse_filter(args) do
    args
    |> Enum.find_value(fn arg ->
      case Regex.run(~r/^--filter=(.+)$/, arg) do
        [_, prefix] -> prefix
        _ -> nil
      end
    end)
  end

  defp load_manifest(project_root) do
    path = Path.join([project_root, ".foundry", "manifest.exs"])

    case File.read(path) do
      {:ok, content} ->
        {kw, _} = Code.eval_string(content)
        kw

      _ ->
        []
    end
  end
end