Skip to content

ci: enforce cyclomatic complexity ceiling with strict linting - #863

Closed
leandrocp wants to merge 1 commit into
mainfrom
complexity-ceiling
Closed

ci: enforce cyclomatic complexity ceiling with strict linting#863
leandrocp wants to merge 1 commit into
mainfrom
complexity-ceiling

Conversation

@leandrocp

Copy link
Copy Markdown
Contributor

Adds a cyclomatic complexity ceiling and turns the linter up to strict, so both
run on every push and pull request. Part of applying one complexity standard
across the projects in this family.

What this enforces

  • .credo.exs pins Credo.Check.Refactor.CyclomaticComplexity at max 9
    classic McCabe, the same metric and ceiling that oxlint's eslint/complexity
    enforces on the JavaScript in the sibling repositories.

  • strict: true lives in the config rather than only in the CI invocation, so a
    local mix credo reports exactly what CI reports.

  • Credo.Check.Readability.MaxLineLength is set to Credo's strict default of
    120, or to .formatter.exs's line_length where that is larger, so the
    formatter and the linter cannot disagree about a line the formatter produced.

  • Credo.Check.Design.TagTODO / TagFIXME are off. Those notes belong in the
    issue tracker; failing a build on one only encourages deleting the note.

  • assets/.oxlintrc.json turns on oxlint's correctness, pedantic, perf
    and suspicious categories and pins eslint/complexity at the same max 9,
    classic McCabe, over the JavaScript in assets/.

Two checks are waived, each with the reason and the count in the config:
Credo.Check.Design.AliasUsage (109 sites — a naming question, not a complexity
one, and a sweep would touch most of the tree at once) and
Credo.Check.Warning.StructFieldAmount (1 site, Beacon.Config at 42 fields,
where every field is one documented option).

assets/.eslintrc.js is replaced: nothing ran it, there was no lint script and
no workflow. oxlint goes into devDependencies with an npm run lint script
and a mix assets.lint alias.

CI

  • The quality job in quality.yml gains a mix credo --strict step.
  • A new javascript.yml runs oxlint over assets/, straight from the registry
    with npx oxlint@1.80.0.

Code changes

Nineteen functions were over the cyclomatic ceiling and forty-nine were nested
deeper than Credo allows. Twenty-six of the latter were in
runtime_renderer.ex, the runtime AST interpreter.

The largest ones:

  • Content.validate_if_value_matches_type/4 (28) was a 20-arm cond pairing a
    type name with its guard and its error message. The guards are now clauses of
    value_matches_type?/2 and the article is type_article/1; an unknown type
    still raises, as it did.
  • Layouts.do_build_seo_meta_tags/3 (24) built a list with eleven
    tags = if non_empty?(x), do: [tag | tags], else: tags lines. It is now a
    pipeline of put_tag/4 calls, in the same order, so adding a tag is one line.
  • SEO.Metrics.compute/1 (21) mixed four count queries, a per-page score and
    the result map. The queries are named (orphan_count/2 and friends) and the
    score is page_score/1 over score_checks/1.
  • RuntimeRenderer.eval_ast/2 for |> (17) chose the module for a bare piped
    call inside a six-arm cond; that is now pipe_module/2, one clause per
    known helper.
  • Css.ThemeParser.parse_value/2 (19) was a five-arm cond each containing its
    own nested case; each arm is now its own function.
  • Actions.Interpreter.evaluate_test/2 (16) dispatched ten comparison
    operators in a case; they are clauses of compare/3.
  • RuntimeRenderer.do_render_component/3 had the same twenty lines of "read the
    serialized component and render it" before and after the lazy load. That is
    render_serialized_component/3, called from both.
  • extract_component_assigns/1 had two verbatim copies of the same nested slot
    transform. Both call transform_component_pair/1 and transform_slot/1.
  • The slot builder inside eval_ir for component calls was a third copy of
    build_cms_inner_block/3; it calls it now.
  • Web.Live.PageLive.render/1 nested a case on the notification component
    inside a case on the site setting inside an if; the four outcomes are four
    named functions.

Alongside those: ten defp f do try do ... end end bodies became the implicit
function-level rescue, six unless ... else became if, four
length(list) > 0 became list patterns, two Enum.count(x) > 0 became
Enum.empty?/1, three with expressions with one clause and an else became
case, two Enum.map |> Enum.join became Enum.map_join, alias groups were
sorted in four files, and eight modules gained @moduledoc false.

Client.Filters defines apply/3 as its public entry point, and Credo reads
the heads of the clauses whose third argument is a list as calls to
Kernel.apply/3. That file carries a credo:disable-for-this-file with the
reason.

In JavaScript, assets/js/beacon.js moved off getElementById,
getElementsByTagName(...)[0].appendChild and bare hasOwnProperty.

Verification

  • mix credo --strict: clean, 2861 modules and functions.
  • mix test: 535/580 passed, 45 failed — a subset of the 46 that fail on
    main
    before this branch. The one that no longer appears is flaky across
    runs on main too. This checkout cannot reproduce a green suite; the failures
    are unrelated to these changes.
  • mix compile --warnings-as-errors: the warning set is byte-identical to
    main's. It is non-empty on both, on the Elixir this was written against.
  • npx oxlint@1.80.0 in assets/: clean.

Files whose only difference would have been mix format output are left alone,
so the diff is the change and not a reformat.

@leandrocp leandrocp closed this Aug 30, 2026
@leandrocp
leandrocp deleted the complexity-ceiling branch August 30, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant