diff --git a/test/fixtures/tracker_id_baseline.txt b/test/fixtures/tracker_id_baseline.txt new file mode 100644 index 0000000..3bf4dd5 --- /dev/null +++ b/test/fixtures/tracker_id_baseline.txt @@ -0,0 +1,52 @@ +# Tracker ids already present in lib/. +# Read by the "no lib/ file gains a tracker id" test in +# test/statifier_examples/tracker_id_test.exs, which defines the id shape. +# One line per file and id: the file, the id, and how many times the file +# carries it, tab-separated. A new id is refused, not added here; a line is +# lowered or deleted when an id leaves its file. +lib/statifier_examples/application.ex se-1cl 1 +lib/statifier_examples/card_auth.ex se-0u1 1 +lib/statifier_examples/card_auth.ex se-2ox 1 +lib/statifier_examples/card_auth.ex se-bv9 1 +lib/statifier_examples/card_auth.ex se-ihm 1 +lib/statifier_examples/card_auth/legacy_check.ex se-bv9 1 +lib/statifier_examples/charts.ex se-06z 2 +lib/statifier_examples/charts.ex se-4dt 3 +lib/statifier_examples/charts.ex se-6ag 1 +lib/statifier_examples/charts/async_calls.ex se-d74 1 +lib/statifier_examples/charts/durable.ex se-29d 1 +lib/statifier_examples/charts/durable.ex se-4dt 3 +lib/statifier_examples/charts/durable.ex se-6ag 2 +lib/statifier_examples/charts/durable.ex se-cqr 1 +lib/statifier_examples/charts/durable.ex se-j87 1 +lib/statifier_examples/charts/durable.ex se-w4i 2 +lib/statifier_examples/charts/durable.ex sp-2yx 1 +lib/statifier_examples/charts/execution.ex se-0ay 1 +lib/statifier_examples/charts/execution.ex se-29d 1 +lib/statifier_examples/charts/execution.ex se-b2f 1 +lib/statifier_examples/charts/fan_out.ex sb-kha0 1 +lib/statifier_examples/charts/fan_out.ex se-j87 1 +lib/statifier_examples/charts/fixture.ex se-06z 1 +lib/statifier_examples/charts/step.ex se-06z 1 +lib/statifier_examples/charts/subchart.ex se-4dt 1 +lib/statifier_examples/charts/subchart.ex se-6ag 1 +lib/statifier_examples/charts/subchart.ex st-pvpz 1 +lib/statifier_examples/charts/sync_adapter.ex se-4dt 2 +lib/statifier_examples/documents.ex se-1cl 1 +lib/statifier_examples/persistence.ex se-1tro 1 +lib/statifier_examples/persistence.ex se-6ag 1 +lib/statifier_examples/persistence.ex se-j87 1 +lib/statifier_examples/persistence.ex sp-11w 1 +lib/statifier_examples/signup.ex st-pvpz 1 +lib/statifier_examples/signup/handlers.ex se-dyo 1 +lib/statifier_examples/signup/invites.ex se-j87 1 +lib/statifier_examples/signup/journey.ex se-19h 1 +lib/statifier_examples/signup/journey.ex se-e68 2 +lib/statifier_examples/signup/journey.ex se-w4i 1 +lib/statifier_examples/signup/promotion.ex se-j87 1 +lib/statifier_examples/signup/screens.ex se-19h 1 +lib/statifier_examples/signup/screens.ex se-e68 1 +lib/statifier_examples/signup/signup_step.ex se-1mc 1 +lib/statifier_examples_web/components/signup_elements.ex se-19h 1 +lib/statifier_examples_web/live/editor_live.ex se-1cl 1 +lib/statifier_examples_web/live/signup_screens_live.ex se-19h 2 diff --git a/test/statifier_examples/tracker_id_test.exs b/test/statifier_examples/tracker_id_test.exs new file mode 100644 index 0000000..c9d7182 --- /dev/null +++ b/test/statifier_examples/tracker_id_test.exs @@ -0,0 +1,150 @@ +defmodule StatifierExamples.TrackerIdTest do + @moduledoc """ + No file under `lib/` gains a tracker id. + + A tracker id names an issue in one of the family's trackers: a repository + prefix, a dash, and three or four base36 characters (`se-` then `06z`, + say). In a moduledoc or a comment it points a reader at an issue instead + of saying what the code does and why, and the issue text is not shipped + with the code. Write the substance instead. + + ## Mechanism: a committed baseline + + `lib/` already carries such ids. Each one is counted in the committed + baseline, `test/fixtures/tracker_id_baseline.txt`: per file, the id and + how many times the file carries it. A count above the baseline fails, + naming the file and the id; a count below it fails too, naming the + baseline line to lower or delete, so an id that leaves a file cannot come + back unseen. An existing occurrence that moves within its file changes no + count. Like `StatifierExamples.PrivateIdTest`, it needs no git history and + runs the same locally and in CI. + + ## What it does not catch + + * An id of a prefix missing from `@prefixes`: a tracker added to the + family later is enforced only once its prefix is listed there. + * An id that spells one of `@css_words`: those are the CSS class names + in `lib/` that share the shape (`sr-only`), excluded by name. + * An author who adds a baseline line beside a new id. The baseline's + own header says a new id is refused, not added there, and a reviewer + reads the diff. + """ + use ExUnit.Case, async: true + + # Every tracker prefix in the family, one per repository. + @prefixes ~w(ece enc eq ots pts px rd rs sb sd se sob sp sr st sui) + + # CSS class names in lib/ with a tracker id's shape. A class name is an + # English word where an id has minted characters, so it is excluded by + # name; everything else of the shape is an id. + @css_words ~w(only) + + @tracker_id Regex.compile!( + "\\b(?:" <> + Enum.join(@prefixes, "|") <> + ")-(?!(?:" <> Enum.join(@css_words, "|") <> ")\\b)[a-z0-9]{3,4}\\b" + ) + + @baseline "test/fixtures/tracker_id_baseline.txt" + + defp repo_path(relative), do: __DIR__ |> Path.join("../../#{relative}") |> Path.expand() + + # Every file under lib/, globbed at run time rather than listed, so a file + # added later is enforced the moment it exists. + defp lib_files do + root = repo_path("") + + root + |> Path.join("lib/**/*") + |> Path.wildcard() + |> Enum.filter(&File.regular?/1) + |> Enum.map(&Path.relative_to(&1, root)) + |> Enum.sort() + end + + defp tracker_ids(text), do: @tracker_id |> Regex.scan(text) |> Enum.map(&hd/1) + + defp tracker_id_counts(files) do + for file <- files, + {id, count} <- + file |> repo_path() |> File.read!() |> tracker_ids() |> Enum.frequencies(), + into: %{}, + do: {{file, id}, count} + end + + defp read_baseline do + @baseline + |> repo_path() + |> File.read!() + |> String.split("\n", trim: true) + |> Enum.reject(&String.starts_with?(&1, "#")) + |> Map.new(fn line -> + [file, id, count] = String.split(line, "\t") + {{file, id}, String.to_integer(count)} + end) + end + + # Every invented id below is assembled from two halves at run time, so + # this file's own source carries none of them. + defp invented(prefix, rest), do: prefix <> rest + + # sabotage: drop "only" from @css_words -> red on the `sr-only` negative; + # shorten the id shape to three characters -> red on a four-character + # positive (both run 2026-09-30). + test "the tracker-id pattern matches each prefix's id shape and no class name" do + positives = + for prefix <- @prefixes, rest <- ["zz9", "zz9q"] do + id = invented(prefix <> "-", rest) + {"filed as #{id} last week", id} + end ++ + [ + {"(#{invented("se-", "zz9")}.2)", invented("se-", "zz9")}, + {"Bead `#{invented("sb-", "zz9q")}` builds it", invented("sb-", "zz9q")}, + {"# is a second one (#{invented("sp-", "zz9")}).", invented("sp-", "zz9")} + ] + + for {text, id} <- positives, do: assert(tracker_ids(text) == [id], text) + + negatives = [ + ~s(Actions), + ~s(
), + "`--sb-block-accent` and `--sb-block-accent-tint`", + ~s(@accent_token "--sb-accent-myapp"), + "the first-run path", + "a post-1998 engine", + "released as v0.32.0", + "ADR-0005 part (iii)" + ] + + for text <- negatives, do: assert(tracker_ids(text) == [], text) + end + + # sabotage: plant an invented tracker id in charts/fixture.ex's moduledoc + # -> red, naming the file and the id; add a second copy of an id + # documents.ex already carries -> red ("2 now, 1 before"); delete the id + # from charts/fan_out.ex's moduledoc -> red on the second assertion + # (all three run 2026-09-30). + test "no lib/ file gains a tracker id" do + baseline = read_baseline() + current = tracker_id_counts(lib_files()) + + added = + for {{file, id} = key, count} <- current, + count > Map.get(baseline, key, 0), + do: "#{file}: #{id} (#{count} now, #{Map.get(baseline, key, 0)} before)" + + gone = + for {{file, id} = key, before} <- baseline, + Map.get(current, key, 0) < before, + do: "#{file}\t#{id}\t#{before}" + + assert added == [], + "new tracker ids in lib/ - write the substance instead:\n" <> + Enum.join(Enum.sort(added), "\n") + + assert gone == [], + "ids removed since #{@baseline} was written - lower or delete " <> + "these lines there so the id cannot come back unseen:\n" <> + Enum.join(Enum.sort(gone), "\n") + end +end