Compare commits

...
78 Commits
Author SHA1 Message Date
Firehose Bot 28f9162872 Intention location details, small fixes 2026-10-30 09:37:01 +00:00
Firehose Bot 611f0887c4 blog: tag Intention, Location, Details post 2026-10-30 09:23:26 +00:00
Firehose Bot e38afb523c move roam-export-blog exporter into the doom config
The org-roam note exporter and its tests/fixtures now live in the doom
configuration (~/.doom.d/qwan) as org-roam-export-blog.el: renamed to
the org-roam- prefix so M-x org-roam-export-blog is discoverable, with
a configurable repo root and a new jekyll draft target.

Drop the test-elisp make target: it pointed at the moved files.
2026-10-29 22:49:21 +00:00
Firehose Bot 7fda0455c1 compile deps in make setup so cold clones stay offline
Some deps download artifacts at compile time: rustler_precompiled
fetches the mdex_native NIF from GitHub during its first compile, and
NimblePublisher pulls mdex_native into both apps. Compiling inside the
deps target keeps that traffic inside setup - the explicit user-run
network step - instead of the first make check/test on a fresh clone.
2026-10-29 18:09:42 +00:00
Firehose Bot 0d344a4f57 make blog slug tests content-independent
Commit 1709cb7 deleted the pinned hello-world post and ten tests
silently rotted with it - invisible until now because the suite could
not run in this clone (incomplete deps, then the broken frontmatter).
The slug, banner, og-meta and tag tests now derive slug and title from
the compiled posts at runtime (newest live post, newest draft), matching
the existing latest_post_with_links pattern, so content churn no longer
breaks them.
2026-10-29 18:05:29 +00:00
Firehose Bot 84539ec40f fix GNU-only \+ in check-pins sed patterns
\+ is a GNU BRE extension; /usr/bin/sed on macOS treats it as a literal
plus, so every Dockerfile ARG pin was reported "not found" and make
check false-failed even though mise.toml and the Dockerfile agree. Use
the POSIX form \{1,\} and note the constraint in the header.
2026-10-29 17:56:41 +00:00
Firehose Bot 97fc024a38 add make setup and network-free deps.check guard
mix deps.get contacts hex.pm even when everything is already installed
(the advisory check), so the everyday loops failed in offline/sandboxed
environments; and the root deps target only installed app's deps, never
blogex's.

make setup (root) installs Hex deps for both apps plus the app's JS
packages, once per clone, explicitly. check, test and test.coverage in
both apps now depend on a local deps.check guard instead: every package
pinned in mix.lock must exist in deps/, else fail loudly pointing at
make setup. AGENTS.md documents the new target.
2026-10-29 17:44:16 +00:00
Firehose Bot 7215cd6db0 fix split closing quote in intention-location-details frontmatter
8fa342e split the description's trailing ", onto its own line, leaving
the frontmatter invalid Elixir; NimblePublisher evals frontmatter at
compile time, so the app could not compile. Also drops the
“/” entities from the description (uncommitted WIP, kept
per plan).
2026-10-29 17:44:16 +00:00
Firehose Bot 8fa342e80d publish, and fix description 2026-10-29 17:00:29 +00:00
Firehose Bot 9029ead53a Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/mostalive/firehose 2026-10-29 16:52:06 +00:00
Firehose Bot 1709cb78a1 intention-location details pattern 2026-10-29 16:51:38 +00:00
Willem van den Ende e257d58541 yak(deps-upgrade-phoenixecto-and-postgrex-once-the-mix-120-xref-fix-ships-z3qh): install ecto_psql_extras for LiveDashboard Ecto Stats 2026-10-29 13:43:58 +00:00
Willem van den Ende df9597682c yak(deps-upgrade-phoenixecto-and-postgrex-once-the-mix-120-xref-fix-ships-z3qh): bump phoenix_live_dashboard to 0.9.1 2026-10-29 13:41:12 +00:00
Willem van den Ende f5faa8a3af yak(theme-explicit-light-is-overridden-on-dark-os-browsers-chip-backgrounds-are-dropped-pku7): theme: explicit light is overridden on dark-OS browsers; chip backgrounds are dropped 2026-10-29 13:07:08 +00:00
Willem van den Ende edba66239e yak(coverage-floors-re-measure-and-re-record-after-the-behavior-test-yaks-mgr8): coverage floors: re-measure and re-record after the behavior-test yaks 2026-10-29 10:38:36 +00:00
Willem van den Ende 9c0f33e6ca yak(behavior-tests-web-controllers-and-auth-edge-cases-o04i): behavior tests: web controllers and auth edge cases 2026-10-29 10:15:21 +00:00
Willem van den Ende b2bd6bc3aa yak(behavior-tests-web-core-components-uyju): behavior tests: web core components 2026-10-29 10:14:27 +00:00
Willem van den Ende ef8407b272 yak(credo-strict-tuned-config-both-apps-x02d): credo strict, tuned config (both apps) 2026-10-29 10:13:02 +00:00
Willem van den Ende 13e3186f58 yak(boundary-repo-as-sibling-boundary-web-repo-forbidden-tgrc): boundary: Repo as sibling boundary, web->Repo forbidden 2026-10-29 10:08:56 +00:00
Willem van den Ende 3ef38bc8fc yak(mutation-testing-with-muex-blogex-pure-app-code-1341): mutation testing with muex (blogex + pure app code) 2026-10-29 10:07:55 +00:00
Willem van den Ende 09da15c1df yak(sobelow-in-the-lens-pass-vr40): sobelow in the Lens pass 2026-10-28 23:22:07 +00:00
Willem van den Ende e292b4ebfb yak(ratchets-deferred-decisions-handoff-practice-ccjp): ratchets, deferred decisions, handoff practice 2026-10-28 23:14:18 +00:00
Willem van den Ende b72446b80e yak(behavior-tests-editorial-liveviews-microprints-editor-dashboard-llh3): behavior tests: editorial LiveViews (microprints, editor dashboard) 2026-10-28 22:27:59 +00:00
Willem van den Ende 44a343ae72 yak(behavior-tests-blogex-rendered-html-components-layout-router-html-0dev): behavior tests: blogex rendered HTML (components, layout, router HTML) 2026-10-28 22:21:23 +00:00
Willem van den Ende cc03cefe8d yak(behavior-tests-blogex-public-api-and-router-plumbing-q1fb): behavior tests: blogex public API and router plumbing 2026-10-28 22:15:02 +00:00
Willem van den Ende c04d8c856c yak(link-validator-never-sees-compiled-html-internal-link-validation-is-vacuous-ddd1): Link validator never sees compiled HTML — internal-link validation is vacuous 2026-10-28 22:08:45 +00:00
Willem van den Ende 4e7798c906 yak(make-checkverify-format-gate-rewrites-files-instead-of-checking-lnje): make check/verify format gate rewrites files instead of checking 2026-10-28 21:50:01 +00:00
Willem van den Ende a6a7b3a8c7 yak(pin-user-inspect-output-exactly-both-redacted-fields-shape-l0mg): pin User inspect output exactly (both redacted fields + shape) 2026-10-27 21:28:37 +00:00
Willem van den Ende a72d73f21c app: logout disconnects open LiveView sockets
`put_token_in_session/2` wrote only `:user_token`, so `log_out_user/1`'s
`if live_socket_id = get_session(conn, :live_socket_id)` was always false and
logging out left open LiveView sockets running. Write the socket topic
alongside the token, copying phx.gen.auth's format verbatim
("users_sessions:" <> Base.url_encode64(token)) so it matches both what
Phoenix.LiveView.Socket.id/1 reads and what log_out_user/1 reads. The reissue
path goes through the same function, so it follows the new token.

Tests (provider/reader agreement, both previously absent):
- log_in_user/3 stores the id derived from the session token
- fetch_current_scope_for_user/2 reissues the id with the new token
- log_out_user/1 broadcasts exactly one "disconnect" on that id
- the value written is the topic Socket.id/1 returns for the session the
  websocket receives (string keys)

Not tested end to end: a real disconnect needs the transport process, which
Phoenix.LiveViewTest does not start — Phoenix.Socket.__info__/2 handles the
broadcast (deps/phoenix/lib/phoenix/socket.ex:571) and
phoenix_live_view/test/client_proxy.ex never subscribes to the socket id topic.

Gate: make check + make verify green (app coverage 86.97%, blogex 75.16%,
crap sensors green).

Yak: logout-must-disconnect-existing-liveview-sockets-livesocketid-never-set-6fme
2026-10-27 09:56:25 +00:00
Willem van den Ende 2ccbe28774 blogex: CRAP scores as a Lens sensor (ex_crap)
Same wiring as app: `crap` reads the export from `test.coverage`, runs in
MIX_ENV=test, and refuses to score when the export is absent.

Baseline at the default threshold (30): 12 files, 59 functions, worst score
6.00. Nothing to fix; blogex's CRAP ceiling is its coverage ceiling, which the
behavior-test yaks already own.
2026-10-26 21:32:39 +00:00
Willem van den Ende 2ba0b7bc48 app: CRAP scores as a Lens sensor (ex_crap)
The coverage export from `test.coverage` now also feeds a `crap` sensor,
second in app's VERIFY_SENSORS. It runs with MIX_ENV=test so it reads the
export's own environment instead of compiling a dev build and scoring a
different module set, and it guards on the export's existence because
ex_crap silently falls back to 0% coverage when the file is missing.

Baseline at ex_crap's default threshold (30): 23 files, 139 functions,
worst score 20.00 (CoreComponents.table/1, 0% - unused upstream scaffold).
No offenders, so nothing to refactor or cover yet.
2026-10-26 21:32:37 +00:00
Willem van den Ende 3eae3afcbb demo skill: look up routing and demo users in the project
Steps 2 and 4 carried copy-paste from another project (lib/hub_web/router.ex,
alias Hub.Accounts, /users/log-in with user[email]). A stale path there sends
the agent to a router that does not exist, and a guessed module alias fails at
run time.

Both are now generic instructions to read the project:

- route map: locate the router by searching for `use Phoenix.Router`, then read
  its scopes, pipelines and live_sessions to see what is public and what needs
  a session
- authentication: only when the route actually needs it; take credentials from
  documented demo accounts or the project's seeds, create a demo user through
  the project's own path (seeds, registration route, or the accounts function
  found by grep - never a guessed alias), and read the login route and form
  selectors off the router and rendered form

The port example keeps its concrete app/endpoint pair, now labelled as an
example with the grep that finds the right names, plus a note to use the
project's toolchain/build wrapper instead of bare `mix`.

make check green.
2026-10-26 21:26:19 +00:00
Willem van den Ende a0692056bc demo skill: read the dev port from config; make deps installs JS packages
The skill assumed port 4000, but config/dev.exs serves on 8056 (and honours
$PORT), so a fresh demo run failed its liveness check and every rodney URL
pointed at the wrong port.

- demo skill: resolve DEMO_PORT from the app config with
  `mix run --no-start -e 'Application.get_env(:firehose,
  FirehoseWeb.Endpoint)[:http][:port]'` (Endpoint.config/1 raises without a
  started endpoint), derive $DEMO_BASE_URL from it, start the server on that
  port from app/, and use $DEMO_BASE_URL in every rodney example. The skill
  also points at .tmp/phx-demo.log and names the assets/node_modules
  highlight.js failure as a diagnosable cause.
- app/Makefile: new `assets` target that runs `npm install` only when
  node_modules/.package-lock.json is missing, and `deps` now depends on it.
  A fresh clone gets a working JS bundle from `make deps`/`make test`; later
  runs never touch the network, so offline runs keep working.

Verified: guard skips with node_modules present and installs after moving it
aside; the skill's DEMO_PORT command prints 8056 and follows PORT=9999;
make check and make test (179 + 162) green.
2026-10-26 21:08:00 +00:00
Willem van den Ende be80c89548 demo: screenshots for the external link marker and its SR hint
Three captures of /blog/engineering/delayed-gratification-in-ai-summer
taken with rodney (headless chromium):

- external-link-hint-icon.png: a paragraph as rendered - the marker icon
  follows the external link, the hint text is invisible
- external-link-hint-unhidden.png: same paragraph with the sr-only rule
  cancelled, showing " (opens in a new tab)" inside the link, which is
  what screen readers get
- external-link-hint-page.png: full page for context (internal links such
  as "Swim the Test" carry no icon)

Backing evidence, from the same load: the accessibility tree names exactly
the 8 external links with "opens in a new tab"; the hint box measures
1x1 with clip-path inset(50%).
2026-10-26 20:54:45 +00:00
Willem van den Ende e3fc734862 External links: screen-reader hint on links that leave the blog
External links in post bodies already opened in a new tab and showed an
icon, but the icon is invisible to screen readers, so nothing announced
that the link leaves the site.

- blogex: ExternalLinks.rewrite/1 appends
  <span class="blogex-external-hint"> (opens in a new tab)</span> as the
  last child of every external anchor. No aria-label, so the accessible
  name still contains the visible link text (WCAG 2.5.3); the icon colour
  and 2.5.3 both hold. Idempotent: a second pass adds no second hint.
- blogex: anchors are now matched together with their closing tag
  (the hint goes inside), so a stray unclosed <a> is left alone.
- app: sr-only rule for .blogex-external-hint in .blogex-post-body. The
  split stays as before: blogex marks up, the host app styles.
- tests: hint cases in blogex (appended, per link, after inner markup,
  idempotent, not on internal/mailto/anchor links) + anchors without href
  (Blogex.ExternalLinks now at 100% line coverage), and an app test that
  the hint reaches the rendered post page. Two exact-string expectations
  updated for the intended markup change.

make test: app 179, blogex 162. make verify: green.
2026-10-26 20:28:16 +00:00
Willem van den Ende 4b4d0c491c app: exclude embed_templates wrappers and the release-only Application
Raises the measured total 86.17% -> 86.79% without touching a test, because the
excluded modules hold no behavior a test can observe:

- FirehoseWeb.{Blog,UserRegistration,UserSettings}HTML are `use` +
  `embed_templates` only; every function in them is macro-generated. Their
  TEMPLATES are already exercised through the controller tests.
- Firehose.Application: start/2 is proven by the suite booting at all, and
  config_change/3 runs only on a release hot upgrade.

Deliberately NOT excluded, despite being embed_templates wrappers:
FirehoseWeb.PageHTML (blog_label/1, post_base_path/1) and
FirehoseWeb.UserSessionHTML (local_mail_adapter?/0) carry real private logic, so
their sub-100% coverage is a behavior gap for the controller/auth yak, not macro
noise.

Also drops the now-dead Firehose.Checks.NoConnShadowing entry: lib_dev/ is no
longer compiled, so it is not measurable. Floor stays 86 (86.79 rounds down).
2026-10-26 20:02:45 +00:00
Willem van den Ende 9730987846 app: give credo checks one load path (fixes redefining-module warning)
lib_dev/ was both compiled (elixirc_paths for :dev and :test) and source-loaded
by credo through `requires:` in .credo.exs. Two load paths for one module means
that whenever _build/dev already holds the beam, `mix credo` warned:

    warning: redefining module Firehose.Checks.NoConnShadowing
    (current version loaded from _build/dev/.../Elixir.Firehose.Checks.NoConnShadowing.beam)

Keep the explicit contract and drop the accidental one: `requires:` is the
documented way for credo to load project checks, so lib_dev/ now leaves
elixirc_paths in every environment. Nothing under test/ referenced the check, so
the :test entry was dead as well, and credo compiles the file on every run
anyway, so the check still gets compiled — just once.

One-time migration for checkouts that already built it:
`mix clean` (or delete _build/dev) to drop the stale beam; until then the old
warning persists. Fresh clones are unaffected.
2026-10-26 20:01:33 +00:00
Willem van den Ende f54ce2fab7 app: note that CoreComponents.table/1 and list/1 are unused upstream scaffold
These two come from the upstream Phoenix generator and are worth keeping for
future data/admin screens, so they stay rather than being deleted as dead code.

The note also records why they look unused but aren't pointless: the collections
already in the UI are hand-rolled (editor_dashboard_live's drafts/scheduled rows
are bordered divs; Blogex.Components.post_index renders post previews and tag
lists itself). That is the fact a future reader needs before either deleting
these or writing yet another bespoke list.
2026-10-26 19:50:47 +00:00
Willem van den Ende cb13ccecab docs: the Lens pass runs each suite before its sensors
Same shape in both apps now that coverage is wired: one suite run exports the
coverage, then the sensors read it. AGENTS.md said "sensors ... and finally both
test suites" and told sensor authors only to list targets in VERIFY_SENSORS; add
the export dependency and the "a check must fail when its input is missing" rule,
since `mix test.coverage` exits 0 when it finds no export.
2026-10-26 18:41:02 +00:00
Willem van den Ende abb712f8a2 app: coverage floor (86) + framework-glue exclusions
Measured before choosing: 86.17%, identical via `mix test --cover` and via the
exported sample + `mix test.coverage`, stable across seeds. Floor is the integer
below that, with the rule written next to it: a FLOOR, ratcheted up as
behavior-test yaks land, never lowered (a drop means the change or an exclusion
is wrong).

Exclusions are the modules the suite cannot or should not exercise, each
justified in the config comment: `use`-generated glue (Repo, Mailer, Telemetry,
ErrorHTML/ErrorJSON), the `use FirehoseWeb` macro, production release tasks
(Release), the lib_dev credo check (NoConnShadowing), and test scaffolding
(DataCase, ConnCase, AccountsFixtures, Test.FakeBlog). Everything else counts,
CoreComponents/controllers/LiveViews included.

Wiring matches blogex and the fast/push split: `test.coverage` runs the suite
once with `--cover --export-coverage default` (deleting stale exports first,
since `mix test.coverage` imports every .coverdata it finds and would pool
them), then the `coverage` sensor enforces the floor from that export. The
sensor guards on the export existing because `mix test.coverage` exits 0 when it
finds nothing — a floor check that did not run must not pass.
2026-10-26 18:40:09 +00:00
Willem van den Ende 754c8fa1b9 blogex: coverage floor (74) + test-scaffolding exclusions
`test_coverage` sets the floor the Lens pass enforces, measured first: 74.53%
with test/support excluded (`mix test --cover` and the exported sample give the
same number). The rule — a FLOOR, ratcheted up as behavior yaks land, never
lowered — is written next to the config, along with why each module is excluded
(test scaffolding only: fixtures measure the tests, not the library).

Wiring, per the fast/push split: `test.coverage` runs the suite once with
`--cover --export-coverage default`; the `coverage` sensor then enforces the
floor from that export via `mix test.coverage` (no second suite run). So
`verify` runs the suite first and the sensors after it. The sensor guards on the
export existing: `mix test.coverage` exits 0 when it finds no .coverdata, and a
floor check that did not run must not report success.

blogex/.gitignore: cover/ (generated).
2026-10-26 18:38:35 +00:00
Willem van den Ende a9a02aa9ba app: hand-write the User Inspect impl so coverage can instrument it
`redact: true` makes Ecto derive an Inspect impl at the `schema` line.
Elixir 1.20.4 emits that whole derived impl on that one line, and OTP 27's
coverage instrumentation (sys_coverage) turns it into invalid Erlang
("variable '_2' unsafe in 'case'"), so `mix test --cover` aborted for the whole
app before reporting anything. Minimal repro, no Ecto needed:
`@derive Inspect` on a plain struct fails on Elixir 1.20.4 + OTP 27.3.4.19 and
works on Elixir 1.18.3 + the same OTP.

Writing the impl out by hand, one expression per line, keeps `inspect/1`
output byte-identical (Ecto/Elixir render redacted fields as
`#Mod<..., ...>`) and keeps redaction pinned by the existing test, and lets the
coverage sensor land. Revisit when the Elixir/OTP pins change.
2026-10-26 18:38:13 +00:00
Willem van den Ende 96278e0d02 AGENTS.md: document the fast loop, the Lens pass and its triage classes
Records the two loops (make check per commit, make verify per push), how to
install the hook (make hooks), the deliberate --no-verify bypass, the Lens
practice (separate pass, don't chase nits inline, file a yak), the triage
classes (real bug / environment-dependent test / design nit), and how that
reconciles with the zero-defects policy. Playwright stays outside the Lens
pass pending an explicit decision.
2026-10-26 17:59:44 +00:00
Willem van den Ende 5db2814c2e pre-push Lens pass + make hooks installer
.githooks/pre-push runs 'make verify', tees the log to .git/lens-pass.log and,
on failure, prints one summary (last step started, log path, bypass) before
exiting 1. 'make hooks' symlinks .githooks/* into the clone's hooks dir.

core.hooksPath is deliberately left alone: bd keeps its own pre-commit
(post-merge) hooks in .git/hooks and pointing hooksPath at .githooks would
silently retire them while bd still reports them installed.
2026-10-26 17:58:58 +00:00
Willem van den Ende 068911b4e8 make verify: add the Lens pass container (both apps, cheapest first)
Root verify runs check first, then a per-app verify in each Makefile: sensor
slots hooked to VERIFY_SENSORS (empty today, so nothing is claimed that is not
run) and then that app's suite. Measured: 9.9s wall, app 178 + blogex 156 tests.
2026-10-26 17:58:42 +00:00
Willem van den Ende 0b815dc66a make check: include blogex in the cheap static gate
Root check now loops app then blogex then the pin check, mirroring the shape
the Lens pass will use. blogex gains a check target (compile with warnings
fatal + format); credo is a marked slot until its dep exists, and the gate
never claims a sensor it did not run.
2026-10-26 17:58:14 +00:00
Willem van den Ende ea588bd670 blogex: add precommit alias (compile --warning-as-errors, format, test) 2026-10-26 17:57:56 +00:00
Willem van den Ende 888aa8b00b blogex: format drift in link_validator (found before wiring blogex into make check) 2026-10-26 17:57:50 +00:00
Willem van den Ende cbbdaae53d doc: plan for internal code health sensors
Records the plan for bringing coverage floors, CRAP scores, credo strict,
sobelow, boundary and mutation testing to firehose, framed as the
"internal verification" counterpart to the existing Playwright / conn /
LiveView tests.

Ordered in phases because the fast/push split (make verify + pre-push
hook, cheap precommit) is the container the other sensors wire into.
Includes the steering principles that made the stickies run work
(behavior not state, functional core/imperative shell, delete dead code,
leave defensive branches uncovered deliberately) and the deferred
decisions (CRAP threshold below 30, boundary.spec.check, Playwright in
the Lens pass).
2026-10-26 17:47:04 +00:00
mostalive cee64b9f74 Script to sync Dockerfile and mise.toml 2026-10-26 16:48:42 +00:00
mostalive e16513f195 more precise pinning in docker and mise 2026-10-26 14:13:48 +00:00
mostalive 792516919a bump base image: elixir 1.20.4, debian 20 oct this month 2026-10-26 13:36:48 +00:00
Willem van den Ende a1ff6dbbac mix.lock: patch/minor dependency bumps (18 packages)
All within existing mix.exs constraints. Notables: bandit 1.12.5->1.13.1,
phoenix 1.8.15->16, phoenix_live_view 1.2.12->13, swoosh 1.28.1->1.29.0,
tailwind 0.4.1->0.5.1 (new bundled CLI - check CSS output after deploy).
Tests: 156 passing (app + blogex).
2026-10-26 12:53:33 +00:00
Firehose Bot 9fdb003521 converting to static site with one prompt 2026-10-23 09:32:40 +01:00
Firehose Bot d8cf81aeec mention I haven't tried Deepseek yet 2026-10-21 16:58:58 +01:00
Firehose Bot 5a9106b61e Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/mostalive/firehose 2026-10-21 14:53:50 +01:00
Firehose Bot aef57c54f9 my open weight models this month 2026-10-21 14:53:35 +01:00
mostalive 9050329685 Add link to fork, fix tags in other posts 2026-10-21 11:50:35 +01:00
mostalive 86d52aa420 add image to blog, fixed emacs drag and drop on linux 2026-10-21 11:19:31 +01:00
Firehose Bot ebf076c450 remove commas, fix description 2026-10-20 13:23:16 +01:00
Firehose Bot 628c032f0b Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/mostalive/firehose 2026-10-20 13:20:35 +01:00
Firehose Bot 8e1f5806cf video transcriber post 2026-10-20 13:17:50 +01:00
mostalive 8a8992603b Fix catherine breslin link 2026-10-15 14:57:31 +01:00
Firehose Bot 0255549e42 chore: ignore local macOS screenshots
Screenshot evidence stays in the session transcript; no need to track
the image files.
2026-10-15 14:04:25 +01:00
Firehose Bot fc2bafdfa1 fix(pi): abort session-start make test on session replacement to avoid stale ctx
The fire-and-forget run captured its session's pi/ctx; after /new, fork,
switch, or reload pi invalidates those, so the orphaned run's notify/
sendMessage threw 'This extension ctx is stale after session replacement
or reload'. Now session_shutdown aborts the in-flight run before
invalidation (pi.exec supports AbortSignal), and stale errors are
swallowed as a race guard.

Also show a 'running tests...' footer status widget while the run is
in flight, cleared on completion.
2026-10-15 14:00:38 +01:00
Firehose Bot a1fc8486e9 feat(pi): record session-start make test in test-balls via event bus
Emit test-balls:record on the shared pi.events bus after the session-start
make test run completes, so test-balls shows a ball for it like any other run.

The run is now fire-and-forget: session start no longer blocks on make test,
and the emit always lands after every extension's session_start handler has
run (project extensions load before global ones, so emitting synchronously
raced test-balls' runs reset). Exactly one ball is recorded per run, even if
UI reporting fails.
2026-10-15 13:51:13 +01:00
Firehose Bot 5d7eddbfef fix(build): resolve mise via PATH instead of hardcoded ~/.local/bin path
mise now lives at /opt/homebrew/bin/mise (Homebrew install), so the
hardcoded MISE_BIN default broke make test/deps/compile. Fall back to
PATH lookup, matching what the check target already did.
2026-10-15 13:43:26 +01:00
Firehose Bot c8a3713cca fix link 2026-10-11 14:40:45 +01:00
Firehose Bot 855bb37065 remove todo
can't find John's post on linkedin
2026-10-08 13:56:01 +01:00
Firehose Bot 1cd2d062a3 emacs lisp script to turn org-roam notes into blog posts
with acceptance tests
2026-10-08 13:40:18 +01:00
Firehose Bot 38873bde8c will it blend - live 2026-10-08 13:39:09 +01:00
Firehose Bot 310a5c0afd Bump phoenix_live_view to 1.2.12, closing last security advisory
The microprints git dep now allows phoenix_live_view ~> 1.2, so the
app can take the CVE-2026-64941 fix (open redirect in
validate_local_url!/2, fixed in 1.2.9). No 1.1.x backport existed.

blogex test lock follows to 1.2.12 so the library is tested against
the version the app ships with. mix deps.get now reports zero
security advisories.
2026-10-08 09:50:49 +01:00
Firehose Bot 7869166841 Add Playwright e2e test setup and configs 2026-10-08 09:49:07 +01:00
Firehose Bot b972603143 Add draft blog posts on step size and TDD diagnostics
Both published: false. The 07-20 draft references a screenshot that is
not yet in the repo (roam-export image copying is still in progress).
2026-10-08 09:49:07 +01:00
Firehose Bot 20213b0f78 mix format: app layout and editor dashboard test 2026-10-08 09:49:07 +01:00
Firehose Bot 3eec05b1d3 Update dependencies with security advisories
Bump all Hex deps with OSV advisories to fixed versions:
bandit 1.12.5, phoenix 1.8.15, plug 1.20.3, plug_crypto 2.2.0,
postgrex 0.22.4, decimal 3.1.1 (via ecto/ecto_sql 3.14), swoosh 1.28.1,
mint 1.11.0, hpax 1.1.0, finch 0.24.0, req 0.7.5, lazy_html 0.1.13.

Constraint changes: ecto_sql ~> 3.14 (ecto 3.14 requires decimal ~> 3.0),
req ~> 0.7.

phoenix_live_view stays at 1.1.27 (CVE-2026-64941, LOW): the fix landed
in 1.2.9 with no 1.1.x backport, and the microprints git dep pins
~> 1.1.0. Tracked in a follow-up yak.
2026-10-08 09:49:04 +01:00
Firehose Bot 4ed6aee47f Ignore build and tooling artifacts 2026-10-08 09:49:01 +01:00
Firehose Bot c5db0cbda8 Fix compile-time link validation and add tag page links
- Restore HTML link extraction in LinkValidator (removed in a83634d
  under the false premise that post bodies are raw markdown; they
  are HTML rendered by NimblePublisher at compile time). The missing
  regex made extract_links/1 find zero links, silently disabling
  compile-time validation.
- Support /blog/{blog_id}/tag/{tag} links: validate blog ID,
  require non-empty tag (tags are user-defined, e.g. pi.dev).
- Fix invalid links in two posts: tag/Pi.dev -> tag/pi.dev,
  2026-07-13-synthetic-tdd.md -> synthetic-tdd.
- Fix test warnings: use Plug.Test deprecation, unused import,
  runtime-defined TestBlogValid module.
- Add regression tests for HTML extraction and tag page links.
2026-10-08 08:43:51 +01:00
108 changed files with 5226 additions and 322 deletions
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
#
# pre-push: the Lens pass. Runs `make verify` (cheap gate + heavy sensors + both
# test suites) and blocks the push if anything fails.
#
# Install with `make hooks` (symlinks this file into this clone's git hooks
# directory). Deliberate bypass: `git push --no-verify`.
#
# Why a symlink and not `git config core.hooksPath .githooks`: bd keeps its own
# managed hooks in .git/hooks (pre-commit flushes .beads/issues.jsonl, post-merge
# imports it). Pointing core.hooksPath elsewhere would silently retire them, and
# bd would still report its hooks as installed. Installing one symlink per hook
# in .githooks/ keeps every hook manager's files where they expect them.
#
# stdin carries "<local ref> <local sha> <remote ref> <remote sha>" per ref being
# pushed; the Lens pass is repo-wide, so it is deliberately not read.
set -uo pipefail
if ! cd "$(git rev-parse --show-toplevel)"; then
echo "pre-push: cannot find the repository root; refusing to let this push through" >&2
exit 1
fi
if ! command -v make >/dev/null 2>&1; then
echo "pre-push: make not found on PATH — the Lens pass cannot run, so the push is blocked" >&2
echo "pre-push: install make, or bypass deliberately with: git push --no-verify" >&2
exit 1
fi
log=$(git rev-parse --git-dir)/lens-pass.log
echo "pre-push: running the Lens pass (make verify) — full log: $log"
set -o pipefail
if make verify 2>&1 | tee "$log"; then
echo "pre-push: Lens pass passed"
exit 0
fi
last_step=$(grep -E '^==> ' "$log" | tail -n 1)
{
echo
echo "=================================================================="
echo "pre-push: LENS PASS FAILED — push blocked"
echo " last step started: ${last_step:-unknown (see log)}"
echo " full log: $log"
echo " triage the failure (real bug / environment-dependent test /"
echo " design nit — see AGENTS.md), then re-run: make verify"
echo " deliberate bypass: git push --no-verify"
echo "=================================================================="
} >&2
exit 1
+16
View File
@@ -10,3 +10,19 @@ transcripts/
.rodney/
.dir-locals.el
*.log
# VM crash dumps at repo root (app/ has its own rule)
erl_crash.dump
# Playwright test output artifacts
app/assets/test-results/
# pi agent tool state for the app sub-project (logs, npm cache)
app/.pi/
# macOS screenshots (session evidence lives in the transcript, not in git)
Screenshot*.png
# nono sandbox scratch space
nono/
/.DS_Store
+63 -13
View File
@@ -1,11 +1,64 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
/**
* Fires `make test` in the background on session start.
*
* The run is tied to the session that started it:
* - a footer status widget shows "running tests…" while it runs
* - on completion it records a test-balls ball, notifies, and posts the output
* - `session_shutdown` (quit/reload/new/resume/fork) aborts an in-flight run:
* pi invalidates captured pi/ctx objects on session replacement, so any
* post-run work from an orphaned run would throw "This extension ctx is
* stale after session replacement or reload". The replacement session
* starts its own run, so an aborted run is abandoned silently.
*/
const STALE_CTX_PREFIX = "This extension ctx is stale after session replacement or reload.";
const isStaleCtxError = (error: unknown): boolean =>
error instanceof Error && error.message.startsWith(STALE_CTX_PREFIX);
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
try {
const { stdout, stderr, code } = await pi.exec("make", ["test"]);
const output = stdout + stderr;
let inFlight: AbortController | undefined;
pi.on("session_start", async (_event, ctx) => {
// Defensive: if this instance ever starts twice, only the newest run lives.
inFlight?.abort();
inFlight = new AbortController();
const signal = inFlight.signal;
// Fire-and-forget: do not block session start on the test run.
void runMakeTest(pi, ctx, signal).catch((error) => {
if (isStaleCtxError(error)) return; // session replaced mid-finish: nothing left to report to
console.error("run_make_test:", error);
});
});
pi.on("session_shutdown", () => {
// Runs before the runner is invalidated: kill the child process so an
// orphaned run can never touch the stale pi/ctx that spawned it.
inFlight?.abort();
});
}
async function runMakeTest(pi: ExtensionAPI, ctx: ExtensionContext, signal: AbortSignal): Promise<void> {
const showStatus = ctx.mode === "tui";
if (showStatus) ctx.ui.setStatus("make-test", "running tests…");
// Record exactly one ball per run, even if UI reporting below fails.
let recorded = false;
const record = (pass: boolean) => {
if (recorded) return;
recorded = true;
pi.events.emit("test-balls:record", { pass, command: "make test", ts: Date.now() });
};
try {
const { stdout, stderr, code } = await pi.exec("make", ["test"], { signal });
if (signal.aborted) return; // session replaced: abandon silently
record(code === 0);
if (showStatus) ctx.ui.setStatus("make-test", undefined);
const output = stdout + stderr;
if (code === 0) {
ctx.ui.notify("make test passed", "info");
} else {
@@ -24,14 +77,11 @@ export default function (pi: ExtensionAPI) {
display: true,
}, { deliverAs: "followUp" });
} catch (error) {
if (signal.aborted || isStaleCtxError(error)) return; // session replaced: abandon silently
if (showStatus) ctx.ui.setStatus("make-test", undefined);
record(false);
const message = error instanceof Error ? error.message : String(error);
pi.sendMessage({
customType: "run-make-test",
content: [
{ type: "text", text: `Error running make test: ${message}` },
],
display: true,
}, { deliverAs: "followUp" });
ctx.ui.notify(`run_make_test failed: ${message}`, "error");
throw error; // the session_start catch logs it
}
});
}
+58 -23
View File
@@ -46,7 +46,7 @@ Build an understanding of what to demo from these sources (in priority order):
Explicit scenario (--scenario): If provided, use as primary guide.
Plan file: Read the plan's Goal, Acceptance Criteria, and completed Steps.
Recent commits: Run git log --oneline -15 and git diff main...HEAD --stat to identify changed files and commit messages.
Route map: Cross-reference changed files against known LiveView routes in the router (lib/hub_web/router.ex).
Route map: Find the router yourself - do not assume a path. Search the project for its router (`grep -rn "use Phoenix.Router" lib/`; conventionally lib/<app>_web/router.ex), then read the scopes, pipelines and live_sessions to learn which routes the feature touches, which are public, and which require a logged-in user.
From these sources, produce a demo outline:
@@ -56,42 +56,77 @@ From these sources, produce a demo outline:
Interactions: any clicks, form fills, or navigation sequences to perform
If no plan exists and commits are ambiguous, ask the user for a brief description of what to demo.
3. Check dev server
3. Resolve the dev port, then check the server
curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/ 2>/dev/null
Never assume a port. Read it from the project config, from the repo root:
DEMO_PORT=$(cd app && mix run --no-start -e \
'IO.puts(Application.get_env(:firehose, FirehoseWeb.Endpoint)[:http][:port])')
DEMO_BASE_URL="http://localhost:${DEMO_PORT}"
curl -s -o /dev/null -w "%{http_code}" "$DEMO_BASE_URL/"
Adapt that command to the project: find the app/endpoint pair in `config/*.exs`
(`grep -rn "Endpoint," config/`) and use the same names. If the config reads the
port from the environment, the value above already reflects it. Do not resolve
the port with `<Endpoint>.config(:http)`: with `--no-start` the endpoint's ETS
table does not exist and it raises.
If the project pins a toolchain or wraps builds, use its wrapper for every
command below (`mise exec -- mix ...`, asdf shims, or its `make` targets - check
AGENTS.md/README first). Bare `mix` is not a safe assumption.
If the server is not reachable:
The Phoenix dev server is not running. Start it now?
mix phx.server &
mkdir -p .tmp && (cd app && PORT=$DEMO_PORT mix phx.server \
> ../.tmp/phx-demo.log 2>&1 &)
After starting, wait up to 10 seconds and verify connectivity. If it still fails, proceed with backend-only evidence (skip all browser screenshots) and note the limitation in the demo document.
4. Handle authentication
Then poll `$DEMO_BASE_URL` for up to 10 seconds. If it still fails, read
`.tmp/phx-demo.log`: an error about an unresolvable JS module means the
project's JS packages are not installed - run its assets install step (in this
repository `make -C app assets`, which root `make deps` also runs). Only when
the server really cannot come up, proceed with backend-only evidence (skip all
browser screenshots) and note the limitation in the demo document.
The app requires authentication for LiveView routes. Before capturing UI screenshots:
Use `$DEMO_BASE_URL` for every URL below - there is no default port to fall
back on.
4. Handle authentication (only if the route needs it)
Ensure a demo user exists:
Decide this from the project, not from this file. If every page you need is
public, skip this step. A route needs a session when the router found in step 2
puts it inside a `pipe_through` that requires a logged-in user, or inside a
`live_session` guarded that way.
mix run -e "
alias Hub.Accounts
case Accounts.get_user_by_email(\"demo@example.com\") do
nil -> Accounts.register_user(%{email: \"demo@example.com\", password: \"demodemo1234\"})
user -> {:ok, user}
end
"
When you do need a session, read each part from the project instead of assuming
it:
Log in via rodney:
- Credentials: check README, AGENTS.md, docs and the project's seeds
(`priv/repo/seeds.exs`, a `mix demo`-style task). Use documented demo
credentials if they exist - never invent them.
- Demo user: if nothing is documented, create one through the project's own
path, in this order: run its seeds; use its registration route; call the
accounts/context function you find by searching for it (`grep -rn
"get_user_by_email\|register_user\|create_user" lib/`). Copy the exact module,
function and field names from the source; do not guess an alias.
- Login route, form fields and submit button: read them off the router and out of
the rendered form (`rodney html form` or `rodney ax-tree`) rather than
assuming a path or an input name.
Then drive the form with the selectors you just read:
rodney start
rodney open http://localhost:4000/users/log-in
rodney wait "input[name='user[email]']"
rodney input "input[name='user[email]']" "demo@example.com"
rodney input "input[name='user[password]']" "demodemo1234"
rodney click "button[type='submit']"
rodney open "$DEMO_BASE_URL<login-route>"
rodney wait "<email-input-selector>"
rodney input "<email-input-selector>" "<email>"
rodney input "<password-input-selector>" "<password>"
rodney click "<submit-selector>"
rodney waitidle
If login fails, warn and proceed with backend-only evidence.
Confirm the session took - `rodney url` should have left the login route, and
the page should show the user (name, avatar, menu). If login fails, warn, note it
in the demo document, and proceed with backend-only evidence.
5. Initialize the demo document
Slugify the feature name (lowercase, hyphens, no special chars). Then:
@@ -136,7 +171,7 @@ For each UI page identified in step 2, navigate with rodney, screenshot, and emb
Static page capture:
rodney open http://localhost:4000/<route>
rodney open "$DEMO_BASE_URL/<route>"
rodney waitidle
rodney screenshot demos/screenshots/<feature>-<page-name>.png
+150 -3
View File
@@ -26,11 +26,24 @@ firehose/
│ ├── lib/
│ └── mix.exs
├── mise.toml # Runtime versions (Elixir, Erlang, Node)
└── Makefile # make (test, check)
├── Dockerfile # Deploy build; ARG pins must match mise.toml
├── scripts/check-pins.sh # Verifies the pin sync; runs in `make check`
├── .githooks/pre-push # Lens pass on push; install once with `make hooks`
└── Makefile # make (check, verify, test, hooks)
```
Two blogs are configured:
# Toolchain pins
`mise.toml` and the `Dockerfile` ARG pins must agree; `scripts/check-pins.sh`
(runs as part of `make check`) enforces this. When bumping versions, change
both files in one commit. Upstream drift (newer Elixir/OTP/Debian releases)
is reported by `dokku-updates firehose check` (dokku-maintenance repo).
Upgrading pins or mix packages is largely unscripted: for such tasks, a Pi
sub agent can be started in this directory — it reads this AGENTS.md and
follows the zero-defects policy above.
| Blog | Route | Description |
|------|-------|-------------|
| Engineering | `/blog/engineering` | Main blog |
@@ -57,9 +70,143 @@ This is a Phoenix Liveview monorepo, the blogging library is in blogex, the appl
Main make targets:
- make test
- make check - runs credo static analysis and suggests refactorings
- `make setup` - one-time clone setup (and after any mix.exs gains a dep):
fetches Hex deps for both apps, the app's JS packages, and compiles deps
(some deps fetch artifacts at compile time, e.g. the mdex_native NIF).
Needs network - run it yourself; agent sandboxes refuse installs.
check/test/verify never touch the network: they run a local `deps.check`
guard that fails with a pointer here when deps are missing.
- `make check` - cheap static gate for both apps (no DB): app `credo --strict` +
format **check** (`mix format --check-formatted`), blogex compile (warnings
fatal) + format **check**, then `scripts/check-pins.sh`. It never rewrites
files.
- `make precommit` - alias of `check`; fast, safe to run on every commit
- `make test` - both suites (app needs PostgreSQL on localhost:5432)
- `make verify` - the Lens pass: `check`, then per-app heavy sensors and both suites
- `make hooks` - install `.githooks/*` into this clone (once per clone)
# Verification: fast loop vs Lens pass
Two loops, deliberately split so commits stay cheap and pushes stay honest.
**Fast loop (every commit).** `make check` is static analysis only: no database,
no coverage, about a second. `make precommit` is the same target. Per app,
`mix precommit` (run inside `app/` or `blogex/`) is the fuller loop: compile with
warnings fatal, format, test. Run these as often as you like while working.
`make check` never writes files. A formatting failure means run `make format`
(both apps). The mutating loops are the explicit ones: `make format` and the
per-app `mix precommit`, which still formats as you work.
**Lens pass (every push, and before handing off).** `make verify` runs `check`
first (cheapest), then per app: one suite run that exports its coverage, then
that app's sensors (sobelow, coverage, CRAP; mutation as that yak lands), which
run cheapest-first and read the export rather than re-running the suite. Measured
2026-11-01: ~11s wall (app 178 tests + blogex 156 tests + coverage floor).
`.githooks/pre-push` runs `make verify` and blocks the push when anything fails.
Install it once per clone:
make hooks
It symlinks `.githooks/*` into the clone's hooks directory; `core.hooksPath` is
deliberately left alone so bd's own hooks (pre-commit JSONL flush, post-merge
import) keep firing. On failure the hook prints one summary - last step started,
log path, bypass - and exits non-zero; the full output is in `.git/lens-pass.log`.
Deliberate override (e.g. pushing a branch you know is red): `git push --no-verify`.
## Lens practice
The Lens pass is a *separate pass with its own context*. It is not a way to find
work mid-feature: finish and commit what you are building, then run it.
- Do not chase hook or `credo` output inline while building a feature. If
something interesting surfaces, note it and file a yak; take it up in a Lens
session.
- A failing Lens pass blocks the push, and therefore the hand-off. Triage it;
never re-run until green and never ignore it.
- Adding a sensor: implement it as its own make target, add it to that app's
`VERIFY_SENSORS` in `app/Makefile` / `blogex/Makefile` (cheapest first), and
update the slot comments there. A gate that reports a check it did not run is
worse than no gate - the `VERIFY_SENSORS` list is what gets echoed. Sensors run
*after* the app's `test.coverage` step, because the coverage export they read is
produced by that suite run; if a check can pass without its input, make it fail
loudly (see the `coverage` target's guard).
### Triage classes
| Class | Looks like | What to do |
|-------|-----------|------------|
| *Real bug* | behavior is wrong; a test fails for a real reason; a sensor reports a real risk | fix inline, with a test that pins the behavior |
| *Environment-dependent test* | passes alone, fails in the pass; depends on ambient state (rows left behind, wall clock, ordering, network, locale) | the test is the bug: make it deterministic by construction - own fixtures, explicit clock, no ambient state as fixtures. Never retry-until-green |
| *Style or design nit* | credo or format suggestion, naming, structure, "this could be smaller" | do not refactor mid-build: note it and file a yak; the Lens session decides |
Reconciling this with the zero-defects policy: "never continue when there are
errors" governs *what you do with a failure* - report, triage, decide - not
*when* you fix it. Never silently absorb a finding, and never let a pre-existing
failure ride along: if it predates your change, stop and discuss it with the user
rather than working around it.
Browser tests (`make playwright*`) are *external* verification and are **not**
part of the Lens pass. Decided 2026-10-28 (human, yak "ratchets, deferred
decisions, handoff practice"): it stays that way - Playwright is not wired into
`make verify` or into either `VERIFY_SENSORS`. The targets need a running
Phoenix server or the Docker Chrome container, and a gate that only runs with
docker or sudo is not a gate the Lens pass can rely on. Run them as their own
loop, by hand, when a change touches the browser surface.
### Landing the plane
The Lens pass is what you land with, so it is the last step of every session,
not a thing that only happens on push:
- After any session that changed code, run `make precommit`. It is `make check`
under its session-ending name and costs about a second; there is no session
where it is not worth running.
- Before handing off - to a human, a reviewer, or the next agent - run
`make verify`, or push, which fires `.githooks/pre-push` and runs the same
pass on the same tree. A hand-off note that says "tests pass" without a Lens
pass behind it is a guess, not a hand-off.
- A failing Lens pass is triaged into the classes above (see *Triage classes*)
and **reported** in the hand-off note, with the class it fell in. A nit gets a
yak; it does not get fixed inline mid-build. Never re-run until green.
- `git push --no-verify` stays the deliberate override for a branch you know is
red. It is never how a session lands.
## Ratchets
The coverage threshold in `app/mix.exs` and `blogex/mix.exs` (the `threshold:`
inside each `test_coverage/0`) is a **floor**, not a target:
- After each behavior-test yak lands, re-measure both apps - `make test.coverage`
then `make coverage` inside each app - and raise the floor to the new total.
The convention is integer truncation of the measurement: a measured 82.61%
means a floor of 82. Never above the measurement: a floor that cannot pass is
not a ratchet, it is a red gate.
- Never lower a floor. A legitimate drop is a conversation, not a config edit.
Lowering the number, or adding an `ignore_modules` entry to prop it up, hides
the behavior gap the floor exists to surface.
- The record of the last measurement and its date is the comment above
`test_coverage/0` in that app's `mix.exs`. Update it there, in the same commit
as the number. Do not copy the totals into this file: one record, in the
place the gate reads, is the whole point.
Two further health numbers are baselines-to-be; neither is `make verify`'s
business yet, and neither is to be invented:
- **CRAP offender count.** `make crap` in one app is the sensor: a passing run
prints `Summary: ... worst_score=...`, a failing run prints `High scores: N`
and the offending functions. Both apps sit under the default `--max-score
(30)`, and the decision (2026-10-28) not to lower it, plus the baseline
offender count, live in the comment above the `crap` target in
`app/Makefile` / `blogex/Makefile`.
- **Mutation survivor count.** Mutation is a slot comment only in
`app/Makefile` and `blogex/Makefile` ("yak: mutation testing with muex - a
sensor, not a gate"), and `muex` is a dependency of neither app. Nothing has
been mutated, so there is no survivor baseline; do not conjure one from a run
that did not happen. When the mutation yak lands, its survivors become the
baseline the same way the coverage floor did: measured, dated, recorded where
the sensor lives.
# Development
Always fix failing tests, credo issues and format isseus.
+3 -3
View File
@@ -1,9 +1,9 @@
# Dockerfile for Dokku deployment
# Multi-stage build for Phoenix/Elixir app with monorepo layout
ARG ELIXIR_VERSION=1.18.3
ARG OTP_VERSION=27.2.4
ARG DEBIAN_VERSION=bookworm-20260316-slim
ARG ELIXIR_VERSION=1.20.4
ARG OTP_VERSION=27.3.4.19
ARG DEBIAN_VERSION=bookworm-20261020-slim
ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}"
+57 -9
View File
@@ -1,18 +1,64 @@
# Makefile for Firehose monorepo
.PHONY: check precommit deps compile test format
MISE_BIN ?= mise
# Common check target that runs all static analysis
.PHONY: check precommit verify hooks deps setup compile test test-elisp format
# Cheap static gate over both apps, cheapest first. No database, no coverage:
# this is the loop you run freely while working and before every commit.
check:
@echo "Running static analysis..."
@make -C app MISE_BIN=mise check
@echo "==> check: app (credo --strict, format)"
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) check
@echo "==> check: blogex (compile, format)"
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) check
@echo "==> check: toolchain pins"
@bash scripts/check-pins.sh
@echo "==> check: Lens sensor wiring"
@bash scripts/check-lens-sensors.sh
# Precommit target for CI/pre-commit hooks
precommit: check
# Sync dependencies
deps:
@make -C app deps
# The Lens pass: everything `check` does, plus, per app, one suite run (which
# exports its coverage) and that app's sensors reading the export. Sensors run
# cheapest-first among themselves. `.githooks/pre-push` runs this before every
# push (see `make hooks`); run it yourself before handing off. Bypass
# deliberately with `git push --no-verify`.
verify: check
@echo "==> verify: app"
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) verify
@echo "==> verify: blogex"
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) verify
# Install the versioned hooks in .githooks/ into this clone's hooks directory as
# symlinks (edits take effect immediately, no copy to drift). Idempotent; re-run
# after moving the clone, since the links are absolute.
#
# core.hooksPath is deliberately NOT changed: bd manages its own hooks inside
# .git/hooks (pre-commit flushes .beads/issues.jsonl, post-merge imports it) and
# moving the hooks path would silently retire them.
hooks:
@hooks_dir=$$(git rev-parse --git-path hooks); \
root=$$(git rev-parse --show-toplevel); \
mkdir -p "$$hooks_dir"; \
for hook in .githooks/*; do \
name=$$(basename "$$hook"); \
ln -sfn "$$root/$$hook" "$$hooks_dir/$$name"; \
echo "installed $$hooks_dir/$$name -> $$hook"; \
done
# One-time setup for this clone (and after any mix.exs gains a dependency):
# fetches Hex deps for both apps and the app's JS packages. Needs network, and
# is deliberately NOT wired into check/test/verify: `mix deps.get` contacts
# hex.pm even when everything is already installed (the advisory check), which
# fails offline/sandboxed. The everyday loops run the per-app `deps.check`
# guard instead and point here when deps are missing.
setup:
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) deps
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) deps
# Kept for muscle memory; `setup` is the canonical entry point.
deps: setup
# Compile the project
compile:
@@ -23,9 +69,11 @@ test:
@make -C app test
@make -C blogex test
# Format code
# Format code in both apps. Mutating convenience target: `check` (and so
# `verify`) never writes. If a gate fails on formatting, run this.
format:
@make -C app format
@$(MAKE) -C app MISE_BIN=$(MISE_BIN) format
@$(MAKE) -C blogex MISE_BIN=$(MISE_BIN) format
# Serve the app
serve:
+12 -1
View File
@@ -25,7 +25,9 @@ firehose/
├── blogex/ # Blogex library (multi-blog engine)
│ ├── lib/
│ └── mix.exs
└── mise.toml # Runtime versions (Elixir, Erlang, Node)
├── Dockerfile # Deploy build; ARG pins must match mise.toml
├── mise.toml # Runtime versions (Elixir, Erlang, Node)
└── scripts/check-pins.sh # Verifies the pin sync; runs in `make check`
```
Two blogs are configured:
@@ -51,6 +53,15 @@ mise install
This installs Elixir, Erlang, and Node at the versions specified in `mise.toml`.
## Toolchain pins
`mise.toml` (local dev toolchain) and the `Dockerfile` `ARG` pins (deploy
builder image) must agree — local builds and deployed images should run the
same Elixir/OTP. `make check` runs `scripts/check-pins.sh` to verify this;
when it reports a MISMATCH, fix the file it names. Upstream drift of these
pins (newer Elixir/OTP/Debian releases) is reported separately by
`dokku-updates firehose check` from the dokku-maintenance repo.
### 2. Start PostgreSQL
You need PostgreSQL running locally with a `postgres` user (password: `postgres`).
+24 -1
View File
@@ -4,6 +4,13 @@
# If you find anything wrong or unclear in this file, please report an
# issue on GitHub: https://github.com/rrrene/credo/issues
#
# The gate for this app is `mix credo --strict` on the command line (the
# `credo` target in Makefile, a prerequisite of `check`). The command line, not
# this file, decides: `--strict` overrides the inert `strict: false` key below.
# Editing `strict` here to silence a finding would change nothing - fix the
# finding, or write down why the check is wrong for this codebase (see
# `disabled:`).
#
%{
#
# You can have as many configs as you like in the `configs:` field.
@@ -41,6 +48,11 @@
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
# This is the ONLY load path for lib_dev/ checks: the directory is kept out
# of elixirc_paths in mix.exs on purpose, so the module is not also loaded
# from a compiled .beam (that double load produced
# "warning: redefining module Firehose.Checks.NoConnShadowing").
#
requires: ["lib_dev/firehose/checks/"],
#
# If you want to enforce a style guide and need a more traditional linting
@@ -121,7 +133,6 @@
#
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FilterCount, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.FunctionArity, []},
@@ -169,6 +180,18 @@
{Firehose.Checks.NoConnShadowing, []}
],
disabled: [
#
# Our one deliberate deviation from credo's defaults. CRAP (`ex_crap`,
# threshold 30, wired into the Lens pass in Makefile) is this app's
# complexity sensor, and it scores complexity against real coverage: a
# wide `case` that the tests exercise is not a problem - that was the
# itch behind ExCrap. A second, coverage-blind complexity sensor would
# just argue with the first, so complexity questions are settled by
# the CRAP target. Re-enabling this check without retiring the CRAP
# target is a policy change, not a cleanup.
#
{Credo.Check.Refactor.CyclomaticComplexity, []},
#
# Checks scheduled for next check update (opt-in for now)
{Credo.Check.Refactor.UtcNowTruncate, []},
+30
View File
@@ -0,0 +1,30 @@
# Sobelow per-finding skips. Enabled by `--skip` in `app/Makefile`.
#
# Rule: every entry is preceded by a rationale comment saying what the check
# cannot see and why the protection is real (or why the finding is a false
# positive). An unexplained skip is a defect; `scripts/check-lens-sensors.sh`
# enforces this. Fingerprints are sobelow's own; a code change that moves a
# skip's line invalidates it and the sensor fails loudly rather than staying
# quiet.
#
# Baseline 2026-10-28, sobelow 0.17.0: 3 findings (2 High, 1 Low), all skipped.
# Config.CSP - High - lib/firehose_web/router.ex:13 (pipeline :browser)
# No CSP exists. put_secure_browser_headers does not set one and the root
# layout has an inline theme script with no nonce, so choosing a policy is a
# design decision rather than a mechanical fix.
# Follow-up yak: app: add a Content-Security-Policy (sobelow Config.CSP)
Config.CSP: Missing Content-Security-Policy,lib/firehose_web/router.ex:13,486EC13
# Config.HTTPS - High - config/prod.exs
# No force_ssl: production TLS is terminated by the Dokku proxy. Whether the
# endpoint should also force SSL/HSTS (and trust X-Forwarded-Proto) is a
# deployment decision, not a mechanical fix.
# Follow-up yak: app: enforce HTTPS in production (sobelow Config.HTTPS)
Config.HTTPS: HTTPS Not Enabled,config/prod.exs:0,2B5C077
# Traversal.FileModule - Low - lib/firehose_web/live/microprints_live.ex:331
# False positive: the client path is only read after Enum.find matches it
# against the server-scanned microprint list, and source_dir comes from
# @source_dirs. Both Path.join inputs are server-computed, never client text.
Traversal.FileModule: Directory Traversal in `File.read`,lib/firehose_web/live/microprints_live.ex:331,7ED2FE5
+127 -8
View File
@@ -1,19 +1,54 @@
# Makefile for Firehose app
MISE_BIN ?= $(HOME)/.local/bin/mise
MISE_BIN ?= mise
MISE_EXEC = $(MISE_BIN) exec --
.PHONY: check precommit deps compile test format credo
.PHONY: check precommit deps deps.check assets compile test test.coverage sobelow coverage crap format format.check credo verify
# Run all static analysis checks (no database required)
check: credo format
# Run all static analysis checks (no database, no network required). The
# deps.check guard fails loudly with a pointer at `make setup` when Hex deps
# are missing. `format.check` only reports: a gate that rewrites the files it
# judges can never fail. Fix formatting with `make format`.
check: deps.check credo format.check
# Precommit target for CI/pre-commit hooks
precommit: check compile
# Sync dependencies
deps:
# Install JS dependencies (esbuild bundle, playwright). `mix phx.server` and
# `mix assets.deploy` need them: without `assets/node_modules` the bundle fails
# to resolve highlight.js and LiveView never loads.
#
# Guarded on npm's own install marker, so a clone pays for it once and later
# runs never touch the network: `make test` keeps working offline.
assets:
@test -f assets/node_modules/.package-lock.json || \
(echo "==> assets: npm install (first run)"; cd assets && npm install)
# Sync dependencies (Hex packages + JS packages) and compile them. The compile
# step is part of setup on purpose: some deps download artifacts at compile
# time - rustler_precompiled fetches the mdex_native NIF from GitHub during
# its first compile, and NimblePublisher pulls mdex_native into this app. Keep
# that traffic in this explicit, user-run step so check/test/verify stay
# offline even on a cold clone. (rustler_precompiled caches artifacts globally
# in ~/Library/Caches/rustler_precompiled, so later test-env compiles find
# them without network.)
deps: assets
$(MISE_EXEC) mix deps.get
$(MISE_EXEC) mix deps.compile
# Local, network-free guard: every package pinned in mix.lock must be present
# in deps/. check, test and test.coverage depend on this instead of `deps`, so
# the everyday loops never touch the network: `mix deps.get` contacts hex.pm
# even when everything is already installed (the advisory check), which fails
# in offline/sandboxed environments. Fetching is `make setup`'s explicit job,
# run from the repo root.
deps.check:
@test -f mix.lock || { echo "deps.check: no mix.lock - run 'make setup' at the repo root" >&2; exit 1; }
@missing=""; \
for dep in $$(sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p' mix.lock); do \
[ -d "deps/$$dep" ] || missing="$$missing $$dep"; \
done; \
test -z "$$missing" || { echo "deps.check: missing:$$missing - run 'make setup' at the repo root" >&2; exit 1; }
# Compile the project
compile:
@@ -21,13 +56,55 @@ compile:
# Run tests (requires PostgreSQL running on localhost:5432)
# Note: If you don't have PostgreSQL, you can skip tests with `make check`
test: deps compile
test: deps.check compile
$(MISE_EXEC) mix test
# Format code
# Run the suite once with coverage exported, so the coverage floor check (and,
# later, CRAP) reads the export instead of running the suite a second time.
# Stale exports are removed first: the floor must never report an older run.
test.coverage: deps.check compile
rm -f cover/*.coverdata
$(MISE_EXEC) mix test --cover --export-coverage default
# Enforce the coverage floor (mix.exs -> test_coverage) from that export. No
# test run. `mix test.coverage` exits 0 when it finds no export at all, so the
# guard below is load-bearing: a floor check that did not run must not pass.
coverage:
@test -f cover/default.coverdata || { echo "coverage: no exported coverage - run 'make test.coverage' first" >&2; exit 1; }
$(MISE_EXEC) mix test.coverage
# Enforce CRAP scores (complexity x coverage) from the same export. No test run.
# Runs in MIX_ENV=test because that is the environment the export came from:
# running in dev would compile a second build and score against a different set
# of modules. The guard is load-bearing for the same reason as `coverage`:
# ex_crap falls back to 0% coverage when the export is missing, which would
# report scores that are pessimistic instead of absent - a false failure is
# less bad than a false pass, but neither is acceptable in the Lens pass.
#
# Threshold is ex_crap's default (30), kept deliberately: decision 2026-10-28
# (human, yak "ratchets, deferred decisions, handoff practice") is not to
# ratchet --max-score down yet - below 30 is a policy change, not a cleanup.
# Baseline at that threshold, same date: 0 offenders above 30, worst function
# FirehoseWeb.CoreComponents.table/1 at 20.00 ("Summary: files=23 functions=140
# scored=140 worst_score=20.00"). A failing run prints "High scores: N" plus the
# offending functions; on a passing run read N=0 off the worst score rather than
# inventing a count.
crap:
@test -f cover/default.coverdata || { echo "crap: no exported coverage - run 'make test.coverage' first" >&2; exit 1; }
MIX_ENV=test $(MISE_EXEC) mix crap
# Format code. MUTATES the tree; deliberately not part of `check` (nor of the
# Lens pass), which must never write: a gate that fixes what it judges can never
# fail, and would dirty the tree a push is about to send. `make check` uses
# `format.check` below instead.
format:
$(MISE_EXEC) mix format
# Verify formatting without writing. Fails with the offending files, so the fix
# is explicit: run `make format` (or `make -C app format`).
format.check:
$(MISE_EXEC) mix format --check-formatted
# =============================================================================
# Playwright e2e browser tests
# =============================================================================
@@ -95,3 +172,45 @@ playwright-full: playwright-docker-start
# Run Credo static analysis
credo:
$(MISE_EXEC) mix credo --strict
# =============================================================================
# Lens pass (heavy checks; root `make verify` runs this before every push)
# =============================================================================
#
# Sensor slots, cheapest first. Credo is cheap enough that it lives in
# `check`, not here. Add a sensor as its yak lands, both as a target and to
# VERIFY_SENSORS — that variable is the single source of truth for what this
# target reports as wired.
# - sobelow (yak: sobelow in the Lens pass)
# WIRED below (sobelow, ~1s). Fails loudly on findings and
# when the task is missing — no fallback of any kind.
# Per-finding skips exist only in .sobelow-skips, each with a
# written rationale (pinned by scripts/check-lens-sensors.sh).
# - coverage (yak: coverage floors + framework-glue exclusions policy)
# WIRED below (test.coverage + coverage).
# - crap (yak: CRAP scores with ex_crap (both apps))
# WIRED below (crap); reuses the coverage export.
# - mutation (yak: mutation testing with muex — a sensor, not a gate)
#
# Runtime note: the coverage sensor exports from a single suite run
# (`test.coverage` -> `mix test --cover --export-coverage default`) that CRAP
# reuses, instead of running the suite twice.
#
# The suite runs FIRST, because it is what produces the coverage export the
# sensors read; sensors still run cheapest-first among themselves.
VERIFY_SENSORS := sobelow coverage crap
# Sobelow security scan. `--exit` is load-bearing: without it sobelow prints
# findings and exits 0 (verified 2026-10-28, sobelow 0.17.0: 3 findings -> 0,
# `--exit` -> 1; it defaults to the `low` confidence threshold). `--skip`
# applies only the individually rationalised entries in .sobelow-skips.
# There is deliberately no guard or fallback: if the dep is absent, `mix
# sobelow` is a hard Mix error ("The task could not be found") and the
# verify loop's `|| exit 1` fails the pass.
sobelow:
$(MISE_EXEC) mix sobelow --skip --exit
verify:
@echo "==> verify(app): suite (coverage exported), then sensors [$(VERIFY_SENSORS)]"
@$(MAKE) test.coverage
@for sensor in $(VERIFY_SENSORS); do echo "==> verify(app): sensor $$sensor"; $(MAKE) $$sensor || exit 1; done
+44 -14
View File
@@ -27,7 +27,11 @@
--color-base-200: oklch(94% 0.012 75);
--color-base-300: oklch(90% 0.016 75);
--color-base-content: oklch(25% 0.015 50);
--color-primary: oklch(58% 0.16 45);
/* 54% lightness, not 58%: primary is also *text* (pagination links, body
links, the post-title hover). At 58% it measured 4.18:1 on base-100, below
the 4.5:1 floor; 54% is 4.93:1 there and 4.52:1 on base-200. Same hue and
chroma - this is a contrast fix, not a re-colour. */
--color-primary: oklch(54% 0.16 45);
--color-primary-content: oklch(98% 0.01 75);
--color-secondary: oklch(55% 0.12 30);
--color-secondary-content: oklch(98% 0.008 30);
@@ -53,11 +57,18 @@
--noise: 0;
}
/* Warm dark theme */
/* Warm dark theme.
`prefersdark: false` deliberately: daisyUI emits an *unscoped*
`@media (prefers-color-scheme: dark) { :root { … } }` for a prefersdark
theme, which lands after the light block and beats `[data-theme="light"]` on
equal specificity - an explicit light choice rendered dark on a dark-OS
browser. The inline script in components/layouts/root.html.heex resolves
"system" to an explicit data-theme instead, so no rule here may follow the
OS preference on its own. */
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: false;
prefersdark: true;
prefersdark: false;
color-scheme: "dark";
--color-base-100: oklch(24% 0.015 50);
--color-base-200: oklch(20% 0.012 50);
@@ -144,12 +155,12 @@ body { font-family: 'Source Sans 3', sans-serif; }
}
.blogex-post-preview header h2 a {
color: oklch(var(--color-base-content));
color: var(--color-base-content);
text-decoration: none;
}
.blogex-post-preview header h2 a:hover {
color: oklch(var(--color-primary));
color: var(--color-primary);
}
.blogex-post-description {
@@ -163,7 +174,10 @@ body { font-family: 'Source Sans 3', sans-serif; }
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
opacity: 0.6;
/* 0.6 measured 4.16:1 (base-content over base-100, light theme) - below the
4.5:1 floor. 0.625 is what the floor needs; 0.65 keeps a margin without
making the meta line as loud as the description (0.7 -> 5.66:1). */
opacity: 0.65;
margin-top: 0.25rem;
}
@@ -172,7 +186,7 @@ body { font-family: 'Source Sans 3', sans-serif; }
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
background: oklch(var(--color-base-200));
background: var(--color-base-200);
}
.blogex-post-header h1 {
@@ -193,9 +207,9 @@ body { font-family: 'Source Sans 3', sans-serif; }
.blogex-post-body p { margin-top: 1rem; }
.blogex-post-body ul { list-style: disc; padding-left: 1.5rem; margin-top: 0.5rem; }
.blogex-post-body ol { list-style: decimal; padding-left: 1.5rem; margin-top: 0.5rem; }
.blogex-post-body pre { background: oklch(var(--color-base-200)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; }
.blogex-post-body pre { background: var(--color-base-200); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin-top: 1rem; }
.blogex-post-body code { font-size: 0.875em; }
.blogex-post-body a { color: oklch(var(--color-primary)); text-decoration: underline; }
.blogex-post-body a { color: var(--color-primary); text-decoration: underline; }
/* External link marker on links rewritten by Blogex.ExternalLinks (class
`blogex-external`, added at compile time together with target="_blank").
@@ -219,7 +233,23 @@ body { font-family: 'Source Sans 3', sans-serif; }
-webkit-mask-size: contain;
}
.blogex-post-body blockquote { border-left: 3px solid oklch(var(--color-primary)); padding-left: 1rem; margin-top: 1rem; margin-bottom: 1rem; font-style: italic; opacity: 0.85; }
/* Screen-reader hint injected by Blogex.ExternalLinks at the end of every
external link's text. Visually hidden (the icon above is the visual cue),
still announced: "Wikipedia, link, opens in a new tab". */
.blogex-post-body .blogex-external-hint {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border-width: 0;
}
.blogex-post-body blockquote { border-left: 3px solid var(--color-primary); padding-left: 1rem; margin-top: 1rem; margin-bottom: 1rem; font-style: italic; opacity: 0.85; }
.blogex-tag-list {
display: flex;
@@ -232,13 +262,13 @@ body { font-family: 'Source Sans 3', sans-serif; }
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
background: oklch(var(--color-base-200));
background: var(--color-base-200);
text-decoration: none;
}
.blogex-tag-link:hover, .blogex-tag-active {
background: oklch(var(--color-primary));
color: oklch(var(--color-primary-content));
background: var(--color-primary);
color: var(--color-primary-content);
}
.blogex-pagination {
@@ -251,7 +281,7 @@ body { font-family: 'Source Sans 3', sans-serif; }
}
.blogex-pagination a {
color: oklch(var(--color-primary));
color: var(--color-primary);
text-decoration: none;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Auth helpers for Playwright e2e tests.
*
* Provides utilities for logging in as a test user.
* The app uses email-only registration (magic-link flow for initial confirmation)
* and email+password for subsequent logins.
*/
import { Page } from "@playwright/test";
export const TEST_USER = {
email: process.env.PLAYWRIGHT_EMAIL || "test@firehose.test",
password: process.env.PLAYWRIGHT_PASSWORD || "testpassword123!",
};
/**
* Navigate to the login page, fill in credentials, and submit.
* Returns the page after successful login (redirected to home).
*/
export async function loginAsTestUser(page: Page) {
await page.goto("/users/log-in");
// Use the password-based login form
await page.getByLabel("Email").fill(TEST_USER.email);
await page.getByLabel("Password").fill(TEST_USER.password);
// Click "Log in and stay logged in"
await page.getByRole("button", { name: /log in and stay logged in/i }).click();
await page.waitForURL("/");
}
/**
* Navigate to the registration page, fill in email, and submit.
* Note: In dev mode, registration is invite-only by default. Set
* `PLAYWRIGHT_ALLOW_REGISTRATION=true` to run tests against a server
* with open registration enabled.
*/
export async function registerTestUser(page: Page) {
await page.goto("/users/register");
await page.getByLabel("Email").fill(TEST_USER.email);
await page.getByRole("button", { name: /create an account/i }).click();
await page.waitForURL("/users/log-in");
}
+33
View File
@@ -0,0 +1,33 @@
import { test, expect } from "./fixtures";
import { loginAsTestUser } from "./auth-helpers";
test.describe("Authenticated pages", () => {
test("redirects unauthenticated users to login page", async ({ page }) => {
const response = await page.goto("/editor/dashboard");
// Should redirect to login
expect(response?.url()).toContain("/users/log-in");
});
test("can log in and access the editor dashboard", async ({ page }) => {
await loginAsTestUser(page);
// After login, we should be on the home page
await expect(page).toHaveURL("/");
await expect(page.locator("body")).not.toBeEmpty();
});
test("can log out", async ({ page }) => {
await loginAsTestUser(page);
// Now log out
await page.goto("/users/log-out");
// Should redirect to home
await expect(page).toHaveURL("/");
// Try accessing an authenticated page
await page.goto("/editor/dashboard");
// Should redirect to login
await expect(page).toHaveURL(/\/users\/log-in/);
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* Custom Playwright test fixtures.
*
* Provides a `test` function that connects to a remote Chrome via CDP
* when PLAYWRIGHT_CDP_URL is set (Docker Chrome mode).
* Falls back to the default local browser otherwise.
*/
import { test as base, chromium } from "@playwright/test";
type Fixtures = {
// Browser is provided by Playwright Test by default.
// We only override it when connecting to remote Docker Chrome.
};
const cdpUrl = process.env.PLAYWRIGHT_CDP_URL;
export const test = cdpUrl
? base.extend<Fixtures>({
browser: async ({}, use) => {
const browser = await chromium.connectOverCDP(cdpUrl);
await use(browser);
await browser.close();
},
})
: base;
export { expect } from "@playwright/test";
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from "./fixtures";
test.describe("Connectivity smoke test", () => {
test("visits the homepage and verifies it loads", async ({ page }) => {
const response = await page.goto("/");
expect(response?.ok()).toBeTruthy();
// Verify the page has content
await expect(page).toHaveTitle(/Firehose/);
});
test("visits the contact page", async ({ page }) => {
const response = await page.goto("/contact");
expect(response?.ok()).toBeTruthy();
await expect(page.locator("body")).not.toBeEmpty();
});
test("visits the blog engineering index", async ({ page }) => {
const response = await page.goto("/blog/engineering");
expect(response?.ok()).toBeTruthy();
await expect(page.locator("body")).not.toBeEmpty();
});
test("visits the blog releases index", async ({ page }) => {
const response = await page.goto("/blog/releases");
expect(response?.ok()).toBeTruthy();
await expect(page.locator("body")).not.toBeEmpty();
});
});
+4
View File
@@ -1,4 +1,8 @@
defmodule Firehose do
use Boundary,
deps: [Firehose.Repo],
exports: :all
@moduledoc """
Firehose keeps the contexts that define your domain
and business logic.
+31
View File
@@ -2,6 +2,16 @@ defmodule Firehose.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
# `redact: true` below makes Ecto derive an `Inspect` impl for us. We write
# that impl out by hand instead (see the `defimpl` after the schema):
# Elixir 1.20.4 emits the derived code entirely on the `schema` line, and
# OTP 27's coverage instrumentation turns that single line into invalid
# Erlang ("variable '_2' unsafe in 'case'"), which aborts `mix test --cover`
# for the whole app. Output and redaction are identical, and the redaction is
# pinned by test/firehose/accounts_test.exs. Drop this and go back to the
# derive when the Elixir/OTP pins no longer reproduce the crash.
@derive_inspect_for_redacted_fields false
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
@@ -12,6 +22,27 @@ defmodule Firehose.Accounts.User do
timestamps(type: :utc_datetime)
end
defimpl Inspect do
alias Firehose.Accounts.User
# Same set as the `redact: true` field options above; keep them in sync.
@redacted_fields [:password, :hashed_password]
def inspect(%User{} = user, opts) do
infos =
for %{field: field} = info <- User.__info__(:struct),
field not in @redacted_fields,
do: info
Inspect.Any.inspect_as_struct(
user,
Macro.inspect_atom(:literal, User),
infos,
opts
)
end
end
@doc """
A user changeset for registering or changing the email.
+10
View File
@@ -3,6 +3,16 @@ defmodule Firehose.Application do
# for more information on OTP Applications
@moduledoc false
# Booting the app is the one job that must reach into the web tier (it starts
# FirehoseWeb.Telemetry and FirehoseWeb.Endpoint) while the web tier reaches
# back into the contexts. Declaring the Application a top-level boundary of
# its own is the layout boundary's README prescribes for `mix phx.new`
# projects, and it keeps the graph acyclic:
# Firehose.Application -> {Firehose, FirehoseWeb} -> Firehose -> Firehose.Repo.
# Putting FirehoseWeb in Firehose's deps instead is a cycle, which boundary
# rejects ("dependency cycle found: Firehose -> FirehoseWeb -> Firehose").
use Boundary, top_level?: true, deps: [Firehose, FirehoseWeb]
use Application
@impl true
+2
View File
@@ -1,4 +1,6 @@
defmodule Firehose.Repo do
use Boundary, top_level?: true, deps: [], exports: []
use Ecto.Repo,
otp_app: :firehose,
adapter: Ecto.Adapters.Postgres
+4
View File
@@ -1,4 +1,8 @@
defmodule FirehoseWeb do
use Boundary,
deps: [Firehose],
exports: [Endpoint, Telemetry]
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
@@ -303,6 +303,16 @@ defmodule FirehoseWeb.CoreComponents do
"""
end
# `table/1` and `list/1` below are part of the upstream Phoenix generator's
# core_components.ex and are kept available for when we need them — they are
# genuinely useful, not leftover scaffolding to be deleted.
#
# Nothing renders them yet: the collections that exist in the UI are hand-rolled
# instead (editor_dashboard_live.ex builds its drafts/scheduled rows out of
# bordered divs; Blogex.Components.post_index renders post previews and tag
# lists as articles/links). If you build a new data or admin screen, prefer
# these two over more bespoke markup — and if you retrofit an existing screen,
# drop the note here.
@doc """
Renders a table with generic styling.
@@ -366,6 +376,7 @@ defmodule FirehoseWeb.CoreComponents do
"""
end
# Upstream scaffold, unused in-app today — see the note above `table/1`.
@doc """
Renders a data list.
+4 -1
View File
@@ -84,7 +84,10 @@ defmodule FirehoseWeb.Layouts do
def theme_toggle(assigns) do
~H"""
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
<%!-- The knob follows the visitor's choice, not the resolved palette: in
system mode data-theme may be light or dark, but the system icon stays
selected. --%>
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme-choice=light]_&]:left-1/3 [[data-theme-choice=dark]_&]:left-2/3 transition-[left]" />
<button
class="flex p-2 cursor-pointer w-1/3"
@@ -49,8 +49,7 @@
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-4 text-orange-500" />
Engineering Blog
<.rss_icon class="size-4 text-orange-500" /> Engineering Blog
</a>
<a
href="/api/blog/releases/feed.xml"
@@ -58,8 +57,7 @@
target="_blank"
rel="noopener noreferrer"
>
<.rss_icon class="size-4 text-orange-500" />
Release Notes
<.rss_icon class="size-4 text-orange-500" /> Release Notes
</a>
</div>
</div>
@@ -14,20 +14,37 @@
</script>
<script>
(() => {
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
}
// Theme selection is always explicit: the document always carries a
// data-theme attribute, resolved here before first paint. An explicit
// choice therefore always wins in CSS, whatever the OS preference is;
// no stylesheet rule follows the OS on its own.
//
// data-theme-choice records what the visitor actually chose (unset,
// system, light, dark) and is what the toggle's knob follows: in
// "system" mode the palette may resolve to light or dark, but the
// system icon must stay selected.
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
const stored = () => localStorage.getItem("phx:theme");
const resolve = (theme) =>
theme === "system" ? (prefersDark.matches ? "dark" : "light") : theme;
const apply = (theme) => {
document.documentElement.setAttribute("data-theme", resolve(theme));
document.documentElement.setAttribute("data-theme-choice", theme);
};
const setTheme = (theme) => {
localStorage.setItem("phx:theme", theme);
apply(theme);
};
// Light is the default: no stored preference means light, not "follow
// the OS". "System" is an explicit choice and is stored as such.
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
apply(stored() || "light");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
// Follow the OS only while the visitor's recorded choice is "system".
prefersDark.addEventListener("change", () => {
if (stored() === "system") apply("system");
});
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "light"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})();
</script>
+2 -3
View File
@@ -14,9 +14,8 @@ defmodule FirehoseWeb.Router do
plug :fetch_current_scope_for_user
end
pipeline :api do
plug :accepts, ["json"]
end
# No :api pipeline: the JSON blog API is served by
# forward "/api/blog/...", Blogex.Router below, which sets its own content types.
scope "/", FirehoseWeb do
pipe_through :browser
+7 -1
View File
@@ -171,9 +171,15 @@ defmodule FirehoseWeb.UserAuth do
end
defp put_token_in_session(conn, token) do
put_session(conn, :user_token, token)
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, user_session_topic(token))
end
# Must match what Phoenix.LiveView.Socket.id/1 reads out of the socket's
# connect_info session, so a "disconnect" broadcast reaches open sockets.
defp user_session_topic(token), do: "users_sessions:#{Base.url_encode64(token)}"
@doc """
Plug for routes that require sudo mode.
"""
+182 -9
View File
@@ -10,7 +10,8 @@ defmodule Firehose.MixProject do
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
compilers: [:phoenix_live_view] ++ Mix.compilers(),
test_coverage: test_coverage(),
compilers: extra_compilers(Mix.env()) ++ [:phoenix_live_view] ++ Mix.compilers(),
listeners: [Phoenix.CodeReloader]
]
end
@@ -32,10 +33,25 @@ defmodule Firehose.MixProject do
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "lib_dev", "test/support"]
defp elixirc_paths(:dev), do: ["lib", "lib_dev"]
#
# `lib_dev/` is deliberately NOT compiled in any environment: it holds
# credo-only checks (Firehose.Checks.NoConnShadowing), and credo loads them
# itself from source via `requires:` in .credo.exs. Compiling that path as
# well gave every run two load paths for the same module, which surfaces as
# "warning: redefining module Firehose.Checks.NoConnShadowing" on a warm
# `_build/dev`. Keep the single source load: if you add code under lib_dev/
# that is NOT a credo check, give it its own compiled path instead.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# The boundary compiler must be listed *before* `:elixir`: it enforces by
# registering an `after_compiler(:elixir, ...)` callback, so a later position
# would look wired and report nothing. It stays out of `:prod` on boundary's
# own advice (a violation must not be able to fail a release build); `:dev`
# and `:test` are where code is written.
defp extra_compilers(env) when env in [:dev, :test], do: [:boundary]
defp extra_compilers(_), do: []
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
@@ -44,13 +60,20 @@ defmodule Firehose.MixProject do
{:bcrypt_elixir, "~> 3.0"},
{:phoenix, "~> 1.8.1"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
{:ecto_sql, "~> 3.14"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.1.0"},
{:phoenix_live_view, "~> 1.2"},
{:lazy_html, ">= 0.1.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:phoenix_live_dashboard, "~> 0.9.1"},
# LiveDashboard's Ecto Stats page loads the Postgres extras module at
# runtime, and declaring it also puts `postgrex` ahead of the dashboard
# in the dep graph. That edge matters: the dashboard references
# `Postgrex.Interval` in an `in` guard, and without it a parallel dep
# compile warns the module is "not available or yet to be defined"
# (upstream's `no_warn_undefined` list omits `Postgrex.Interval`).
{:ecto_psql_extras, "~> 0.8"},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
{:heroicons,
@@ -62,7 +85,7 @@ defmodule Firehose.MixProject do
depth: 1},
{:swoosh, "~> 1.16"},
{:gen_smtp, "~> 1.0"},
{:req, "~> 0.5"},
{:req, "~> 0.7"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.26"},
@@ -73,7 +96,15 @@ defmodule Firehose.MixProject do
{:microprints,
git: "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git",
branch: "main"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
# Compile-time boundary enforcement (Firehose / FirehoseWeb / Firehose.Repo).
# Declared directly even though `ex_crap` already pulls it in transitively:
# dropping a test-only metric tool must not silently take the boundary
# compiler with it.
{:boundary, "~> 0.11", runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false},
{:muex, "~> 0.12", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.16", only: [:dev, :test], runtime: false}
]
end
@@ -83,6 +114,14 @@ defmodule Firehose.MixProject do
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
#
# `mutate` / `mutate.fast` are a sensor, not a gate: they are deliberately
# absent from Makefile's VERIFY_SENSORS and always pass `--fail-at 0`, so a
# survivor is reported rather than fatal. The scope lives in the plain-text
# files beside this one; the aliases read them at run time, so the text file
# stays the single source of truth. Both are empty today because the app's
# candidate modules are only tested through the database-backed DataCase;
# an empty scope raises instead of silently running over nothing.
defp aliases do
[
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
@@ -96,7 +135,141 @@ defmodule Firehose.MixProject do
"esbuild firehose --minify",
"phx.digest"
],
precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"]
precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"],
mutate: &mutate/1,
"mutate.fast": &mutate_fast/1
]
end
# No macro/`use` mutators: the web layer (where the DSLs live) is out of
# scope, and an empty scope never reaches muex at all. Spellings are muex
# 0.12's CLI names.
@mutation_mutators "arithmetic,boolean,comparison,conditional,function_call,literal,return_value"
defp mutate(args), do: run_mutation("mutation_scope.txt", args)
defp mutate_fast(args), do: run_mutation("mutation_scope_fast.txt", args)
defp run_mutation(scope_file, args) do
Mix.Task.run("muex", mutation_args(scope_file, args))
end
@doc false
def mutation_args(scope_file, extra_args \\ []) do
files = read_mutation_scope(scope_file)
# `--fail-at 0` comes first so an ad-hoc override cannot turn the sensor
# into a gate. Extra args come before our defaults, and OptionParser keeps
# the first value, so `mix mutate --files lib/foo.ex` targets one file.
["--fail-at", "0"] ++
extra_args ++
["--files", Enum.join(files, ","), "--mutators", @mutation_mutators]
end
@doc false
def read_mutation_scope(scope_file) do
path = Path.expand(scope_file, Path.dirname(Mix.Project.project_file()))
files =
case File.read(path) do
{:ok, contents} ->
contents
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
{:error, reason} ->
Mix.raise(
"mutation scope #{scope_file} is unreadable (#{:file.format_error(reason)}); " <>
"refusing to run the mutation sensor over an unknown scope"
)
end
if files == [] do
Mix.raise(
"mutation scope #{scope_file} is empty; refusing to run the mutation sensor over nothing"
)
end
files
end
# The coverage floor the Lens pass enforces (`make verify` -> `make coverage`
# in app/). Rule: the threshold is a FLOOR. Raise it as behavior-test yaks
# land; never lower it. A drop means the change (or an exclusion) is wrong,
# not the floor.
#
# Measured 2026-10-29 (PostgreSQL on localhost:5432, exclusions below):
# 94.01% on `main` = 9c0f33e, with boundary enforcement, credo, the mutation
# scope and the core-components behavior tests all landed (88.19% after the
# web-controller/auth edge-case tests, 87.56% after the editorial-LiveView
# ones, 86.97% before them, and 86.79% / 86.17% at earlier exclusion
# revisions), identical via `mix test --cover` and via
# `mix test --cover --export-coverage default` + `mix test.coverage`. Floor is
# the integer below that — so a floor of 94 means "at least 94.00%".
#
# `test.coverage` deletes stale exports before sampling: `mix test.coverage`
# imports *every* .coverdata it finds, so leftover exports from earlier runs
# pool into the total and quietly inflate it.
#
# Excluded are modules the suite cannot or should not exercise; counting them
# would move the number without saying anything about app behavior:
#
# * framework glue generated by `use`:
# Firehose.Repo `use Ecto.Repo`
# Firehose.Mailer `use Swoosh.Mailer`
# FirehoseWeb.Telemetry Phoenix's telemetry supervisor + metric list
# FirehoseWeb.ErrorHTML Phoenix error renderers, both delegating to
# FirehoseWeb.ErrorJSON Phoenix.Controller.status_message_from_template/1
# * FirehoseWeb the `use FirehoseWeb` macro: imports and aliases
# only, nothing that executes
# * embed_templates wrappers FirehoseWeb.BlogHTML,
# FirehoseWeb.UserRegistrationHTML,
# FirehoseWeb.UserSettingsHTML: each is `use` +
# `embed_templates`, so every function in them is
# generated by the macro. Their TEMPLATES are
# exercised through the controller tests; the
# wrapper modules hold no hand-written code.
# NOTE: PageHTML and UserSessionHTML are
# deliberately NOT excluded — both carry private
# helpers with real logic (blog_label,
# post_base_path, local_mail_adapter?), so their
# gaps are behavior gaps, not macro noise.
# * Firehose.Application start/2 is proven by the suite booting at all (a
# broken supervision tree fails every test), and
# config_change/3 only runs on a release hot
# upgrade, which no test can reach.
# * Firehose.Release production release tasks (`mix ecto.migrate` on
# Dokku); the suite never runs them
# * test scaffolding Firehose.DataCase, FirehoseWeb.ConnCase,
# Firehose.AccountsFixtures, Firehose.Test.FakeBlog:
# fixtures measure the tests, not the app
#
# `Firehose.Checks.NoConnShadowing` needs no entry: lib_dev/ is no longer in
# elixirc_paths (credo loads it from source), so it is not measurable here.
#
# Everything else counts — CoreComponents, controllers, LiveViews included:
# that is where the behavior-test work shows up as a higher floor.
defp test_coverage do
[
summary: [threshold: 94],
ignore_modules: [
Firehose.Repo,
Firehose.Mailer,
Firehose.Release,
Firehose.Application,
FirehoseWeb,
FirehoseWeb.Telemetry,
FirehoseWeb.ErrorHTML,
FirehoseWeb.ErrorJSON,
FirehoseWeb.BlogHTML,
FirehoseWeb.UserRegistrationHTML,
FirehoseWeb.UserSettingsHTML,
Firehose.DataCase,
FirehoseWeb.ConnCase,
Firehose.AccountsFixtures,
Firehose.Test.FakeBlog
]
]
end
end
+45 -38
View File
@@ -1,62 +1,69 @@
%{
"bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
"bandit": {:hex, :bandit, "1.13.1", "1ce25a7beb750ed3fa2e905962f1f9a679cb585f1da0ae45637d06e385ce2eae", [:mix], [{:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.6", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "39028f0b5a21404661410a6c19275060a9822c9dcca70b7c3e4e4fa50b11da58"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"boundary": {:hex, :boundary, "0.11.0", "56cff565b5565930765e02782281b022935fe8c2e1504543c8c1b44b85893b40", [:mix], [], "hexpm", "b9297a96ea14c0e63dbd77b8135e0d483f9c6f34ed1c9dee4b3fea82c596f55a"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"},
"db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"},
"decimal": {:hex, :decimal, "3.2.0", "f1235f05d841a211fda78a08fdb9777f2ff51a71f18455233fae186ff76187c6", [:mix], [], "hexpm", "190bca80580ce61aa69bb0eacadd7677710205c770225c2c2c07e32a5c31b234"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"},
"ecto_psql_extras": {:hex, :ecto_psql_extras, "0.8.8", "aa02529c97f69aed5722899f5dc6360128735a92dd169f23c5d50b1f7fdede08", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "> 0.16.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "04c63d92b141723ad6fed2e60a4b461ca00b3594d16df47bbc48f1f4534f2c49"},
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
"elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"},
"esbuild": {:hex, :esbuild, "0.10.1", "32b44b468d2c6386b6bf9229617cbf51be025ce4380eacd383a8979cbb8327ef", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "890f251330052dcacafa40c409769e7c42e3876fb0ae450707bde831be7b34ea"},
"ex_crap": {:hex, :ex_crap, "0.1.1", "c1770e7ce1843f304a27f5d32bfa867a340140e337684ee1929c770b2499f884", [:mix], [{:boundary, "~> 0.10", [hex: :boundary, repo: "hexpm", optional: false]}], "hexpm", "ada5d59bce442c1a8a987cb5328cbf4469fb56eb8d04641886f2199569a75b12"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"finch": {:hex, :finch, "0.24.0", "4022b6194e907b6d9b597c168001cac38e488a35fe36cef9d8297e3159782510", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "33ba40069c3587c2f99f9125b766e19dad87d6d54be3c6961db2304df04cef00"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"},
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
"makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"},
"hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"},
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"lazy_html": {:hex, :lazy_html, "0.1.13", "860ea816f5bc7936d1ab5ccebc388f9d1031ba9c35ed81a4db9a1c3a50737704", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "9a8405d6785fe6f8423b86e0ec5f21806ef79941fe853eac3d14fbbb173c34e9"},
"makeup": {:hex, :makeup, "1.2.3", "c3ef0ff83acf305384acba0f70bff87d8732502baa1eb3eb081d8c154fbe18f5", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "15970751b7021c2bcd7ed56a54b3def23affd3969fb7c214dffdf28e97ce6f76"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
"mdex_native": {:hex, :mdex_native, "0.2.4", "a74b9d799d3fc487b1f97b70ee9b04108c7af0514920d56828e173d19312c2a9", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d71185c43c5209c6939f4b65f903f71c892512c93ea6843171227855d2d976f5"},
"microprints": {:git, "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git", "29ef59ff6eb41853b6f91872d8fffdfba4d85a62", [branch: "main"]},
"makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"},
"mdex_native": {:hex, :mdex_native, "0.2.12", "867912ab5675e0a315e44ea5eb9ec22ac09c12559cc89fc1ba71afd7d95bf96a", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "c9557b4e576515b01edc5bc48f6ac0a6e13c25a0bd3bf4a6e0a2ccd2199d747b"},
"microprints": {:git, "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git", "f2a361555310518870ad3c093050169f30ff870c", [branch: "main"]},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"},
"muex": {:hex, :muex, "0.12.0", "80d8f1b2f7bfcba8ecb46b7b43dce55b555d33da54b84e74feedc4079fa2bd36", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a4b395b87d23b8d34f4508d0c13494b734f1fff8156eca2360fd279a0799d52e"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nimble_publisher": {:hex, :nimble_publisher, "2.0.0", "f4fc100949ccdf1831094ff303b79f27c6f04b2ead829409426ee0d9ed3aa55b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:mdex_native, "~> 0.1", [hex: :mdex_native, repo: "hexpm", optional: false]}], "hexpm", "88997f74b763c24a15d20a90d7cd4ef1fd01172a2b4651e54d8021a44659f2c8"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"nimble_publisher": {:hex, :nimble_publisher, "2.1.0", "8cda388aecfcaf3f2b2ed1c26a609c1e1266f73fe1fda0fd277a9f61007dd274", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:mdex_native, "~> 0.1", [hex: :mdex_native, repo: "hexpm", optional: false]}], "hexpm", "f5cc1b32a3705f37afeade44e21d750de74979230f7b892f9790fc02487e6e10"},
"phoenix": {:hex, :phoenix, "1.8.16", "6a4742054740496bfabefc95253f978d8f595f07bb601039b7a18b384a873916", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.4", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "e7355c35889305bc539eb09dae2393abcbaf0f65f6874a3f0dcb5f0ec53737fe"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.9.1", "d4786b24bbe1eacc431049dbbb8d377f5359fce57b9d7cdd773bba4ac0af2ef6", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "de25e1a28cfb64413d8bdaf3ba5b64da1a19e0ed44a02320119523cc45da051d"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.2.13", "850284c529f013e82d9596325bd92cb5859c0872176616b6e2ec4294e2ef616e", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a5cd2a3df10552bce6b7e68e5d376ff3b46cca3ca62d6c43bd4398d80c197fbe"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.4.1", "2ff3e7ba8b432120989870997b9adaefb76e1f63805554ab0b9e948987981f4b", [:mix], [], "hexpm", "352fba7a87bf61395b6e97da66944ad9b1bfe5459e5981039f60d68270bd9700"},
"phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"},
"plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},
"plug_crypto": {:hex, :plug_crypto, "2.2.1", "bd1d0358a2e2f5ffb818217e64c394f9d24ed191d862b7fa1bcce264ace2b8ea", [:mix], [], "hexpm", "4417ccbd8f85e627321cd07d2ab9c4b621f1fd883b9905af241f9b4b780b94b6"},
"postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"},
"ranch": {:hex, :ranch, "2.3.0", "7de7b041a9a6a5091a3aa5898d66c0564be671d87db4f9d63b1b5ee775b097df", [:make, :rebar3], [], "hexpm", "6168ec49409d982f7cfbd83dd083144f6cbe67caa4036551d2f0a3ad67c9d023"},
"req": {:hex, :req, "0.7.5", "0c3293cbf7717aba20b1b1b3ea13fe8d3fdf6f1557490f390c3f1ef6dd9db955", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "576853c4bf403faa3c1fa5d093e0759227083b78f8a6864c67d62c0a9c45514d"},
"rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"swoosh": {:hex, :swoosh, "1.23.0", "a1b7f41705357ffb06457d177e734bf378022901ce53889a68bcc59d10a23c27", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "97aaf04481ce8a351e2d15a3907778bdf3b1ea071cfff3eb8728b65943c77f6d"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.10.0", "8f07ff0865cb09a6a0f75e11701e9a46e331e05f737da4e5b01374dcd25b68c0", [:mix], [{:rustler, "~> 0.28", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c68c9dc732c12acfdf4a7fa06f4e6f48a641179e6be49a474c8a60809929c430"},
"sobelow": {:hex, :sobelow, "0.17.0", "57436898f5b5eac05244ddb301927b1e6521d7de5df3023929d0fe2818d11055", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "02914e7cdc5bd9bdcb1a2be7f08d793fd765f8c238c924f29857d3996d435928"},
"swoosh": {:hex, :swoosh, "1.29.0", "b938791267d8a55415344018aa0421d214f9bc36c93cbe27305d3284a853785c", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d23ea419c6c67604628c0e93a8d5c6d13bcc442137870671e1b05325b2956a13"},
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.2.0", "7632c19c01d88d8aaca5da1a0e8912f5af39b79e7c08a2c253aeb3c14c2c957e", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "71dde12fc29b58b9c77ec17ec319109e5ca848d010fc1965ed4463bba1837c07"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"thousand_island": {:hex, :thousand_island, "1.6.0", "d6ad7c2220c96a270cc8ca1586ec4d8bde648a4a3a53f003ce093f41beec0288", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4d4720470f091fc21a1311777c2ac66916c43621bffc068590c9f9bb3cd057a3"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
}
View File
View File
+139
View File
@@ -0,0 +1,139 @@
{
"extends": [
"default"
],
"meta": {
"name": "playwright",
"version": "",
"description": "Playwright e2e testing — browser binaries on host, connects to localhost Phoenix server",
"author": null
},
"security": {
"signal_mode": null,
"process_info_mode": null,
"ipc_mode": null,
"capability_elevation": null,
"wsl2_proxy_policy": null
},
"groups": {
"include": [
"deny_credentials"
],
"exclude": []
},
"commands": {
"allow": [],
"deny": []
},
"filesystem": {
"allow": [
"$HOME/Library/Caches/ms-playwright",
"$HOME/.local/share/mise/",
"~/Library/Caches/mise/clang/22.1.7",
"~/Library/Caches/mise/dotnet/8.0.300",
"~/Library/Caches/mise/elixir/1.14.3",
"~/Library/Caches/mise/elixir/1.15.8",
"~/Library/Caches/mise/elixir/1.16.0-otp-26",
"~/Library/Caches/mise/elixir/1.16.1-otp-26",
"~/Library/Caches/mise/elixir/1.17-otp-26",
"~/Library/Caches/mise/elixir/1.17.3-otp-27",
"~/Library/Caches/mise/elixir/1.18-otp-27",
"~/Library/Caches/mise/elixir/1.18.3",
"~/Library/Caches/mise/elixir/1.18.3-otp-27",
"~/Library/Caches/mise/elixir/1.19.5-otp-28",
"~/Library/Caches/mise/erlang/25.2.1",
"~/Library/Caches/mise/erlang/26.2.1",
"~/Library/Caches/mise/php"
],
"read": [
"~/.cargo/bin",
"~/.config",
"~/Library/Caches/mise/elixir"
],
"write": [],
"allow_file": [
"~/Library/Caches/mise/clang/22.1.7/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/dotnet/8.0.300/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/elixir/1.14.3/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/elixir/1.15.8/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/elixir/1.16.0-otp-26/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/elixir/1.16.1-otp-26/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/elixir/1.17-otp-26/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/elixir/1.17.3-otp-27/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/elixir/1.18-otp-27/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/elixir/1.18.3-otp-27/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/elixir/1.18.3/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/elixir/1.19.5-otp-28/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/elixir/idiomatic_filenames-67c49.msgpack.z",
"~/Library/Caches/mise/erlang/25.2.1/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/erlang/26.2.1/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/erlang/26.2.2/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/erlang/27.3.3/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/erlang/27.3.4/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/erlang/28.4.2/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/erlang/28.5/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/ghc/8.10.7/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/php/8.4.0RC1/list_bin_paths-67c49.msgpack.z",
"~/Library/Caches/mise/php/remote_versions-67c49.msgpack.z"
],
"read_file": [
"$HOME/.gitconfig",
"~/.config/mise/config.toml",
"~/dev/elixir/firehose/mise.toml"
],
"write_file": [],
"unix_socket": [],
"unix_socket_bind": [],
"unix_socket_dir": [],
"unix_socket_dir_bind": [],
"unix_socket_subtree": [],
"unix_socket_subtree_bind": [],
"deny": [],
"bypass_protection": [],
"suppress_save_prompt": []
},
"network": {
"block": false,
"allow_http2": false,
"allow_domain": [],
"open_port": [],
"listen_port": [],
"connect_port": [],
"custom_credentials": {},
"tls_intercept": null,
"upstream_proxy": null,
"upstream_bypass": []
},
"diagnostics": {
"suppress_system_services": []
},
"linux": {
"af_unix_mediation": null,
"sandbox_policy": null
},
"env_credentials": {},
"environment": null,
"command_policies": null,
"credential_capture": {},
"credential_providers": {},
"credential_routes": [],
"workdir": {
"access": "readwrite"
},
"hooks": {},
"session_hooks": {},
"rollback": {
"exclude_patterns": [],
"exclude_globs": []
},
"open_urls": null,
"allow_launch_services": null,
"allow_gpu": null,
"allow_parent_of_protected": null,
"interactive": false,
"skipdirs": [],
"packs": [],
"binary": null,
"command_args": [],
"unsafe_macos_seatbelt_rules": []
}
+18
View File
@@ -0,0 +1,18 @@
{
"meta": {
"name": "playwright",
"description": "Playwright e2e testing — browser binaries on host, connects to localhost Phoenix server"
},
"extends": "default",
"groups": {
"include": ["deny_credentials"]
},
"workdir": {
"access": "readwrite"
},
"filesystem": {
"allow": [
"$HOME/Library/Caches/ms-playwright"
]
}
}
@@ -1,9 +0,0 @@
%{
title: "Hello World",
author: "Firehose Team",
published: false,
tags: ~w(elixir phoenix),
description: "Our first engineering blog post"
}
---
Welcome to the Firehose engineering blog! We'll be sharing deep dives into our tech stack here.
@@ -269,7 +269,8 @@ Further reading
- [How to get started with the Pi coding agent (on a VPS)](/blog/engineering/how-to-get-started-with-the-pi-coding-agent-on-a-vps) — Setting up Pi on a VPS is easier than I thought.
- [Smaller open LLMs now work for open agents](/blog/engineering/smaller-open-llms-now-work-for-open-agents) — A phase shift in quality and speed of open weight models and inference.
- Nate B Jones had a good podcast / video this week on Apples' play with local models for e.g. legal offices who can not get their work certified if their data leaves the office, no matter how encrypted it is. [Nate B Jones on Apple and the next trillion dollars](https://podcasts.apple.com/gb/podcast/ai-news-strategy-daily-with-nate-b-jones/id1877109372?i=1000763732500)0
- Nate B Jones had a good podcast / video this week on Apples' play with local models for e.g. legal offices who can not get their work certified if their data leaves the office, no matter how encrypted it is. [Nate B Jones on Apple and the next trillion dollars](https://podcasts.apple.com/gb/podcast/ai-news-strategy-daily-with-nate-b-jones/id1877109372?i=1000763732500)
- If you want to try this out, at least one reader had success with the [Will it Blend?](/blog/engineering/will-it-blend) approach. Point your existing coding agent with hosted models at this blog post, and ask it to set Pi and Qwen up on your machine. Best let it ask you some questions on what you want to do, and look into your hardware. This is different for mac or for Linux (I would avoid Windows for this, if possible). NVIDIA is easier than AMD for GPU. I do have this working on AMD, with [Lemonade Server](https://lemonade-server.ai), but I do not know if agents will find that by themselves.
Full disclosure: I am long APPL, NVDA and BABA (AliBaba, makers of QWEN). I use other hardware and models too and don't have much Nvidia hardware.
@@ -282,7 +283,7 @@ What was remarkable to me in the Field Report above is that the writer chose Ope
> Why don't I just use [..] Claude Code? I had problems due to a lack of optimization for small context windows. Long-running tasks that complete large projects independently matter for me, so no Claude Code.
So maybe my intuition is right. I am not doing long running tasks like I was doing with Cladue Code _yet_ with Pi and Qwen. This shows it is possible, and might even be better. Detailed prompts and deterministic harnesses make the difference between frontier models and harnesses smaller. More about that maybe later. I hope this helps you, let me know if you have any questions or remarks.
So maybe my intuition is right. I am not doing long running tasks like I was doing with Claude Code _yet_ with Pi and Qwen. This shows it is possible, and might even be better. Detailed prompts and deterministic harnesses make the difference between frontier models and harnesses smaller. More about that maybe later. I hope this helps you, let me know if you have any questions or remarks.
Acknowledgements
----
@@ -0,0 +1,18 @@
%{
title: "Decide the size of your step, 2026 edition",
author: "Willem van den Ende",
tags: ~w(AI coding-agents practice),
description: "When introducing TDD I always say you have to be able to move forward in really small steps, even when you take bigger steps. That way you always have something to fall back on.",
published: false
}
---
This post has been typed by hand, and describes my current way of dealing with taking much larger steps using coding agents. I enjoy building quickly, seeing usable software take shape. I can do that fairly safely using [synthetic TDD](/blog/engineering/synthetic-tdd), but have found that the resulting system is not always as coherent as I would like it to be. Look carefully in the screenshots in [the scheduled posts release notes](/blog/releases/scheduled-publishing)
and you will notice that there is a _Dashboard_ showing scheduled posts, but there is no navigation to Dashboard. Claude Code had not needed it to produce the demo. A coding agent can remember many more URLs than I can, and it can find them if the navigation is not there. A similar thing happened in the [Federated Business Intelligence App](/blog/engineering/a-thin-slice-for-federated-collaboration). No navigation links, but a slick demo.
WeReview 2 was one of the first things I did mostly hands-off, over a year ago. I had pointed Claude Code at the source for [WeReview 1](https://wereviewhq.com/), asked it to analyse what was there. I did mention my overall goals, broke down the work in parts, and was pleasantly surprised that I got most of WeReview 1 plus a fix for some UX annoyances in about five days of calendar time. It was half-term and I was also doing things with my family. This was one of the cases where I experienced AI whiplash.
When introducing TDD I always say you have to be able to move forward in really small steps, even when you take bigger steps. That way you always have something to fall back on.,
I have been mentioning this recently in QWAN's AI with TDD workshop, as well as in a keynote I developed for Philips.
@@ -1,7 +1,7 @@
%{
title: "Synthetic TDD",
author: "Willem van den Ende",
tags: ~w(Vocabulary AI TDD),
tags: ~w(Vocabulary AI TDD Synthetic-TDD),
description: "Synthetic Test Driven Development is when you have a coding agent generate the tests and the code, and maybe also do the refactor step with an agent.",
published: true
}
@@ -23,7 +23,7 @@ As mentioned above, it is not something I do often. When working in client proje
Making two pull requests in one go
---
So how do I make two pull requests out of it? I ask [my assistant](/blog/engineering/tag/Pi.dev), learning something in the process:
So how do I make two pull requests out of it? I ask [my assistant](/blog/engineering/tag/pi.dev), learning something in the process:
> Now I have two commits. what if I wanted to make a Pull Request on github for each commit separately?
@@ -0,0 +1,48 @@
%{
title: "Improve Diagnostic in the TDD cycle",
author: "Willem van den Ende",
tags: ~w(TDD AI),
description: "When we do TDD in baby steps, we review the diagnostic when we have written the failing test at the start of the cycle. We do our best to make the failure message understandable to our future selves and others. Does this work with Synthetic TDD? If not, what do we do about it?",
published: false
}
---
I am bringing a vibe-TDDed project started a year ago to production. It has 998 tests at the moment (well, 999, I just added one, which led me down a rabbit hole).
When we do TDD in baby steps, we review the diagnostic when we have written the failing test at the start of the cycle. We do our best to make the failure message understandable to our future selves and others. Does this work with Synthetic TDD? If not what do we do about it?,
From hands-off to hand-on, one test at a time
----
I had 998 tests added by a variety of agents. The 999th one, I added by hand. Inspired by Matteo Vaccari, I started refactoring one of the end to end tests towards an internal DSL, with output (steps and dialogs in a workflow) that can work as a conversation starter between me and users, as well as keeping me on track in the myriad of states that go in to seemingly simple workflows.
I quite like the semi-formality that Allium gives me to determine states. I had used it in this project to help me create dynamic forms with Claude Code. I had made various spikes of Dynamic Forms before, but it gets complicated quickly, especially when you want to show some user defined field conditionally based on another user defined fields' value. Allium, a coding agent, tests and iteration got me over the line. Re-reading the Allium spec, we have indeed a small, almost programming language to define and validate forms.
What does failure feel like?
----
One thing that I had not done much in this system is see tests fail and try to understand what the output says. There are a fair number of integration tests, close to the UI. Phoenix Liveview comes with decent support for writing tests, but if the tests' expectation is to find some words in a web page, and the test fails, I got a wall of text and escape characters in return.
The feedback from the failing test I wrote looked like this (copy pasted from the passing test that would fail in a similar non-intuitive way):
![Screenshot of ascii gibberish: escaped html with many 'quot' and css etc. plus a line number of the failure](/static/images/blog/2026/Screenshot 2026-07-20 at 14.06.13.png)
Here I was trying to establish that an error message was present. The smallest step was asserting that success was absent, and then iterating to find a better diagnostic.
Especially for integration tests it matters when and where a failure is triggered.
Matteo Vaccari's talk reminded me of Ward Cunningham's swim system - so besides the cryptic assertion failures I have screenshots in a html page, one step at a time. I am working in small steps, building just what I need. I still do like to have a precise assertion failure, so I get fast feedback right where the test is. I can then refer to the screenshot to see what it looks like.
It tunrs out that getting both the feedback I want in the printed dialog, and the expectation needed a fair amount of refactoring, and I did some learning on phoenix liveview testing.
Backstory - what brought us here
---
WeReview 2, or at least the initial architectural spike for it, was one of those projects that gave me AI whiplash last year. WeReview 0 and 1 have been running since 2016, and 1 is in a kind of local optimum. It just runs in production with close to zero effort, but is not extensible in the directions I want. So a bit over a year ago I spent 5 days with claude code, mostly hands-off to do 80 percent of a rewrite, with a number of quality of life improvements for me, conference organisers, and reviewers. For proposers (people who propose a session) I also want to improve feedback, and that is more or less where I am now, trying to bring WeReview 2 to production.
Further reading
===
My [Enabling a local model to explain images](https://willemvandenende.com/blog/engineering/enabling-a-local-model-to-explain-images-in-pidev) has the sketch by Jon Jagger, naming the "Improve the diagnostic" action as a transition form Red to Red in a TDD State diagram.
@@ -11,7 +11,7 @@ TLDR;
----
Every application has some kind of workflow. Understanding larger workflows, making them cohesive and understandable to users and developers is non-trivial. I re-discovered Ward Cunningham's Swim the Test approach, this is the Further Reading section of future posts.
[Jon Uddell's 2008 post about Ward Cunninghams' implementation of Brian Marick's 'Visible Workings'] https://blog.jonudell.net/2008/03/04/ward-cunninghams-visible-workings/ is a succinct introduction . connecting the Visible Workings pattern to the implementation made for a workflow system on behalf of the Eclipse Foundation.
[Jon Uddell's 2008 post about Ward Cunninghams' implementation of Brian Marick's 'Visible Workings'](https://blog.jonudell.net/2008/03/04/ward-cunninghams-visible-workings/) is a succinct introduction . connecting the Visible Workings pattern to the implementation made for a workflow system on behalf of the Eclipse Foundation.
[The "Swim" System for User-Oriented Presentation for Test-Case results](https://wiki.eclipse.org/images/2/20/Swim_System_PNSQC_2007_Paper.pdf). Plain spoken Paper by Ward Cunningham, Bjorn Freeman-Benson and Karl Matthias.
@@ -1,7 +1,7 @@
%{
title: "Learning to Swim test",
author: "Willem van den Ende",
tags: ~w(ATDD ComprehensionMaxxing CollaborationMaxxing SyntheticTDD Refactoring),
tags: ~w(ATDD ComprehensionMaxxing CollaborationMaxxing Synthetic-TDD Refactoring),
description: "The faster you can build, the more pressing communication with others and understanding together becomes. You can build software to promote understanding and collaboration, but where do you start? I found a foothold in the 'swim tests' paper and existing integration tests.",
published: false
}
@@ -102,7 +102,7 @@ I had an integration test that could serve as a starting point. But the workflow
5. Add more steps
6. Go to 4.
For 1. I found a forum post, and I already had some [Synthetic](/blog/engineering/2026/07-13-synthetic-tdd.md) tests. This was also a good opportunity to re-read [the documentation](https://phoenix-live-view.hexdocs.pm/Phoenix.LiveViewTest.html). Rendering pages, components, selecting elements and getting the text back is all built in, so all we need to do is wrap it in a page and save the parts. I did consider writing the reports out as markdown, with html snippets at some point. When rendering html to a pdf, the page breaks happen in the middle of screenshots sometimes. But the PDF already lacks the styling.
For 1. I found a forum post, and I already had some [Synthetic](/blog/engineering/synthetic-tdd) tests. This was also a good opportunity to re-read [the documentation](https://phoenix-live-view.hexdocs.pm/Phoenix.LiveViewTest.html). Rendering pages, components, selecting elements and getting the text back is all built in, so all we need to do is wrap it in a page and save the parts. I did consider writing the reports out as markdown, with html snippets at some point. When rendering html to a pdf, the page breaks happen in the middle of screenshots sometimes. But the PDF already lacks the styling.
I initially iterated with [Pi](/blog/engineering/tag/pi.dev) on how to collect tests. I had a fancy idea of collecting the various dialogs in a process (well supported e.g. by Elixir GenServers), then thought of doing it the unix way (write out dialogs, than `cat` them all together), and ended up collecting step outputs in a list, and rendering the list at the end. This did require re-ordering the test a bit: the `assert` has to come at the end, after creating the report.
@@ -0,0 +1,73 @@
%{
title: "Disposable End to End tests",
author: "Willem van den Ende",
tags: ~w(),
description: "",
published: false
}
---
I quite like playwright for the simplicity of writing End to End tests through the browser. Last year I realised that a coding agent (Claude Code, but Pi and local models can handle browsers as well) makes it cheap to write tests. Initially I didn't like the tests produced, but re-writing one by hand, maybe with some assistance, deleting ones that were superflous and then having the others rewritten in the style of the golden example was pretty painless.
Keeping them around does come at a cost, though.
1. Don't let people do what computers can do (unless it is the better way to learn for the people)
2. Don't let an LLM do what a simpler machine learning system or symbolic AI can do.ssh://git@gitea.apps.sustainabledelivery.com:3022/QWAN/dirtytests_cs.git
3. Don't let any AI do what deterministic code does better.
4. Make work fun and engaging for people
3. Tends to be faster and more energy efficient than 2. With people you never know, they can be very efficient and circumvent problems instead of attacking them head on.
In the end Pi could not access the browser through my sandbox, I have some configuration updates to make. But my 27B model used curl, found how to use the CSRF token in the page and made it through.
Handover message from the tool
----
> All tests pass, clean compile. The script is ready at scripts/deploy-and-test-admin.sh.
> Usage:
```bash
./scripts/deploy-and-test-admin.sh --password 'MyP@ss123!'
```
> What it does:
1. ssh dokku — sets ADMIN_EMAIL / ADMIN_PASSWORD on the Dokku app
2. git push dokku — deploys (migrations run, admin is provisioned)
3. Waits for the app to be ready (polls /users/log_in)
4. Logs in as admin via curl, verifies redirect to dashboard
5. Checks /admin/tenants is accessible as super admin
> Options:
- --password — admin password (prompts if omitted)
- --app-url — override the app URL (auto-resolved from git remote)
- --app — override Dokku app name (default: wereview2)
- --skip-deploy — skip the git push, just test login
Final test run
----
The first test run failed, my coding agent had tried to do something clever, reviewing the script: derive the url from the git remote of the repository. 27B clearly has been trained on dokku, and remembered it well. That does not work yet, as I had not set up the port mapping. Since I have time on desk now, I decided to do the riskiest part first, the rest is following my notes on previous deployments (yes, I remain, as ever, optimistic when deploying new applications).
``` bash
export APP_URL="http://wereview2.apps.sustainabledelivery.com:5000/"
willem@sequential:~/dev/spikes/elixir/wereview-2$ ./scripts/deploy-and-test-admin.sh
=== Super Admin Deploy & Test ===
Email: <email>
URL: http://someurl.apps.sustainabledelivery.com:5000/
Dokku: someurl.apps.sustainabledelivery.com
→ Setting admin credentials on Dokku...
→ Deploying (git push dokku)...
Everything up-to-date
→ Deploy complete.
→ Waiting for app to be ready...
→ App is ready.
→ Logging in as <email>
```
And then logging in, it failed. I had made the code to clever. Or rather, learnt something about which code is and is not available in the production deployment.
@@ -1,7 +1,7 @@
%{
title: "Retort",
author: "Willem van den Ende",
tags: ~w(Synthetic TDD pi.dev coding-agents evals ),
tags: ~w(Synthetic-TDD pi.dev coding-agents evals ),
description: "At first I was bemused by Retort, Adrian Cockroft's whole stack coding agent evaluation tool. It is growing on me, and might be useful as a starting point for custom evals.",
published: true
}
@@ -0,0 +1,50 @@
%{
title: "Will It Blend?",
author: "Willem van den Ende",
tags: ~w(AI pi.dev ComprehensionMaxxing),
description: "Will it Blend was a series of videos promoting a blender brand, in which various unlikely objects were put in a blender to see if they would blend.",
image: "/images/blog/2026/will-it-blend.svg",
published: true
}
---
**Problem** You find something interesting, but don't completely understand it, or are not sure where to start.
**Solution** Point your coding agent at the thing you read (a blog post, research paper, product announcement) and build something so that you can play with it.
Will it Blend was [a series of infomercials](https://en.wikipedia.org/wiki/Will_It_Blend%3F) promoting a blender brand, in which various unlikely objects, such as tablets, were put in a blender to see if they would blend.
![will-it-blend](/images/blog/2026/will-it-blend.svg)
I like it as a _metaphor_ for trying to get something to work that you have just come across. I often use it when reading about the new hot AI thing of the week (say decision models a couple of weeks ago). I&rsquo;ll go and look for an open source implementation and have a play with it, asking my coding agent for help installing it and coming up with a micro project I can do with it.
Worst case I lost half an hour to an hour, and put the idea back on the backburner. But more often than not, I get something out of it that I can take forward: working software and better, more grounded, understanding of a topic.
## Examples of when I used this
I use this all the time now. And so do other people, it is not that original. I want to encourage you to see if things blend too, only takes half an hour or so.
### Decision models
For instance, a day after [Jev](https://en.wikipedia.org/wiki/Jev_(AI_model))came out, Laya popped up on Reddit. I cloned the repository, opened Pi and asked some questions about what you could do with it. Then I had a look at the example code on the Laya page, used my coding agent to run it, had a chat about how I could use it in a coding agent, and had the agent make some sample questions with sample data. I also wondered why it was taking so long to start, got the coding agent to reduce startup by 40 times (28 seconds to about 0.6 seconds or so), and I learnt in the process that this was a BERT model with some pre-training. I had seen BERT&rsquo;s but not played with one in the wild before. This enabled me to write some comments on LinkedIn, and learn, indirectly, about my mistakes :-). Not bad for an hour or two on a sunday afternoon.
### Getting started with a local model in an open coding agent
Another example would be John Nolan , who put [my post on a local coding agent on Mac OS](/blog/engineering/my-local-agentic-dev-setup-today) through his coding agent to get Pi + local llms set up on his machine. I liked this, because this is also how I got started with making my own coding agent, and later installing Pi. With a coding agent in hand, and some vigilance or sandboxing, this is not much time well spent.
## Background
I learnt this in the spring of 2025 through Julia Turc, probably from her video on [Mixture of Experts: How LLMs Are Getting Smarter Without Getting Slower](https://www.youtube.com/watch?v=7yR5ScbK1qk). In this video, Julia takes a 1991 research paper on mixture of experts, pulls it through ChatGPT (this is several hundred AI summer years ago) and creates an executable Collab Notebook out of it.
I wrote a comment at the time:
> +1. I was wondering if llms would make it easier to implement papers (something I haven&rsquo;t done in a while), and later that day, youtube surfaced this video with a live demo. I came for the mixture of experts explanation and stayed for the implementation and paper reading. It is insightful to see what you look at in a paper.
I was primed for this by [Katherine Breslin](https://www.catherinebreslin.co.uk/)&rsquo;s talk at Agile Manchester 2025 (spring, this is important in AI summer years), in which she mentioned making a voice assistant in an afternoon on her own, something which had taken the Alexa team (on which she worked) 150+ people and multiple years.
## Your turn
Do you have research papers, blog posts or videos that leave you wondering? Wonder no more. These days I often start with putting my question in a (gemini) notebook, getting a summary, finding more sources, and asking questions. Then I take a summary document to my coding agent, refer to that in an initial prompt and then it usually works. I create a new git repository for every project, making small commits as I go. Often [with tests](/blog/engineering/synthetic-tdd): they make it easy to get started, express expectations, and keep going if the experiment is a success.
If you want specifics on what I use now: I use [Pi](/blog/engineering/tag/pi.dev) as coding agent. Models that I can run on a 64GB mac (e.g. Qwen 3.8 27B or 3.6 35B) are usually plenty. When I want it faster, or with less fan noise, I use a hosted GLM 5.3 Flash.
I hope you find this useful and will see if something you are curious about will Blend. Happy blending!
@@ -0,0 +1,96 @@
%{
title: "Video Transcriber - turn your videos into text with slides",
author: "Willem van den Ende",
tags: ~w(writing local-models open-weights AI agentic-engineering speech-to-text),
description: "In which the author uses a local text-to-speech model to draft blog posts, starting from talking while sketch noting.",
image: nil,
published: true
}
---
[Romily Cocking](https://github.com/romilly) made [Video Transcriber](https://github.com/romilly/video-transcriber), an open source tool that takes a video, transcribes the audio to text in a markdown file, and clips images every n seconds. It uses local models, so is great for transcribing private presentations and narrated sketch notes.
I attempted to write about Video Transcriber when I first used it nine months ago. But for me, improvisation and explanation don't mix very often, in the moment. I picked it back up to help me draft blog posts, starting from me talking with a virtual flip chart in hand. I am writing all my words organically again. Some typed, some translated from handwriting or voice notes and now images and words from a video.
Instead of using AI to write my words, I am using AI to build me small tools to get my words out. These tools may or may not contain AI themselves. In today's episode, I used a Speech-to-Text model together with a deterministic [computer vision library](https://opencv.org/). I only want to record an image (a 'slide' in the domain of Video Transcriber) whenever I stop dragging the canvas around, or stop drawing for a bit. The vision library allows deterministic code to do that. It can be used instead of the existing functionality in Video Transcriber that creates a slide every 15 or 30 seconds.
A regular pace does not seem to be a good fit for when I am sketching. I either sketch a lot, or talk a lot, but rarely at the same time. Extending Video Transcriber to take that into account was easier done than thought ;-). I am still surprised I can now do this kind of thing without deeply understanding the underlying technology.
This post hinges on three things:
- Using local AI to work with audio and video, and help me write
- Using a coding agent to modify someone else's tool to taste
- The challenge of publishing blog posts at the moment I have just done the thing
It became a bit bigger than I imagined, and is less structured then when I was drafting. I did not plan to do stopped-motion detection, I swear! I hope you enjoy.
Before we go on, a small sample of Video Transcribers' work. This one has timestamps to go with the text, it can also output whole paragraphs at once.
![Transcript excerpt from a book review. Lines preceded by timestamps1 A paragraph of text, a heading that says 'Slide 12' with a timestamp. A partial view of a hand drawn scribble. ](/images/blog/2026/centola-barriers.png)
You may notice it says "Santola" in the screenshot. That should be [Centola](https://www.damoncentola.com/). With a large Speech-To-Text model running on my laptop, this was about the only mistake. Phonetically they are the same, and I may need to work on my pronounciation. This is of course easily fixed by using search and replace.
# Using local AI to work with audio and video, help me write
## What is Video Transcriber for?
It is originally intended to make youtube videos easier to skim and refer back to. Reading text is a lot faster than listening to words. If you missed something, you can quickly glance back in text, whereas in video you have to scroll back and wait for the moment that you missed - it is only obvious in hindsight where that was.
## Different tools for different purposes
For youtube videos I now use Gemini Notebook, because it already has the transcriptions. I do miss the visuals with it sometimes, and want to replace it with an open notebook that runs with local models when I want to. I hadn't used video transcriber in a while, but that changed last week.
## Getting words on the page
What I am interested now is a more effective *Brain to Blog* flow. I have many ideas that I want to explain better to others, and get feedback on, but getting words and pictures on paper is a struggle sometimes. I understand, friction is part of the learning, and I do not wish to remove all of it. But I have recently been Sketch noting again. Narrating while drawing does help me to get ideas on to paper and in audio. Talking after drawing also works. The second and third talk-throughs are usually more concise. It does help to sharpen the vocabulary.
I picked Video Transcriber back up to transcribe sketch notes. I sketch on my tablet, and make a screen recording with audio as I do it. Then transcribe it on a laptop and get a markdown file with images for significant moments out. It takes about half the time of the length of the video to do the transcription. For instance, on my laptop it transcribed a 6 minutes chat in 3 minutes, at the highest quality settings. No special hardware like fancy graphics cards required. I did explore graphics cards and wrote about that further down.
## Baby you can [code while I write](https://en.wikipedia.org/wiki/You_Can_Sleep_While_I_Drive)
While I was drafting this blog post, trying to explain while I did not write about it nine months ago, I set my coding agent to work on a wish. A 6 and a half sketch noting video yielded 81 'slides' (the systems metaphor is a transcriber for a presentation with slides). It would be nice if it only made a slide once I stopped dragging the infinite canvas around, or when I stopped writing for a bit.
I gave the [coding agent](/blog/engineering/tag/pi.dev) a brief prompt, and the video I made to draft a book review of Damon Centola's [Change: How to make big things happen](https://www.goodreads.com/book/show/53369466-change), together with output from Video Transcriber that made a slide every 15 seconds, for a total of 81 slides. With access to tools (ffmpeg, the OpenCV library, Python), and my question, the coding agent (Pi with GLM 5.3 flash in this case) did most of the work independently. It used Romily's demo video from the repository for quick tests, and also used my book review video to compare the resulting markdown. It did some iterations on it to find good settings, and explained three settings to me, which I had it save in a document for the future.
After it more or less worked, I found that the text occasionally contained repeated phrases. The model had spotted this as well, and suggested to remove silence from the video before processing. It had found something that could do it, so I let it rip.
## Getting words out to the world
I already invested in [making it easier to put images in notes and blog posts](/blog/engineering/removing-a-special-cause-of-variation-in-small-steps), and export notes to blog posts. That is paying off already; it is the kind of work that compounds. I have less friction in details, so I can focus on the stories I want to tell. Now the work is in making it a habit to finish and publish posts, as the drafts (with images :-) ) were piling up.
# The challenge of publishing blog posts at the moment I have just done the thing
## Why I didn't write about it nine months ago
So why didn't I write about Video Transriber earlier? I sometimes find it hard to explain what I did right after I did it. Especially when I am improvising.
Romily asked if I wanted to contribute something back, at the time, say a blog post or a pull request. I struggled with that. As a practice, [Will It Blend](https://willemvandenende.com/blog/engineering/will-it-blend) is great to get something to work, and the flow feels great, as it is very improvisational. Being able to quickly vibe code even a small improvement and use it is fantastic. A downside of it, is that I often can&rsquo;t easily explain what happened afterwards, not even with the session transcript, as the coding agent records it, in hand.
# Adopting someone else's tools nine months ago
Looking back at the nine month old commits now, it was me using \`uv\` instead of \`pip\` - I standardized on uv as it felt easier and seems more modern and faster. But I am not a heavy python user like Romily, so I did not want to push my preferences at the time, and I couldn&rsquo;t quickly see how to have both co-exist in the README.
The other one was support for my AMD graphics cards through ROCM. It looks now like it also works with the GPU build in to my laptop, fancy external GPU optiona.. I hand&rsquo;t done anything with ROCM at the time, so was unsure how to integrate it into video-transcriber.
# Am I going to use Video Transcriber more now?
It might well be, that once I have the technical solution, is that my flow of narrating improves to the point where I can just take the screenshots myself, or prefer to select SVGs from the source and drag them in at the appropriate place in the blog post.
I guess this is what happpened with my first use of [Video Transcriber](https://github.com/romilly/video-transcriber). I liked having videos readable and easy to skim, but found that a notebook works better for me when I want to ask questions and go deeper.
Automation is great, but not the solution for everything.
In the meantime, I would recommend checking [Video Transcriber](https://github.com/romilly/video-transcriber) out. It is small and focus, and looks like a good starting point for experimenting with transcription and getting information out of videos. My coding agent is humming in the background, see if if we can do something clever with motion detection to reduce the number of generated slides.
See you next time!
# Further Reading
In [Will It Blend](https://willemvandenende.com/blog/engineering/will-it-blend) I went into more detail in how I adjust other peoples' software to my needs.
Last year I wrote for QWAN on how I see my evolution of knowledge and tool use, in the age of Large Language models: [Stochastic to Deterministic](https://www.qwan.eu/2025/12/11/stochastic-to-deterministic.html). I do more with language and other kinds of models than before. But I still turn things I repeat into tools. Writing is still hard, but [removing accidental friction, like adding images to this blog](/blog/engineering/removing-a-special-cause-of-variation-in-small-steps) from my writing and publishing flow keeps paying off.
I published [my fork of Video Transcriber](https://github.com/mostalive/video-transcriber), in the spirit of "Great artists ship". It is [make it run, with tests](/blog/engineering/synthetic-tdd). I must admit, I let [my agent](/blog/engineering/tag/pi.dev) ignore the instructions to write one test at a time, red-green-refactor style. I find that interesting to do occasionally, especially as a workshop, to 'feel' a model up close, or when I guide a team through entangled legacy code. In this case, I wanted the outcome, and that goes faster with [Synthetic TDD](). Express intent, set up feedback loops, try it out, and do another round.
@@ -0,0 +1,46 @@
%{
title: "To Cheap to Meter - Open Weight models in October 2026",
author: "Willem van den Ende",
tags: ~w(Local-Models),
description: "Earlier today I saw that the 17 October release of Deepseek v4 Flash is even more efficient than previous versions, threatening dominance of american AI labs. GLM 5.3 Flash is already very cheap, I dug out some numbers from my inference provider for you to check.",
image: "/images/blog/2026/cache-use-glm.png",
published: true
}
---
Earlier today I saw that the 17 October release of Deepseek v4 Flash is even more efficient than previous versions, [threatening dominance of american AI labs.](https://georgzoeller.com/blog/posts/chinese-flash-models-are-the-coup-de-gr-ce-for-us-ai-lab-investor-fantasies/)
Someone asked me about my current setup, as they burn through their weekly AI plan quickly. I did a shallow dive in the costs of the larger open weight model I use online, GLM 5.3 Flash. I didn&rsquo;t use much local tokens this week, see below for my local setup this month.
I will try the latest Deepseek as soon as my current inference provider has it up and running, probably somewhere later this week.
## GLM 5.3 what does it cost for a coding agent, with Zero Data Retention in Europe?
Looking at the graph, my coding agent sessions use mostly cache. A coding session tends to send the same file and tool contents over and over again, so very few new tokens are needed. Storing old tokens is relatively cheap.
The light purple part of the bars are cache, the dark purple parts are fresh tokens. Cache is relatively cheap, computing tokens costs much more, but still not that much.
![cache-use-glm](/images/blog/2026/cache-use-glm.png)
I spent less than a dollar to develop Stopped-motion detection in videos, and I did some quality of life improvements on my writing environment while I was at it.
![cost-to-develop-motion-stopped-detection](/images/blog/2026/cost-to-develop-motion-stopped-detection.png)
## Other kinds of models can be even cheaper
I say &rsquo;Models&rsquo; in the title, but above I only mentioned Large Language Models. There is a surge in popularity of other kinds of models like Decision Models, and BERT models are making a comeback. They tend to use less compute than LLMs.
## My local models in October 2026
I still use roughly the same [local model setup as two months ago](https://willemvandenende.com/blog/engineering/developing-with-open-weights-models-only-three-months-in). QWEN3.6 27B has been replaced by QWEN3.8 27B, which is very capable in long running agentic tasks. It has less world knowledge then Deepseek V4 Flash and GLM 5.3 Flash, and is apparently more expensive to host. But I can give it a task, walk away, and it will iterate and come out with something that works usually. Walking away is sometimes necessary - my laptops fans do become noisy on long runs.
Most of my use is small things, or long running tasks where I can walk away.
## My hosted open weights models at the moment
I went from Deepseek v4 Flash to GLM 5.3 flash, because the latter can process images. This has become an integral part of my workflow, e.g. for sketching out software development exercises and turning them into code. In web development it is handy if the model can control a browser, take screenshots, interpret the screenshots and iterate; synthetic exploratory testing.
I might go back to Deepseek once my provider makes the new version available, somewhere this week. It benchmarks well, so it might also work well on my code.
## Buy hardware, or use a service?
People sometimes ask me what hardware they should buy for Local Models. With these prices, and my usage, this is hard to justify for pure cost reasons. If you value independence and privacy, by all means, buy some hardware. But try hosted models first to see if they work for you.
@@ -0,0 +1,17 @@
%{
title: "I reduced the attack surface of an old Wordpress blog by shutting it down",
author: "Willem van den Ende",
tags: ~w(security pi.dev),
description: "Converting an interactive site like a blog to a static site is now very easy. Static sites have a tiny attack surface. They are not great for interaction, but for blogs and marketing sites they may be enough, and let you sleep at night.",
published: true
}
---
I reduced the attack surface on an old Wordpress blog by shutting it down, and then converting it to a static site from the backup, without running wordpress at all. The data, code and images was enough. It only took a single prompt (!) to get the basics done, then a few more to fix up the layout. Most of it happened while I was free to do other things.
A static site conversion had been on my mind for years, but the devil is in the details, and eventually would take a couple of days of fiddling. Now most of it happened while I was taking a nap, thanks to Pi.dev and GLM 5.3 Flash, and a feedback loop so the model could do exploratory testing with a web browser.
Seeing [Bert Hubert](https://www.linkedin.com/in/bert-hubert-b05452/)'s call yesterday to take your PHP server offline, and reading about 'Black October' was the push I needed.
The creaking old VM my blog ran on was fine, it had trouble responding to requests - a server that does not respond can not get hacked 😄 . So maybe there is no need to panic, but to take steps regardless.
I'm happy to write out the details if you want to do the same, but I'm only going to do it if there is interest, these kind of write-ups do take time - the devil is in the details, as always.
@@ -0,0 +1,40 @@
%{
title: "The Intention, Location, Details communication Pattern",
author: "Willem van den Ende",
tags: ~w(AI communication ensemble-programming coaching CollaborationMaxxing),
description: "Communicating our intent clearly is helpful in getting the results we want. I find the pattern Intention, Location Details as described by Emily Bache quite handy, It took me a while to find sources, I am collecting them here, before expanding on my own musings.",
image: nil,
published: true
}
---
When developing software, helping others do it, or instructing language models, it helps when we are clear about why we want to change something, [where the change should happen](https://www.qwan.eu/2020/12/23/what-to-put-where.html) (or not) and occasionally be very precise in what the change should be.
I find this pattern &ldquo;Intention, Location Details&rdquo; by [Emily Bache](https://coding-is-like-cooking.info) quite handy, and it is food for thought for AI augmented work. It took me a while to find sources, so I am collecting them here, before expanding on my own musings.
I came across this pattern, as I was going through [Emily Bache](https://coding-is-like-cooking.info) &rsquo;s post [Agentic AI coding breaks Mob programming](https://coding-is-like-cooking.info/2026/10/agentic-ai-coding-breaks-mob-programming-heres-how-to-fix-it/) . In it, there is a paragraph on the Intention, Location, Details communication pattern, aimed at working together effectively in an ensemble:
> To keep everyone aligned and to enable smooth transition between roles, I also teach the communication pattern ‘Intention, location, details’.
>
> This means the talker keeps everyone on board by frequently explaining their ‘intention’. Sometimes they need to go to ‘location’ – point at somewhere on the screen or in the codebase, and once everyone’s attention is on that, it’s clear what they mean. Occasionally that’s not enough and they give explicit ‘detail’ so the ‘typist’ gets told exactly what to do.
>
> In the main though, you communicate intention, so when you rotate roles, the next person should have a good idea about where the previous person was headed, and can seamlessly take over.
## More sources
[Emily Bache](https://coding-is-like-cooking.info) says she learnt this pattern from [Llewellyn Falco](https://llewellynfalco.blogspot.com/). Emily&rsquo;s [Samman coaching book](https://emilybache.com/samman-coaching-book/) has it on page 27. I have read the book, and would recommend, but didn&rsquo;t notice the pattern as I went through it.
I found that the pattern was in the book through [Jo van Eyck](https://jovaneyck.github.io/)&rsquo;s video [IMMEDIATELY improve your pair programming skills with &ldquo;intention, location, detail&rdquo;](https://www.youtube.com/watch?v=kG70AzY44GY) .
Jo&rsquo;s video description refers to a learning hour that is now a dead link. I guess it is now the [Talking with Intent, Typing Syntax](https://sammancoaching.org/learning_hours/ensemble/talking_typing.html) learning hour, which also contains a description of the pattern.
## More rambling
Now that I have finally put the words in a comprehensible order, the pattern is not in a recognisable pattern form. But it does fit many contexts. Intent(ion), location and details in a sense are each micro patterns. Provide clear intent. When the intent is not clear enough, clarify, or point at the location (this is the context for Location). When location (more context) is not enough, provide details.
I associate this with [Commanders Intent](https://www.mca-marines.org/gazette/commanders-intent-easy-to-understand-tough-to-articulate/) and Backbriefing, as explained in the book [The Art of Action](https://www.stephenbungay.com/Books.ink).
What I like about the pattern description above, is that fits in a paragraph. The explanation does not require any background. It took a while before I made it through [The Art of Action](https://www.stephenbungay.com/Books.ink), and the lessons from it gelling appears to be a process that is taking me years.
How about you? Do you often provide details where clear intent might have worked better? (micro management). Does intent sometimes surface **after** providing location and details? (hindsight from working through a problem) I am curious. The devil is, as always, is in the intent(ion).
Happy communicating, read you next time!
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 445 KiB

+80
View File
@@ -0,0 +1,80 @@
defmodule Firehose.CredoConfigTest do
@moduledoc """
Pins the *policy* in `app/.credo.exs`, not just the file's presence.
The gate is `mix credo --strict` (the `credo` target in `app/Makefile`, a
prerequisite of `check`), so the config decides what every future commit has
to satisfy. Plain `ExUnit.Case`: this reads and evaluates a file, it does not
touch the database or boot the app's supervision tree.
The assertions are deliberately coarse - "this check sits in that list",
"the disable says why", "the custom check is still loaded" - so they survive
credo's own boilerplate churn but not a policy change.
"""
use ExUnit.Case, async: true
@config_path ".credo.exs"
@complexity_check "Credo.Check.Refactor.CyclomaticComplexity"
@custom_check "Firehose.Checks.NoConnShadowing"
@custom_check_load_path "lib_dev/firehose/checks/"
test "cyclomatic complexity is disabled in favour of CRAP" do
checks = default_config()[:checks]
assert @complexity_check in check_modules(checks[:disabled])
refute @complexity_check in check_modules(checks[:enabled])
end
test "the disable carries a rationale comment" do
rationale = rationale_above(@complexity_check)
refute rationale == [], "no comment block above the #{@complexity_check} entry"
assert Enum.join(rationale, "\n") =~ ~r/crap/i
end
# Regression guard: `mix credo.gen.config` would silently drop both of these,
# and the custom check would stop running without one word of warning.
test "the custom check and its load path survive" do
config = default_config()
assert @custom_check in check_modules(config[:checks][:enabled])
assert @custom_check_load_path in config[:requires]
end
defp default_config do
{config, _bindings} = Code.eval_file(@config_path)
Enum.find(config.configs, &(&1.name == "default"))
end
defp check_modules(checks) do
Enum.map(checks, fn
{module, _opts} -> module_name(module)
module -> module_name(module)
end)
end
# `Atom.to_string/1` keeps the "Elixir." prefix that `inspect/1` drops; the
# assertions read better (and diff better) without it.
defp module_name(module) do
module |> Atom.to_string() |> String.replace_prefix("Elixir.", "")
end
# The contiguous `#` block immediately above the check's own line. The entry
# line is the one that is not itself a comment: a comment mentioning the
# check by name must not be mistaken for the entry.
defp rationale_above(check_name) do
lines = @config_path |> File.read!() |> String.split("\n")
index =
Enum.find_index(lines, fn line ->
String.contains?(line, check_name) and not comment?(line)
end)
lines
|> Enum.take(index)
|> Enum.reverse()
|> Enum.take_while(&comment?/1)
end
defp comment?(line), do: line |> String.trim() |> String.starts_with?("#")
end
+51 -2
View File
@@ -390,8 +390,57 @@ defmodule Firehose.AccountsTest do
end
describe "inspect/2 for the User module" do
test "does not include password" do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
# Rendering is `#Firehose.Accounts.User<__meta__: ..., id: 42, ...>`: a
# single line that ends with `, ...>`. The tests below pin the stable parts
# (module name, one line, redaction, field names and values) rather than
# the full string, so a toolchain bump that changes formatting does not
# fail them.
test "redacts every field the schema marks redacted, by name and by value" do
user = %User{
id: 42,
email: "shape-pin@example.com",
password: "plaintext-secret-value",
hashed_password: "hashed-secret-value"
}
output = inspect(user)
redacted = User.__schema__(:redact_fields)
# Guard against a vacuous loop if the schema key ever changes meaning.
# (`match?` rather than a comparison against `[]`: the type checker
# proves this schema's literal list non-empty and warns on the compare.)
assert match?([_ | _], redacted)
# The two secrets this schema declares; ties the loop to real fields.
assert :password in redacted
assert :hashed_password in redacted
for field <- redacted do
refute output =~ Atom.to_string(field)
refute output =~ Map.fetch!(user, field)
end
end
test "renders a single struct line with non-redacted fields and values" do
user = %User{
id: 42,
email: "shape-pin@example.com",
password: "plaintext-secret-value",
hashed_password: "hashed-secret-value"
}
output = inspect(user)
redacted = User.__schema__(:redact_fields)
assert String.starts_with?(output, "#Firehose.Accounts.User<")
assert String.ends_with?(output, ">")
refute output =~ "\n"
assert output =~ "id: 42"
assert output =~ ~s(email: "shape-pin@example.com")
for %{field: field} <- User.__info__(:struct), field not in redacted do
assert output =~ Atom.to_string(field)
end
end
end
end
+98
View File
@@ -0,0 +1,98 @@
defmodule Firehose.BoundaryTest do
use ExUnit.Case, async: true
alias Mix.Dep.Lock
# Boundary enforcement itself is a compile-time property: the pin on it is the
# warnings-fatal compile in `make verify` (and the recorded violation
# demonstration in the yak notes). These tests pin the *declarations* that
# compile gate reads, so a later edit that quietly re-opens the web -> Repo
# hole shows up as a failure here instead of only as an architectural drift.
#
# They read the `Boundary` module attribute that `use Boundary` persists. That
# is the same attribute boundary's own classifier reads
# (deps/boundary/lib/boundary/definition.ex: `Module.register_attribute(...,
# Boundary, persist: true)`), so this is the declaration as compiled, not a
# grep of the source text.
describe "boundary dependency" do
test "boundary is a direct dependency, not one inherited from ex_crap" do
assert {:boundary, requirement, opts} =
List.keyfind(Mix.Project.config()[:deps], :boundary, 0)
assert opts[:runtime] == false, "boundary is compile-time only; it must not ship at runtime"
assert Version.match?(locked_version(:boundary), requirement)
end
end
describe "a sibling Repo boundary" do
test "Firehose.Repo is its own top-level boundary with no dependencies" do
definition = boundary(Firehose.Repo)
assert definition.opts[:top_level?] == true,
"Firehose.Repo must be a sibling of Firehose/FirehoseWeb, not nested inside Firehose"
assert deps(Firehose.Repo) == [],
"the Repo is the bottom of the graph: it may depend on nothing"
end
test "Firehose may use Firehose.Repo" do
assert Firehose.Repo in deps(Firehose)
end
test "FirehoseWeb may not use Firehose.Repo" do
refute Firehose.Repo in deps(FirehoseWeb),
"web -> Repo is the hole this boundary closes; only contexts may touch the Repo"
end
end
describe "the context boundary's surface" do
test "Firehose exports everything, so the web tier can keep using context modules" do
assert boundary(Firehose).opts[:exports] == :all
end
end
# Application boot is the only thing that reaches from the context tier into
# the web tier. Making it its own top-level boundary (boundary's README
# prescribes exactly this for `mix phx.new` layouts) is what keeps the graph
# acyclic; the alternative -- FirehoseWeb in Firehose's deps -- is rejected by
# boundary with "dependency cycle found: Firehose -> FirehoseWeb -> Firehose".
describe "the application boundary" do
test "Firehose.Application depends on both tiers, and neither tier on the other's boot code" do
assert boundary(Firehose.Application).opts[:top_level?] == true
assert Enum.sort(deps(Firehose.Application)) == [Firehose, FirehoseWeb]
refute FirehoseWeb in deps(Firehose),
"Firehose depending on FirehoseWeb is the dependency cycle"
end
end
describe "enforcement wiring" do
# A compiler that is registered after :elixir looks wired but never reports
# anything, so the gate would be decoration. Position is the property.
test "the boundary compiler runs before :elixir in this environment" do
compilers = Mix.Project.config()[:compilers]
assert :boundary in compilers, "boundary compiler is not wired in #{Mix.env()}"
assert Enum.find_index(compilers, &(&1 == :boundary)) <
Enum.find_index(compilers, &(&1 == :elixir)),
"boundary must be listed before :elixir: it hooks after_compiler(:elixir, ...)"
end
end
defp boundary(module) do
assert [definition] = module.__info__(:attributes)[Boundary],
"#{inspect(module)} declares no boundary"
definition
end
defp deps(module), do: Keyword.get(boundary(module).opts, :deps, [])
defp locked_version(app) do
Lock.read()
|> Map.fetch!(app)
|> elem(2)
end
end
@@ -0,0 +1,226 @@
defmodule FirehoseWeb.CoreComponentsTest do
# Pure render tests: no conn, no database, so plain ExUnit.Case (the same
# choice layouts_test.exs makes) instead of the ConnCase sandbox.
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
alias FirehoseWeb.CoreComponents
@checkbox_assigns %{
type: "checkbox",
name: "prefs[news]",
id: "prefs_news",
label: "News",
value: "true",
rest: %{},
errors: [],
field: nil
}
@select_assigns %{
type: "select",
name: "role",
id: "role",
label: "Role",
prompt: "Pick one",
options: [{"Admin", "admin"}, {"Editor", "editor"}],
value: "admin",
multiple: false,
rest: %{},
errors: [],
field: nil
}
@textarea_assigns %{
type: "textarea",
name: "body",
id: "body",
label: "Body",
value: "hello world",
rest: %{},
errors: [],
field: nil
}
describe "input/1 checkbox" do
test "a checked box renders checked when the value is true" do
html = render_checkbox(%{})
assert attr(html, "input[type='checkbox']", "name") == "prefs[news]"
assert attr(html, "input[type='checkbox']", "value") == "true"
assert attr(html, "input[type='checkbox']", "checked") == ""
assert text(html) =~ "News"
end
test "an unchecked box still posts false for the field" do
html = render_checkbox(%{value: "false"})
assert attr(html, "input[type='checkbox']", "checked") == nil
# Markup is the behaviour here: the hidden field carrying the same name is
# what makes an unchecked box post "false" instead of posting nothing.
assert attr(html, "input[type='hidden']", "name") == "prefs[news]"
assert attr(html, "input[type='hidden']", "value") == "false"
end
test "shows the error message when the field has errors" do
html = render_checkbox(%{errors: ["can't be blank"]})
assert text(html) =~ "can't be blank"
end
end
describe "input/1 select" do
test "renders the prompt, every option, and preselects the current value" do
html = render_select(%{})
assert text(html) =~ "Pick one"
assert text(html) =~ "Admin"
assert text(html) =~ "Editor"
# The outcome, not the serialisation: the option carrying the current
# value is selected, the other one is not.
assert attr(html, "option[value='admin']", "selected") == ""
assert attr(html, "option[value='editor']", "selected") == nil
end
test "marks the select erroneous and shows the message when given errors" do
html = render_select(%{errors: ["is invalid"]})
assert text(html) =~ "is invalid"
assert "select-error" in classes(html, "select")
end
end
describe "input/1 textarea" do
test "keeps the value inside the textarea" do
html = render_textarea(%{value: "hello world"})
# normalize_value/2 prefixes the content with a newline, so compare the
# trimmed text: what matters is the value, not the surrounding whitespace.
assert String.trim(text(html, "textarea")) == "hello world"
end
test "renders an empty textarea for a nil value instead of the text nil" do
html = render_textarea(%{value: nil})
assert String.trim(text(html, "textarea")) == ""
refute html =~ "nil"
end
test "keeps a leading newline in the value" do
# HTML drops one leading newline of a <textarea> before the browser sees
# it, so normalize_value/2 prefixes the content with one: a value that
# starts with a newline survives, and one that does not gain one.
html = render_textarea(%{value: "\nsecond line"})
assert text(html, "textarea") == "\nsecond line"
end
test "marks the textarea erroneous and shows the message when given errors" do
html = render_textarea(%{errors: ["is too short"]})
assert text(html) =~ "is too short"
assert "textarea-error" in classes(html, "textarea")
end
end
describe "button/1" do
test "renders a button element when given no destination" do
html = render_button(%{})
assert count(html, "button") == 1
assert count(html, "a") == 0
assert String.trim(text(html)) == "Save"
end
test "renders a link to the destination when given an href, keeping the label" do
html = render_button(%{rest: %{href: "/somewhere"}})
assert attr(html, "a", "href") == "/somewhere"
assert String.trim(text(html)) == "Save"
assert count(html, "button") == 0
end
test "renders a live-navigation link when given navigate" do
html = render_button(%{rest: %{navigate: "/elsewhere"}})
# data-phx-link is the only thing distinguishing a live-navigation link
# from a full-page one, so it is the behaviour to pin here.
assert attr(html, "a", "data-phx-link") == "redirect"
assert attr(html, "a", "href") == "/elsewhere"
end
end
defp render_checkbox(overrides) do
render_component(&CoreComponents.input/1, Map.merge(@checkbox_assigns, overrides))
end
defp render_select(overrides) do
render_component(&CoreComponents.input/1, Map.merge(@select_assigns, overrides))
end
defp render_textarea(overrides) do
render_component(&CoreComponents.input/1, Map.merge(@textarea_assigns, overrides))
end
defp render_button(overrides) do
assigns =
Map.merge(
%{
variant: nil,
class: "",
rest: %{},
# A slot, as HEEx builds it: a list of entries whose inner_block takes
# (argument, changed).
inner_block: [%{inner_block: fn _, _ -> "Save" end}]
},
overrides
)
render_component(&CoreComponents.button/1, assigns)
end
# Every attribute assertion goes through here, so the LazyHTML mechanism lives
# in one place: one value per matched element, nil when the element (or the
# attribute) is absent, "" for a valueless attribute such as `checked`.
defp attr(html, selector, name) do
html
|> nodes(selector)
|> LazyHTML.attribute(name)
|> List.first()
end
# The class tokens of every matched element, for the "this field is marked
# erroneous" assertions.
defp classes(html, selector) do
html
|> nodes(selector)
|> LazyHTML.attribute("class")
|> Enum.flat_map(&String.split/1)
end
defp nodes(html, selector) do
html
|> LazyHTML.from_fragment()
|> LazyHTML.query(selector)
end
defp count(html, selector) do
html
|> nodes(selector)
|> Enum.count()
end
defp text(html) do
html
|> LazyHTML.from_fragment()
|> LazyHTML.text()
end
defp text(html, selector) do
html
|> nodes(selector)
|> LazyHTML.text()
end
end
@@ -1,6 +1,19 @@
defmodule FirehoseWeb.BlogControllerTest do
use FirehoseWeb.ConnCase, async: false
# Content churns; the behavior under test does not. Pick deterministically
# from the compiled posts instead of pinning slugs - the pinned "hello-world"
# post was deleted in 1709cb7 and ten tests silently rotted with it.
defp a_draft_post do
Enum.find(Firehose.EngineeringBlog.all_posts(), &(&1.published == false)) ||
raise "no draft (published: false) engineering post to exercise status banners"
end
defp a_live_post do
Enum.find(Firehose.EngineeringBlog.all_posts(), &(Blogex.Post.visibility(&1) == :live)) ||
raise "no live engineering post to exercise og meta tags"
end
describe "GET /blog/:blog_id (index) - date filtering" do
test "does not show future-dated posts", %{conn: conn} do
html = conn |> get(~p"/blog/engineering") |> html_response(200)
@@ -26,7 +39,8 @@ defmodule FirehoseWeb.BlogControllerTest do
setup :register_and_log_in_user
test "authenticated user sees draft banner on draft post", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world")
post = a_draft_post()
response = conn |> get(~p"/blog/engineering/#{post.id}")
assert html_response(response, 200) =~ "Draft"
assert response.resp_body =~ "not published"
@@ -49,9 +63,11 @@ defmodule FirehoseWeb.BlogControllerTest do
describe "GET /blog/:blog_id/:slug - no banners for unauthenticated" do
test "unauthenticated user sees no banner on draft post", %{conn: conn} do
post = a_draft_post()
response =
conn
|> get(~p"/blog/engineering/hello-world")
|> get(~p"/blog/engineering/#{post.id}")
|> html_response(200)
refute response =~ "post-status-banner"
@@ -69,26 +85,30 @@ defmodule FirehoseWeb.BlogControllerTest do
describe "GET /blog/:blog_id/:slug - Open Graph meta tags" do
test "meta tags include og_title", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
post = a_live_post()
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
assert response =~ ~s(<meta property="og:title")
assert response =~ "Hello World"
assert response =~ post.title
end
test "meta tags include og_description", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
post = a_live_post()
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
assert response =~ ~s(<meta property="og:description")
end
test "meta tags include og_type article", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
post = a_live_post()
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
assert response =~ ~s(<meta property="og:type" content="article")
end
test "meta tags include twitter:card", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
post = a_live_post()
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
assert response =~ ~s(<meta name="twitter:card" content="summary_large_image")
end
@@ -101,10 +121,11 @@ defmodule FirehoseWeb.BlogControllerTest do
end
test "meta tags have correct og_url", %{conn: conn} do
response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200)
post = a_live_post()
response = conn |> get(~p"/blog/engineering/#{post.id}") |> html_response(200)
assert response =~
~s(<meta property="og:url" content="http://localhost:4002/blog/engineering/hello-world")
~s(<meta property="og:url" content="http://localhost:4002/blog/engineering/#{post.id}")
end
end
@@ -1,12 +1,25 @@
defmodule FirehoseWeb.BlogTagsTest do
use FirehoseWeb.ConnCase
# Content churns; the behavior under test does not. The newest live post
# with a tag is a deterministic stand-in, and tag pages sort date-descending,
# so it is on page 1 whatever else is published under that tag. (The pinned
# "Hello World" post was deleted in 1709cb7 and this test silently rotted.)
defp a_newest_live_post_with_tag do
Enum.find(Firehose.EngineeringBlog.all_posts(), fn post ->
Blogex.Post.visibility(post) == :live and post.tags != []
end) || raise "no live tagged engineering post to exercise the tag page"
end
describe "engineering blog tags" do
test "GET /blog/engineering/tag/:tag shows tag page with filtered posts", %{conn: conn} do
body = conn |> get("/blog/engineering/tag/elixir") |> html_response(200)
assert body =~ ~s(tagged "elixir")
post = a_newest_live_post_with_tag()
tag = hd(post.tags)
body = conn |> get("/blog/engineering/tag/#{tag}") |> html_response(200)
assert body =~ ~s(tagged "#{tag}")
assert body =~ "Engineering Blog"
assert body =~ "Hello World"
assert body =~ post.title
end
test "GET /blog/engineering/tag/:tag shows empty list for nonexistent tag", %{conn: conn} do
@@ -9,6 +9,14 @@ defmodule FirehoseWeb.BlogTest do
body
end
# Content churns; the behavior under test does not. The newest live post is
# a deterministic stand-in - the pinned "hello-world" post was deleted in
# 1709cb7 and the slug tests silently rotted with it.
defp a_live_post do
Enum.find(Firehose.EngineeringBlog.all_posts(), &(Blogex.Post.visibility(&1) == :live)) ||
raise "no live engineering post to exercise the post pages"
end
describe "engineering blog (HTML)" do
test "GET /blog/engineering returns HTML index with layout", %{conn: conn} do
body = visit_blog_page(conn, "engineering")
@@ -17,8 +25,32 @@ defmodule FirehoseWeb.BlogTest do
end
test "GET /blog/engineering/:slug returns HTML post with layout", %{conn: conn} do
body = visit_blog_page(conn, "engineering", "/hello-world")
assert body =~ "Hello World"
post = a_live_post()
body = visit_blog_page(conn, "engineering", "/#{post.id}")
assert body =~ post.title
end
test "GET /blog/engineering?page=2 shows the next page of older posts", %{conn: conn} do
# Page size is 10 (`Blogex.Blog.paginate/2`, blogex/lib/blogex/blog.ex:121)
# and the test env lists drafts (`show_drafts: true`,
# app/config/config.exs), so the 34 visible engineering posts (the
# 2099-dated future post is excluded by `all_posts/0`) fill 4 pages.
# Page 2, date-descending, runs 2026-07-20 .. 2026-05-14.
body = visit_blog_page(conn, "engineering", "?page=2")
# The pager reports the page the reader asked for, not page 1
# (markup: blogex/lib/blogex/components.ex).
assert body =~ ~r/Page 2 of \d+/
# "Synthetic TDD" (2026-07-13) sits mid-page-2: five posts of headroom
# before new publications would push it to page 3. Matched as an anchor
# because the description of the page-2 post above it also says
# "Synthetic TDD".
assert body =~ ~s|>Synthetic TDD</a>|
# The newest post is unambiguously on page 1, so page 2 is a genuinely
# different set of posts rather than page 1 relabelled.
refute body =~ "I reduced the attack surface"
end
end
@@ -79,6 +111,56 @@ defmodule FirehoseWeb.BlogTest do
assert body =~ "target=\"_blank\" rel=\"noopener\" class=\"blogex-external\""
end
test "rendered post page carries the screen-reader hint for external links", %{conn: conn} do
body =
conn |> get("/blog/engineering/delayed-gratification-in-ai-summer") |> html_response(200)
assert body =~ ~s|<span class="blogex-external-hint"> (opens in a new tab)</span>|
end
end
describe "internal links in compiled posts" do
# `Blogex.Blog._validate_links/2` validates `post.body` after
# NimblePublisher rendered it to HTML. If extraction cannot read that HTML,
# every post looks link-free and compile-time validation is vacuous - which
# is the failure the real posts below pin against.
defp posts_with_internal_links do
Enum.filter(Firehose.EngineeringBlog.all_posts(), fn post ->
Blogex.LinkValidator.extract_links(post.body) != []
end)
end
test "the validator sees internal links in real compiled posts" do
refute posts_with_internal_links() == []
end
test "the post that links to swim-the-test exposes that link" do
post =
Enum.find(posts_with_internal_links(), &(&1.id == "delayed-gratification-in-ai-summer"))
assert post, "expected the compiled post to be visible with internal links extracted"
assert "/blog/engineering/swim-the-test" in Blogex.LinkValidator.extract_links(post.body)
end
test "no internal link in a real compiled post is invisible to extraction" do
# Deliberately permissive scan (any <a> whose href appears in any
# position, quoted or not): every /blog/ link it finds must also come out
# of extract_links/1, or a bad one could never be reported.
links_in_body = fn body ->
~r/<a\s[^>]*?href=["']?(\/blog\/[^"'\s>]+)/
|> Regex.scan(body)
|> Enum.map(fn [_, link] -> link end)
|> Enum.uniq()
end
for post <- Firehose.EngineeringBlog.all_posts(),
link <- links_in_body.(post.body) do
assert link in Blogex.LinkValidator.extract_links(post.body),
"#{post.id}: #{link} is in the compiled body but not extracted"
end
end
end
describe "input validation" do
@@ -118,11 +200,16 @@ defmodule FirehoseWeb.BlogTest do
end
test "GET /api/blog/engineering/:slug returns a post", %{conn: conn} do
assert %{"id" => "hello-world", "title" => "Hello World"} =
post = a_live_post()
assert %{"id" => id, "title" => title} =
conn
|> put_req_header("accept", "application/json")
|> get("/api/blog/engineering/hello-world")
|> get("/api/blog/engineering/#{post.id}")
|> json_response(200)
assert id == post.id
assert title == post.title
end
test "GET /api/blog/engineering/:slug returns 404 for missing post", %{conn: conn} do
@@ -21,6 +21,17 @@ defmodule FirehoseWeb.PageControllerTest do
end
end
describe "GET /contact" do
test "renders the contact page inside the app layout", %{conn: conn} do
body = conn |> get(~p"/contact") |> html_response(200)
# Page content (contact.html.heex) ...
assert body =~ "Get in touch"
# ... wrapped by the app layout, which is where the site nav lives.
assert body =~ ~s|/blog/engineering|
end
end
describe "GET / when authenticated" do
setup :register_and_log_in_user
@@ -61,13 +61,37 @@ defmodule FirehoseWeb.UserSessionControllerTest do
assert html =~ "Keep me logged in on this device"
end
test "raises error for invalid token", %{conn: conn} do
# The two ways a magic link can be dead arrive here through different arms
# of `Firehose.Accounts.get_user_by_magic_link_token/1`: a token that is
# not base64 at all fails to decode (`UserToken.verify_magic_link_token_query/1`
# returns `:error`), while a well-formed token simply matches no row. Both
# must land the visitor back on the log-in page with the error flash, not
# raise or 404.
test "sends the visitor back to log-in when the magic link token is not valid base64",
%{conn: conn} do
# 13 characters is not decodable with `padding: false`; the 0 hits on the
# magic-link `:error` line in cover/ show this test is what covers it.
assert :error = Base.url_decode64("invalid-token", padding: false)
response = conn |> get(~p"/users/log-in/invalid-token")
assert redirected_to(response) == ~p"/users/log-in"
assert Phoenix.Flash.get(response.assigns.flash, :error) ==
"Magic link is invalid or it has expired."
end
test "sends the visitor back to log-in for a decodable but unknown token", %{conn: conn} do
# The assertion keeps this case distinct from the not-base64 one above:
# if this token ever stopped decoding, the two tests would silently pin
# the same arm.
assert {:ok, _decoded} = Base.url_decode64("invalidtoken", padding: false)
response = conn |> get(~p"/users/log-in/invalidtoken")
assert redirected_to(response) == ~p"/users/log-in"
assert Phoenix.Flash.get(response.assigns.flash, :error) ==
"Magic link is invalid or it has expired."
end
end
describe "POST /users/log-in - email and password" do
@@ -87,6 +87,41 @@ defmodule FirehoseWeb.EditorDashboardLiveTest do
refute html =~ "Live Post"
end
test "switching Drafts -> Scheduled -> Drafts shows the matching panel", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/editor/dashboard")
# Both panels are always in the DOM, and only the inactive one is marked
# `hidden`: the panel's own class is the only server-side signal of what
# the reader sees. The panel-scoped content assertions below keep this
# from being a class check alone.
assert has_element?(view, "#drafts-tab")
refute has_element?(view, "#drafts-tab.hidden")
assert has_element?(view, "#scheduled-tab.hidden")
drafts_html = view |> element("#drafts-tab") |> render()
assert drafts_html =~ "Draft Post"
refute drafts_html =~ "Scheduled Post"
view |> element(~s(button[phx-value-tab="scheduled"])) |> render_click()
assert has_element?(view, "#scheduled-tab")
refute has_element?(view, "#scheduled-tab.hidden")
assert has_element?(view, "#drafts-tab.hidden")
scheduled_html = view |> element("#scheduled-tab") |> render()
assert scheduled_html =~ "Scheduled Post"
refute scheduled_html =~ "Draft Post"
view |> element(~s(button[phx-value-tab="drafts"])) |> render_click()
refute has_element?(view, "#drafts-tab.hidden")
assert has_element?(view, "#scheduled-tab.hidden")
drafts_again_html = view |> element("#drafts-tab") |> render()
assert drafts_again_html =~ "Draft Post"
refute drafts_again_html =~ "Scheduled Post"
end
test "renders with app layout including menu", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/editor/dashboard")
# Verify the app layout is applied by checking for menu elements
@@ -94,7 +129,8 @@ defmodule FirehoseWeb.EditorDashboardLiveTest do
assert html =~ "Blog"
assert html =~ "Releases"
assert html =~ "Contact"
assert html =~ "Dashboard" # This should be in the menu
# This should be in the menu
assert html =~ "Dashboard"
end
end
end
@@ -3,6 +3,7 @@ alias FirehoseWeb.MicroprintsLive
defmodule FirehoseWeb.MicroprintsLiveTest do
use FirehoseWeb.ConnCase, async: true
import FirehoseWeb.MicroprintsCase
import Phoenix.LiveViewTest
describe "scan_source_files/0" do
@@ -81,18 +82,15 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
{:ok, view, _html} = live(conn, ~p"/microprints")
# Expand file A
view
|> element("button[phx-value-path=\"#{file_a}\"]", "Expand")
|> render_click()
expand_file(view, file_a)
# Verify file A shows "Collapse" button (it's expanded)
html = render(view)
assert html =~ ~s(phx-value-path="#{file_a}")
assert html =~ "Collapse"
# Highlight a line in file B by dispatching the highlight_line event
# (microprint renders multiple rect segments per line, element() can't pick one)
render_click(view, "highlight_line", %{"line" => "1", "path" => file_b})
# Highlight a line in file B
click_microprint_rect(view, file_b, 1)
# file A should be collapsed when highlighting a different file
html = render(view)
@@ -112,9 +110,7 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
{:ok, view, _html} = live(conn, ~p"/microprints")
# Expand file A
view
|> element("button[phx-value-path=\"#{file_a}\"]", "Expand")
|> render_click()
expand_file(view, file_a)
# Capture file A's source content from the rendered HTML
html_a = render(view)
@@ -123,9 +119,7 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
"File A source should be visible when expanded"
# Expand file B (should auto-collapse A)
view
|> element("button[phx-value-path=\"#{file_b}\"]", "Expand")
|> render_click()
expand_file(view, file_b)
# Capture file B's source content
html_b = render(view)
@@ -144,6 +138,34 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
end
end
describe "collapsing an expanded file" do
test "hides the source lines and brings the Expand control back", %{conn: conn} do
# The URL route is used rather than clicking Expand: `restore_state_from_params/2`
# validates the param against the scanned cards (Enum.find over @microprints),
# so a real scanned path is required either way - see the "unknown expanded
# path in URL is ignored" test for the other half of that contract.
path = MicroprintsLive.scan_source_files() |> List.first()
{:ok, view, _html} = live(conn, ~p"/microprints?expanded=#{path}")
assert has_element?(view, "#line-1"),
"restored expansion should render the file's source lines"
assert has_element?(view, ~s(button[phx-value-path="#{path}"]), "Collapse")
collapse_file(view, path)
refute has_element?(view, "#line-1"),
"collapsing should stop rendering the source viewer"
refute render(view) =~ "Collapse",
"no card should still be expanded once the only expanded one is collapsed"
assert has_element?(view, ~s(button[phx-value-path="#{path}"]), "Expand"),
"the card's control should be Expand again"
end
end
describe "app layout with menu" do
test "renders the microprints page", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/microprints")
@@ -190,9 +212,7 @@ defmodule FirehoseWeb.MicroprintsLiveTest do
{:ok, view, _html} = live(conn, ~p"/microprints")
view
|> element("button[phx-value-path=\"#{file_a}\"]", "Expand")
|> render_click()
expand_file(view, file_a)
# URL should contain expanded param after clicking Expand
html = render(view)
@@ -1,20 +1,11 @@
defmodule FirehoseWeb.SourceViewerTest do
use FirehoseWeb.ConnCase, async: true
import FirehoseWeb.MicroprintsCase
import Phoenix.LiveViewTest
alias FirehoseWeb.MicroprintsLive
defp expand_file(view, path) do
view
|> element("button[phx-value-path=\"#{path}\"]", "Expand")
|> render_click()
end
defp click_microprint_rect(view, path, line) do
render_click(view, "highlight_line", %{"line" => Integer.to_string(line), "path" => path})
end
describe "line elements" do
test "render with unique ids when file is expanded", %{conn: conn} do
files = MicroprintsLive.scan_source_files()
@@ -0,0 +1,188 @@
defmodule FirehoseWeb.ThemeStylesheetTest do
@moduledoc """
Guards the theme's source files against the two defects the theme yak records.
daisyUI 5 stores a *complete* colour in every `--color-*` custom property
(`--color-base-content: oklch(25% 0.015 50)`), so `oklch(var(--color-…))`
re-wraps a complete value and the browser drops the whole declaration. The
visible symptom was a transparent chip background and unstyled links.
Each theme's `prefersdark` option makes daisyUI emit an *unscoped*
`@media (prefers-color-scheme: dark) { :root { … } }`, which lands after the
light block and wins on equal specificity: an explicit `data-theme="light"`
rendered dark on a dark-OS browser. Theme selection now lives in the inline
script in `root.html.heex`, which always writes an explicit `data-theme`
before first paint, so no stylesheet rule may follow the OS on its own.
These read the two source files as text: they are regression guards, not
behavioural tests. The authoritative check for *rendered* colour and theme
selection is the factory probe (`mastra-factory/scripts/theme-probe.mjs`),
which needs a browser; `make verify` never parses or renders CSS.
"""
use ExUnit.Case, async: true
# `__DIR__`-relative so the assertions do not depend on the working directory.
@stylesheet Path.expand("../../assets/css/app.css", __DIR__)
@root_layout Path.expand("../../lib/firehose_web/components/layouts/root.html.heex", __DIR__)
@layouts Path.expand("../../lib/firehose_web/components/layouts.ex", __DIR__)
# Measured by the factory probe (chromium, 2026-10-29) for `.blogex-post-meta`
# in the light theme: base-content over base-100 composited at 0.6 opacity is
# 4.16:1, below the 4.5:1 acceptance floor. The alpha the floor needs is
# 0.625; anything below that fails.
@meta_min_opacity 0.625
# Same probe, same day, for `--color-primary` as *text* on `--color-base-100`
# in the light theme - what pagination links, body links and the post-title
# hover now use. 58% lightness measured 4.18:1; 56% is 4.54:1, too thin a
# margin; 54% is 4.93:1 (and 4.52:1 on base-200, the lightest chip ground).
@light_primary_max_lightness 0.54
describe "colour tokens" do
test "theme colour tokens are used as complete values" do
offenders =
@stylesheet
|> read()
|> String.split("\n")
|> Enum.with_index(1)
|> Enum.filter(fn {line, _number} -> line =~ ~r/oklch\(\s*var\(--color-/ end)
assert offenders == [],
"""
oklch(var(--color-…)) is invalid: the --color-* properties already hold a
complete oklch(…) value, so the declaration is dropped. Use var(--color-…)
directly, or color-mix() where a derived colour is genuinely needed.
Offending line(s):
#{Enum.map_join(offenders, "\n", fn {line, number} -> " #{number}: #{String.trim(line)}" end)}
"""
end
end
describe "theme selection" do
test "the head script always writes an explicit data-theme" do
script = theme_script()
# "system" is resolved here, from the OS preference, before first paint.
assert script =~ ~r/matchMedia\(\s*["']\(prefers-color-scheme:\s*dark\)["']\s*\)/
assert script =~ ~r/setAttribute\(\s*["']data-theme["']/
# Removing the attribute is what let the unstyled root fall back to the
# OS-driven media block, so "system" must never be expressed that way.
refute script =~ "removeAttribute"
end
test "an OS preference change re-resolves only while 'system' is the choice" do
assert theme_script() =~
~r/addEventListener\(\s*["']change["'][\s\S]*?stored\(\)\s*===\s*["']system["']/
end
test "the toggle knob follows the choice, not the resolved palette" do
# A system choice can resolve to the dark palette; the indicator must still
# sit on the system icon, or the toggle lies about what is selected.
layouts = read(@layouts)
assert layouts =~ "[[data-theme-choice=light]_&]:left-1/3"
assert layouts =~ "[[data-theme-choice=dark]_&]:left-2/3"
refute layouts =~ "[[data-theme=light]_&]"
refute layouts =~ "[[data-theme=dark]_&]"
end
# The human's decision (REVIEW.md, Q1): "default to light theme, and make
# setting explicit". Light is what an unset preference resolves to; "system"
# is an explicit choice, not the default.
test "no stored preference means the light theme" do
assert theme_script() =~ ~r/apply\(\s*stored\(\)\s*\|\|\s*["']light["']\s*\)/
end
test "no theme follows the OS preference on its own" do
# `prefersdark: true` mints the unscoped media block that outranked the
# explicit attribute. Selection is the script's job; the stylesheet's
# default (light) must apply whenever an attribute is absent.
assert theme_option("light", "prefersdark") == "false"
assert theme_option("dark", "prefersdark") == "false"
end
end
describe "post meta contrast" do
test "the post meta is dimmed no further than the contrast floor allows" do
opacity = declaration(".blogex-post-meta", "opacity")
assert opacity >= @meta_min_opacity,
"""
.blogex-post-meta has opacity #{opacity}, which composites base-content over
base-100 below the 4.5:1 floor (probe: 0.6 -> 4.16:1, 0.625 -> 4.51:1).
Raise it, or the meta line is unreadable in the light theme.
"""
end
end
describe "light theme link colour" do
test "primary is dark enough to be text on base-100" do
lightness = lightness(theme_option("light", "--color-primary"))
assert lightness <= @light_primary_max_lightness,
"""
The light theme's --color-primary has #{lightness} lightness, so link text
(pagination, body links, the post-title hover) composites below 4.5:1 on
base-100: the probe measured 4.18:1 at 0.58. 0.54 measures 4.93:1.
"""
end
end
defp read(path), do: File.read!(path)
# OKLCH lightness as a 0..1 fraction; daisyUI writes either `54%` or `0.54`.
defp lightness(value) do
case Regex.run(~r/^oklch\(\s*([\d.]+)\s*(%?)/, value) do
[_, number, "%"] -> number |> Float.parse() |> elem(0) |> Kernel./(100)
[_, number, ""] -> number |> Float.parse() |> elem(0)
_ -> flunk("cannot read the lightness of #{inspect(value)}")
end
end
# The declarations of a single plain rule (`selector { … }`).
defp declaration(selector, property) do
selector
|> rule_body()
|> then(fn body ->
case Regex.run(~r/^\s*#{property}:\s*([^;]+);/m, body) do
[_, value] -> value |> String.trim() |> String.to_float()
nil -> flunk("no #{property} in #{selector}")
end
end)
end
defp rule_body(selector) do
case Regex.run(~r/#{Regex.escape(selector)}\s*\{([^}]*)\}/, read(@stylesheet)) do
[_, body] -> body
nil -> flunk("no rule for #{selector} in #{@stylesheet}")
end
end
# The inline script in <head>, not the deferred `app.js` tag.
defp theme_script do
@root_layout
|> read()
|> then(&Regex.scan(~r/<script[^>]*>(.*?)<\/script>/s, &1, capture: :all_but_first))
|> List.flatten()
|> Enum.find(fn body -> body =~ "data-theme" end) ||
flunk("no inline theme script in #{@root_layout}")
end
# The body of the `@plugin "../vendor/daisyui-theme" { … }` block for `name`.
defp theme_block(name) do
@stylesheet
|> read()
|> String.split(~s(@plugin "../vendor/daisyui-theme" {))
|> Enum.find(fn block ->
Enum.any?(String.split(block, "\n"), &(&1 |> String.trim() == ~s(name: "#{name}";)))
end) || flunk("no daisyui-theme block named #{inspect(name)} in #{@stylesheet}")
end
defp theme_option(name, option) do
case Regex.run(~r/^\s*#{option}:\s*([^;]+);/m, theme_block(name)) do
[_, value] -> String.trim(value)
nil -> nil
end
end
end
+96
View File
@@ -4,6 +4,7 @@ defmodule FirehoseWeb.UserAuthTest do
alias Firehose.Accounts
alias Firehose.Accounts.Scope
alias FirehoseWeb.UserAuth
alias Phoenix.LiveView.Socket
import Firehose.AccountsFixtures
@@ -27,6 +28,13 @@ defmodule FirehoseWeb.UserAuthTest do
assert Accounts.get_user_by_session_token(token)
end
test "stores a live socket id derived from the session token", %{conn: conn, user: user} do
conn = UserAuth.log_in_user(conn, user)
token = get_session(conn, :user_token)
assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
end
test "clears everything previously stored in the session", %{conn: conn, user: user} do
conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
refute get_session(conn, :to_be_removed)
@@ -113,6 +121,21 @@ defmodule FirehoseWeb.UserAuthTest do
refute Accounts.get_user_by_session_token(user_token)
end
test "broadcasts a disconnect on the session's live socket id", %{conn: conn, user: user} do
logged_in_conn = UserAuth.log_in_user(conn, user)
token = get_session(logged_in_conn, :user_token)
live_socket_id = get_session(logged_in_conn, :live_socket_id)
FirehoseWeb.Endpoint.subscribe(live_socket_id)
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, live_socket_id)
|> UserAuth.log_out_user()
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
refute_receive %Phoenix.Socket.Broadcast{topic: ^live_socket_id}
end
test "works even if user is already logged out", %{conn: conn} do
conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
refute get_session(conn, :user_token)
@@ -183,6 +206,56 @@ defmodule FirehoseWeb.UserAuthTest do
assert new_signed_token != signed_token
assert max_age == @remember_me_cookie_max_age
end
test "reissues the live socket id together with the token", %{conn: conn, user: user} do
logged_in_conn =
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
token = logged_in_conn.cookies[@remember_me_cookie]
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
old_live_socket_id = get_session(logged_in_conn, :live_socket_id)
offset_user_token(token, -10, :day)
conn =
conn
|> put_session(:user_token, token)
|> put_session(:user_remember_me, true)
|> put_session(:live_socket_id, old_live_socket_id)
|> put_req_cookie(@remember_me_cookie, signed_token)
|> UserAuth.fetch_current_scope_for_user([])
assert new_token = get_session(conn, :user_token)
refute new_token == token
assert get_session(conn, :live_socket_id) ==
"users_sessions:#{Base.url_encode64(new_token)}"
refute get_session(conn, :live_socket_id) == old_live_socket_id
end
end
describe "live view sockets" do
# A real websocket disconnect cannot be driven through Phoenix.LiveViewTest:
# the "disconnect" broadcast is handled by Phoenix.Socket.__info__/2 on the
# transport process (deps/phoenix/lib/phoenix/socket.ex:571), and the test
# harness (phoenix_live_view/test/client_proxy.ex) never subscribes to the
# socket id topic. What can be pinned is the contract the transport relies
# on: the value in the session is the topic Phoenix.LiveView.Socket.id/1
# returns for the session the websocket receives (string keys).
test "the id written to the session is the topic Socket.id/1 reads", %{conn: conn, user: user} do
conn = UserAuth.log_in_user(conn, user)
live_socket_id = get_session(conn, :live_socket_id)
socket_session =
Map.new(conn.private[:plug_session], fn {key, value} -> {to_string(key), value} end)
socket = %Socket{
private: %{connect_info: %{session: socket_session}}
}
assert Socket.id(socket) == live_socket_id
end
end
describe "require_sudo_mode/2" do
@@ -290,4 +363,27 @@ defmodule FirehoseWeb.UserAuthTest do
refute conn.status
end
end
describe "a session token that no longer exists" do
# This is the plug-layer contract that keeps UserAuth's LiveView-side
# defences unreachable in this app: the router wraps /editor/dashboard in
# `pipe_through [:browser, :require_authenticated_user]`, so the plug
# redirects before `on_mount {UserAuth, :ensure_authenticated}` can run and
# its halt arm never fires for a stale token. The same goes for the nil
# scope fallbacks in `scope_from_session/1`.
test "is treated as logged out", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
token = get_session(conn, :user_token)
assert Accounts.get_user_by_session_token(token)
:ok = Accounts.delete_user_session_token(token)
response = conn |> get(~p"/users/settings")
assert redirected_to(response) == ~p"/users/log-in"
assert Phoenix.Flash.get(response.assigns.flash, :error) ==
"You must log in to access this page."
end
end
end
+49
View File
@@ -0,0 +1,49 @@
defmodule Firehose.MutationConfigTest do
# Same wiring pins as blogex, plus the app-specific one: because the app
# scope is deliberately empty (Q45), the aliases must fail loudly. A sensor
# that reports a score for a run that tested nothing is worse than no sensor.
use ExUnit.Case
test "muex is a dev/test-only, non-runtime dependency" do
deps = Mix.Project.config()[:deps]
assert {:muex, requirement, opts} = dep!(deps, :muex)
assert requirement =~ "0.12"
assert opts[:only] == [:dev, :test]
assert opts[:runtime] == false
end
test "mutate and mutate.fast aliases are defined" do
aliases = Mix.Project.config()[:aliases]
assert is_function(aliases[:mutate], 1)
assert is_function(aliases[:"mutate.fast"], 1)
end
test "an empty app scope fails loudly instead of running over nothing" do
assert_raise Mix.Error, ~r/empty/, fn ->
Firehose.MixProject.mutation_args("mutation_scope.txt", [])
end
end
test "ad-hoc args do not bypass the empty-scope failure" do
assert_raise Mix.Error, ~r/empty/, fn ->
Firehose.MixProject.mutation_args("mutation_scope.txt", [
"--files",
"lib/firehose/accounts/user.ex"
])
end
end
defp dep!(deps, name) do
case Enum.find(deps, fn
{^name, _requirement} -> true
{^name, _requirement, _opts} -> true
_ -> false
end) do
{^name, requirement} -> {name, requirement, []}
{^name, requirement, opts} -> {name, requirement, opts}
nil -> flunk("expected #{inspect(name)} in deps/0")
end
end
end
+53
View File
@@ -0,0 +1,53 @@
defmodule Firehose.MutationScopeTest do
# The app's candidate pure modules (Firehose.Accounts.User, Scope and
# UserToken) are only exercised through Firehose.DataCase: their tests use
# the SQL sandbox, fixtures and Repo. Per the yak's Q45 decision, an app
# mutation run would therefore need PostgreSQL, so the scope is deliberately
# empty and the alias fails loudly rather than silently running over nothing.
#
# These pins keep that decision visible: if the scope grows, this file must
# change with it, and it may never grow into the web layer.
use ExUnit.Case
@root Path.expand("..", __DIR__)
test "scope files exist" do
assert File.exists?(Path.join(@root, "mutation_scope.txt"))
assert File.exists?(Path.join(@root, "mutation_scope_fast.txt"))
end
test "app scope is empty because the candidate modules' tests need PostgreSQL" do
assert scope("mutation_scope.txt") == []
end
test "fast scope is a subset of full scope" do
out_of_scope =
MapSet.difference(
MapSet.new(scope("mutation_scope_fast.txt")),
MapSet.new(scope("mutation_scope.txt"))
)
assert MapSet.size(out_of_scope) == 0,
"mutate.fast names files outside the audited scope: #{inspect(MapSet.to_list(out_of_scope))}"
end
test "scope never includes web-layer code" do
web_entries =
Enum.filter(
scope("mutation_scope.txt") ++ scope("mutation_scope_fast.txt"),
&String.starts_with?(&1, "lib/firehose_web")
)
assert web_entries == [],
"the web layer (controllers, LiveViews, components) is out of scope: #{inspect(web_entries)}"
end
defp scope(file) do
@root
|> Path.join(file)
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
end
+43
View File
@@ -0,0 +1,43 @@
defmodule FirehoseWeb.MicroprintsCase do
@moduledoc """
Helpers for driving the `/microprints` LiveView.
Both `FirehoseWeb.MicroprintsLiveTest` and `FirehoseWeb.SourceViewerTest` walk
the same page through the same two controls (the per-card Expand/Collapse
button and the microprint rects), so the mechanics live here instead of being
re-implemented in each test module.
A plain module, imported by the tests, rather than a `use`-able case template:
the LiveView tests own their own setup and only share these three actions.
"""
import Phoenix.LiveViewTest
@doc """
Clicks the Expand control of the card for `path` and returns the render.
"""
def expand_file(view, path) do
view
|> element(~s(button[phx-value-path="#{path}"]), "Expand")
|> render_click()
end
@doc """
Clicks the Collapse control of the card for `path` and returns the render.
"""
def collapse_file(view, path) do
view
|> element(~s(button[phx-value-path="#{path}"]), "Collapse")
|> render_click()
end
@doc """
Clicks a single line of a microprint, highlighting it in the source viewer.
The microprint renders several rect segments per line, so `element/3` cannot
pick one; the `highlight_line` event is dispatched directly instead.
"""
def click_microprint_rect(view, path, line) do
render_click(view, "highlight_line", %{"line" => Integer.to_string(line), "path" => path})
end
end
+238
View File
@@ -0,0 +1,238 @@
# This file contains the configuration for Credo and you are probably reading
# this after creating it with `mix credo.gen.config`.
#
# If you find anything wrong or unclear in this file, please report an
# issue on GitHub: https://github.com/rrrene/credo/issues
#
# The gate for this library is `mix credo --strict` on the command line (the
# `credo` target in Makefile). The command line, not this file, decides:
# `--strict` overrides the inert `strict: false` key below. Editing `strict`
# here to silence a finding would change nothing - fix the finding, or write
# down why the check is wrong for this codebase (see `disabled:`).
#
%{
#
# You can have as many configs as you like in the `configs:` field.
configs: [
%{
#
# Run any config using `mix credo -C <name>`. If no config name is given
# "default" is used.
#
name: "default",
#
# These are the files included in the analysis:
files: %{
#
# You can give explicit globs or simply directories.
# In the latter case `**/*.{ex,exs}` will be used.
#
included: [
"lib/",
"src/",
"test/",
"web/",
"apps/*/lib/",
"apps/*/src/",
"apps/*/test/",
"apps/*/web/"
],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
},
#
# Load and configure plugins here:
#
plugins: [],
#
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: [],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
#
strict: false,
#
# To modify the timeout for parsing files, change this value:
#
parse_timeout: 5000,
#
# If you want to use uncolored output by default, you can change `color`
# to `false` below:
#
color: true,
#
# You can customize the parameters of any check by adding a second element
# to the tuple.
#
# To disable a check put `false` as second element:
#
# {Credo.Check.Design.DuplicatedCode, false}
#
checks: %{
enabled: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
#
## Design Checks
#
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage,
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
{Credo.Check.Design.TagFIXME, []},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 2]},
#
## Readability Checks
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
{Credo.Check.Readability.WithSingleClause, []},
#
## Refactoring Opportunities
#
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.FilterCount, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},
#
## Warnings
#
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.Dbg, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.SpecWithStruct, []},
{Credo.Check.Warning.StructFieldAmount, []},
{Credo.Check.Warning.UnsafeExec, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedMapOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.WrongTestFilename, []}
],
disabled: [
#
# Our one deliberate deviation from credo's defaults. CRAP (`ex_crap`,
# threshold 30, wired into the Lens pass in Makefile) is this
# library's complexity sensor, and it scores complexity against real
# coverage: a wide `case` that the tests exercise is not a problem -
# that was the itch behind ExCrap. A second, coverage-blind complexity
# sensor would just argue with the first, so complexity questions are
# settled by the CRAP target. Re-enabling this check without retiring
# the CRAP target is a policy change, not a cleanup.
#
{Credo.Check.Refactor.CyclomaticComplexity, []},
#
# Checks scheduled for next check update (opt-in for now)
{Credo.Check.Refactor.UtcNowTruncate, []},
#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
#
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
{Credo.Check.Consistency.UnusedVariableNames, []},
{Credo.Check.Design.DuplicatedCode, []},
{Credo.Check.Design.SkipTestWithoutComment, []},
{Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.BlockPipe, []},
{Credo.Check.Readability.ImplTrue, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.NestedFunctionCalls, []},
{Credo.Check.Readability.OneArityFunctionInPipe, []},
{Credo.Check.Readability.OnePipePerLine, []},
{Credo.Check.Readability.SeparateAliasRequire, []},
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.CondInsteadOfIfElse, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.IoPuts, []},
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []},
{Credo.Check.Refactor.NegatedIsNil, []},
{Credo.Check.Refactor.PassAsyncInTestCases, []},
{Credo.Check.Refactor.PipeChainStart, []},
{Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.UnsafeToAtom, []}
# {Credo.Check.Warning.UnusedOperation, [{MyMagicModule, [:fun1, :fun2]}]}
# {Credo.Check.Refactor.MapInto, []},
#
# Custom checks can be created using `mix credo.gen.check`.
#
]
}
}
]
}
+1
View File
@@ -1,6 +1,7 @@
/_build/
/deps/
/doc/
/cover/
*.ez
*.beam
.elixir_ls/
+115 -3
View File
@@ -1,12 +1,124 @@
# Makefile for Blogex library
MISE_BIN ?= $(HOME)/.local/bin/mise
MISE_BIN ?= mise
MISE_EXEC = $(MISE_BIN) exec --
.PHONY: test format
.PHONY: check precommit deps deps.check compile test test.coverage coverage crap format format.check verify
test:
# Cheap static gate for this library (no database, no network, no coverage).
# The deps.check guard fails loudly with a pointer at `make setup` when Hex
# deps are missing. Runs from the root `make check`, which loops app then
# blogex. `format.check` only reports:
# a gate that rewrites the files it judges can never fail. Fix formatting with
# `make format`.
#
# Sensor slot: add `credo` once the "credo strict, tuned config (both apps)"
# yak lands blogex's credo dep + .credo.exs. Do not list it before then — a
# gate that claims a check it did not run is worse than no gate.
check: deps.check compile format.check
# Per-app precommit loop: same target name and alias shape as app/, so both
# apps share one habit (compile --warning-as-errors, format, test).
precommit:
$(MISE_EXEC) mix precommit
# Sync dependencies and compile them: NimblePublisher pulls in mdex_native,
# whose precompiled NIF downloads from GitHub at first compile. Keeping that
# in `deps` (called by root `make setup`) keeps check/test/verify offline on
# a cold clone.
deps:
$(MISE_EXEC) mix deps.get
$(MISE_EXEC) mix deps.compile
# Local, network-free guard: every package pinned in mix.lock must be present
# in deps/. check, test and test.coverage depend on this instead of `deps`, so
# the everyday loops never touch the network: `mix deps.get` contacts hex.pm
# even when everything is already installed (the advisory check), which fails
# in offline/sandboxed environments. Fetching is `make setup`'s explicit job,
# run from the repo root.
deps.check:
@test -f mix.lock || { echo "deps.check: no mix.lock - run 'make setup' at the repo root" >&2; exit 1; }
@missing=""; \
for dep in $$(sed -n 's/^[[:space:]]*"\([^"]*\)".*/\1/p' mix.lock); do \
[ -d "deps/$$dep" ] || missing="$$missing $$dep"; \
done; \
test -z "$$missing" || { echo "deps.check: missing:$$missing - run 'make setup' at the repo root" >&2; exit 1; }
# Compile the project (warnings are fatal)
compile:
$(MISE_EXEC) mix compile --warnings-as-errors
# Run the test suite
test: deps.check
$(MISE_EXEC) mix test
# Run the suite once with coverage exported, so the coverage floor check (and,
# later, CRAP) reads the export instead of running the suite a second time.
# Stale exports are removed first: the floor must never report an older run.
test.coverage: deps.check
rm -f cover/*.coverdata
$(MISE_EXEC) mix test --cover --export-coverage default
# Enforce the coverage floor (mix.exs -> test_coverage) from that export. No
# test run. `mix test.coverage` exits 0 when it finds no export at all, so the
# guard below is load-bearing: a floor check that did not run must not pass.
coverage:
@test -f cover/default.coverdata || { echo "coverage: no exported coverage - run 'make test.coverage' first" >&2; exit 1; }
$(MISE_EXEC) mix test.coverage
# Enforce CRAP scores (complexity x coverage) from the same export. No test run.
# Runs in MIX_ENV=test because that is the environment the export came from:
# running in dev would compile a second build and score against a different set
# of modules. The guard is load-bearing for the same reason as `coverage`:
# ex_crap falls back to 0% coverage when the export is missing, which would
# report scores that are pessimistic instead of absent - a false failure is
# less bad than a false pass, but neither is acceptable in the Lens pass.
#
# Threshold is ex_crap's default (30), kept deliberately: decision 2026-10-28
# (human, yak "ratchets, deferred decisions, handoff practice") is not to
# ratchet --max-score down yet - below 30 is a policy change, not a cleanup.
# Baseline at that threshold, same date: 0 offenders above 30, worst functions
# Blogex.LinkValidator.validate_link/1 and validate_links/1 at 6.00
# ("Summary: files=12 functions=61 scored=61 worst_score=6.00"). A failing run
# prints "High scores: N" plus the offending functions; on a passing run read
# N=0 off the worst score rather than inventing a count.
crap:
@test -f cover/default.coverdata || { echo "crap: no exported coverage - run 'make test.coverage' first" >&2; exit 1; }
MIX_ENV=test $(MISE_EXEC) mix crap
# Format code. MUTATES the tree; deliberately not part of `check` (nor of the
# Lens pass), which must never write: a gate that fixes what it judges can never
# fail, and would dirty the tree a push is about to send. `make check` uses
# `format.check` below instead.
format:
$(MISE_EXEC) mix format
# Verify formatting without writing. Fails with the offending files, so the fix
# is explicit: run `make format` (or `make -C blogex format`).
format.check:
$(MISE_EXEC) mix format --check-formatted
# =============================================================================
# Lens pass (heavy checks; root `make verify` runs this before every push)
# =============================================================================
#
# Sensor slots, cheapest first. Add a sensor as its yak lands, both as a target
# and to VERIFY_SENSORS — that variable is the single source of truth for what
# this target reports as wired.
# - coverage (yak: coverage floors + framework-glue exclusions policy)
# WIRED below (test.coverage + coverage).
# - crap (yak: CRAP scores with ex_crap (both apps))
# WIRED below (crap); reuses the coverage export.
# - mutation (yak: mutation testing with muex — a sensor, not a gate)
#
# The suite runs FIRST: it is what produces the coverage export that the
# sensors below read. (Sampling and reporting are separate steps because
# `mix test --cover --export-coverage` exports instead of printing a summary,
# and reporting through `mix test.coverage` keeps it to one suite run.) Sensors
# still run cheapest-first among themselves.
VERIFY_SENSORS := coverage crap
verify:
@echo "==> verify(blogex): suite (coverage exported), then sensors [$(VERIFY_SENSORS)]"
@$(MAKE) test.coverage
@for sensor in $(VERIFY_SENSORS); do echo "==> verify(blogex): sensor $$sensor"; $(MAKE) $$sensor || exit 1; done
+28 -6
View File
@@ -9,38 +9,60 @@ defmodule Blogex.ExternalLinks do
* `rel="noopener"` — the new tab cannot access `window.opener`
* `class="blogex-external"` — hook for CSS to draw an external-link
marker icon (the host app's stylesheet owns the actual styling)
* a `<span class="blogex-external-hint"> (opens in a new tab)</span>`
at the end of the link text — announced by screen readers, hidden
visually by the host app's stylesheet
Internal links (relative paths), `mailto:` and `#anchor` links are left
untouched.
The rewrite is idempotent: applying it to already-rewritten HTML does
not duplicate attributes.
not duplicate attributes or hints.
Assumptions: the HTML comes from NimblePublisher's Earmark conversion,
which emits double-quoted, lowercase attribute names and never adds
`target`, `rel` or `class` to anchors. Attribute values are matched
with case-insensitive regexes to stay robust for other producers.
Anchors are matched together with their closing tag (the hint goes
inside), so a stray unclosed `<a>` is left alone.
"""
@marker_class "blogex-external"
@hint_class "blogex-external-hint"
@hint_text " (opens in a new tab)"
@external_href ~r|^https?://|i
@anchor ~r|(<a\s[^>]*>)(.*?)(</a>)|is
@doc """
Rewrites all external anchors in an HTML fragment.
"""
@spec rewrite(String.t()) :: String.t()
def rewrite(html) when is_binary(html) do
Regex.replace(~r/<a\s[^>]*>/i, html, &decorate_anchor/1)
Regex.replace(@anchor, html, fn _anchor, opening, content, closing ->
{opening, content} = decorate_anchor(opening, content)
opening <> content <> closing
end)
end
defp decorate_anchor(anchor) do
if external?(href(anchor)) do
anchor
defp decorate_anchor(opening, content) do
if external?(href(opening)) do
opening =
opening
|> ensure_attribute("target", "_blank")
|> ensure_rel("noopener")
|> ensure_class(@marker_class)
{opening, ensure_hint(content)}
else
anchor
{opening, content}
end
end
defp ensure_hint(content) do
if String.contains?(content, @hint_class) do
content
else
content <> ~s|<span class="#{@hint_class}">#{@hint_text}</span>|
end
end
+1 -3
View File
@@ -38,11 +38,9 @@ defmodule Blogex.LinkError do
end
errors_list =
errors
|> Enum.map(fn {line, link, reason, _meta} ->
Enum.map_join(errors, "\n", fn {line, link, reason, _meta} ->
" line #{line}: #{link} — #{reason}"
end)
|> Enum.join("\n")
"""
invalid internal blog links in #{blog}#{post_label}
+72 -19
View File
@@ -1,6 +1,12 @@
defmodule Blogex.LinkValidator do
@moduledoc """
Validates internal blog link semantics in markdown post bodies.
Validates internal blog link semantics in compiled post bodies.
Post bodies reach this module as HTML: `Blogex.Blog` validates them in
`@before_compile`, after NimblePublisher has rendered the markdown. Markdown
link syntax is still understood so bodies and snippets can be validated
before rendering, but the HTML `<a href="…">` anchors are the ones that
matter in the real pipeline.
Checks that internal links follow the pattern `/blog/{blog_id}/{slug}`
where `blog_id` is a valid blog identifier and `slug` matches the
@@ -21,6 +27,12 @@ defmodule Blogex.LinkValidator do
* Must not contain consecutive hyphens
* Query strings and anchor fragments are allowed after the slug
## Tag page links
* `/blog/{blog_id}/tag/{tag}` links point to tag pages
* The tag may not be empty; otherwise any non-empty tag is accepted
(tags are user-defined and may contain dots, uppercase letters, etc.)
## Usage
# Validate a single link
@@ -44,28 +56,48 @@ defmodule Blogex.LinkValidator do
@slug_regex ~r/^[a-z0-9]+(-[a-z0-9]+)*$/
@doc """
Extracts internal blog links from a markdown body.
Extracts internal blog links from a post body.
Returns a list of link paths (strings) that match the pattern
`/blog/{engineering|releases}/{slug}`. External links and non-blog
internal links are ignored.
Returns a list of link paths (strings) that look like `/blog/…` links.
External links and non-blog internal links are ignored. Blog IDs and slugs
are *not* judged here — `validate_link/1` classifies those.
Handles markdown link syntax `[text](url)`.
HTML anchors are read with Floki, so attribute order, quoting and extra
attributes do not matter. Markdown link syntax `[text](url)` is extracted
too (raw markdown bodies and snippets), and the two scans are merged.
## Examples
iex> extract_links("[link](/blog/engineering/post)")
["/blog/engineering/post"]
iex> extract_links("<p><a href=\\"/blog/engineering/post\\">link</a></p>")
["/blog/engineering/post"]
iex> extract_links("<p><a class=\\"x\\" href='/blog/engineering/post'>link</a></p>")
["/blog/engineering/post"]
iex> extract_links("See [GitHub](https://github.com)")
[]
"""
@spec extract_links(String.t()) :: [String.t()]
def extract_links(body) when is_binary(body) do
(markdown_links(body) ++ html_links(body))
|> Enum.uniq()
|> Enum.filter(&internal_blog_link?/1)
end
defp markdown_links(body) do
~r/\[([^\]]+)\]\(([^)]+)\)/
|> Regex.scan(body)
|> Enum.map(fn [_, _, path] -> path end)
|> Enum.filter(&internal_blog_link?/1)
end
defp html_links(body) do
body
|> Floki.parse_document!()
|> Floki.find("a[href]")
|> Enum.flat_map(&Floki.attribute(&1, "href"))
end
defp internal_blog_link?(path) do
@@ -90,20 +122,31 @@ defmodule Blogex.LinkValidator do
iex> validate_link("/blog/engineering/My-Post")
{:error, "slug must be lowercase alphanumeric with hyphens: My-Post"}
iex> validate_link("/blog/engineering/tag/pi.dev")
:ok
"""
@spec validate_link(String.t()) :: :ok | {:error, String.t()}
def validate_link(link) when is_binary(link) do
case parse_blog_link(link) do
nil ->
{:error, "not a blog link: #{link}"}
nil -> {:error, "not a blog link: #{link}"}
{blog_id_str, path} -> validate_blog_path(blog_id_str, path)
end
end
{blog_id_str, slug_part} ->
# The blog ID must be one this app knows about; only then is the rest of the
# path worth looking at. Extracted so `validate_link/1` stays one `case` deep:
# the branching here is data (which part of the path is this?), not control.
@spec validate_blog_path(String.t(), String.t()) :: :ok | {:error, String.t()}
defp validate_blog_path(blog_id_str, path) do
case Map.fetch(@valid_blog_ids, blog_id_str) do
{:ok, _blog_atom} -> validate_slug(slug_part)
{:ok, _blog_atom} -> validate_path(path)
:error -> {:error, "unknown blog ID: #{blog_id_str}"}
end
end
end
defp validate_path("tag/" <> tag), do: validate_tag(tag)
defp validate_path(slug), do: validate_slug(slug)
@doc """
Validates a list of links, returning errors with line numbers.
@@ -179,21 +222,23 @@ defmodule Blogex.LinkValidator do
@doc false
@spec parse_blog_link(String.t()) :: {String.t(), String.t()} | nil
def parse_blog_link(path) do
with ["", "blog", blog_id, rest] <- String.split(path, "/", parts: 4) do
case String.split(path, "/", parts: 4) do
["", "blog", blog_id, rest] ->
slug = String.slice(rest, 0, slug_end(rest))
{blog_id, slug}
else
_ -> nil
_ ->
nil
end
end
@doc false
@spec slug_end(String.t()) :: integer()
defp slug_end(str) do
case String.split(str, ["?", "#"], parts: 2) do
[slug | _] -> String.length(slug)
_ -> String.length(str)
end
# `String.split/3` always returns a non-empty list whose head is a binary,
# so there is no empty/other case to handle here.
[slug | _] = String.split(str, ["?", "#"], parts: 2)
String.length(slug)
end
@doc false
@@ -209,4 +254,12 @@ defmodule Blogex.LinkValidator do
{:error, "slug must be lowercase alphanumeric with hyphens: #{slug}"}
end
end
@doc false
@spec validate_tag(String.t()) :: :ok | {:error, String.t()}
defp validate_tag(tag) when tag == "" do
{:error, "empty tag in tag link"}
end
defp validate_tag(_tag), do: :ok
end
+6 -3
View File
@@ -34,12 +34,15 @@ defmodule Blogex.Router do
use Plug.Router
alias Blogex.Router.Helpers
alias Phoenix.HTML.Safe
plug :match
plug :dispatch
get "/feed.xml" do
blog = conn.private[:blogex_blog]
base_url = Blogex.Router.Helpers.base_url(conn)
base_url = Helpers.base_url(conn)
xml = Blogex.Feed.rss(blog, base_url)
@@ -50,7 +53,7 @@ defmodule Blogex.Router do
get "/atom.xml" do
blog = conn.private[:blogex_blog]
base_url = Blogex.Router.Helpers.base_url(conn)
base_url = Helpers.base_url(conn)
xml = Blogex.Feed.atom(blog, base_url)
@@ -164,7 +167,7 @@ defmodule Blogex.Router do
defp send_html(conn, rendered) do
html =
rendered
|> Phoenix.HTML.Safe.to_iodata()
|> Safe.to_iodata()
|> IO.iodata_to_binary()
conn
+116 -1
View File
@@ -11,7 +11,9 @@ defmodule Blogex.MixProject do
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
test_coverage: test_coverage(),
docs: docs(),
package: package(),
description: "A multi-blog engine powered by NimblePublisher for Phoenix apps"
@@ -27,9 +29,16 @@ defmodule Blogex.MixProject do
]
end
def cli do
[
preferred_envs: [precommit: :test]
]
end
defp deps do
[
{:nimble_publisher, "~> 2.0"},
{:floki, "~> 0.38"},
{:makeup_elixir, ">= 0.0.0"},
{:makeup_erlang, ">= 0.0.0"},
{:phoenix, "~> 1.7"},
@@ -37,8 +46,43 @@ defmodule Blogex.MixProject do
{:phoenix_live_view, "~> 1.0"},
{:jason, "~> 1.4"},
{:plug, "~> 1.15"},
# Declared directly although unused today (no boundaries, no compiler
# entry): `ex_crap` supplies boundary transitively, and a compile-time
# tool must not arrive or vanish as a side effect of a metric tool.
{:boundary, "~> 0.11", runtime: false},
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:rustler, "~> 0.38.0", optional: true}
{:rustler, "~> 0.38.0", optional: true},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false},
{:muex, "~> 0.12", only: [:dev, :test], runtime: false}
]
end
# The coverage floor the Lens pass enforces (`make verify` -> `make coverage`
# in blogex/). Rule: the threshold is a FLOOR. Raise it as behavior-test yaks
# land; never lower it. A drop means the change (or an exclusion) is wrong.
#
# Measured 2026-10-29, with the test scaffolding below excluded: 100.00% —
# the same via `mix test --cover` and via `mix test --cover --export-coverage
# default` + `mix test.coverage`. Floor is the integer below that.
#
# Excluded: the test scaffolding in `ignore_modules` below. The two registry
# blog doubles (Blogex.Test.AlphaBlog/BetaBlog) are deliberately not listed
# there: they measure 100.00% too, so counting them costs nothing.
#
# Everything in lib/blogex counts, and nothing is uncovered: Blogex.LinkError
# was the last gap at 88.89% (8 of 9 lines) until the credo yak's tests closed
# it. The rendered-HTML behavior tests took Blogex.Components and Blogex.Layout
# from 0% to 100%, and Blogex.Router covers its browser arms and `match _`.
# A floor of 100 means every new line needs a test before `make verify` passes.
defp test_coverage do
[
summary: [threshold: 100],
ignore_modules: [
Blogex.Test.FakeBlog,
Blogex.Test.PostBuilder,
Blogex.Test.Setup
]
]
end
@@ -50,6 +94,77 @@ defmodule Blogex.MixProject do
]
end
# Same `precommit` alias name and shape as app/mix.exs, so both apps share
# one loop: compile (warnings fatal), format, test. Heavy sensors belong in
# the Lens pass (`make verify`), not here.
#
# `mutate` / `mutate.fast` are a sensor, not a gate: they are deliberately
# absent from Makefile's VERIFY_SENSORS and always pass `--fail-at 0`, so a
# survivor is reported rather than fatal. The scope lives in the plain-text
# files beside this one; the aliases read them at run time, so the text file
# stays the single source of truth.
defp aliases do
[
precommit: ["compile --warning-as-errors", "format", "test"],
mutate: &mutate/1,
"mutate.fast": &mutate_fast/1
]
end
# No macro/`use` mutators: the scoped modules are plain functions, and the
# macro-heavy modules (blog.ex, router.ex, components.ex, layout.ex) are out
# of scope entirely. Spellings are muex 0.12's CLI names.
@mutation_mutators "arithmetic,boolean,comparison,conditional,function_call,literal,return_value"
defp mutate(args), do: run_mutation("mutation_scope.txt", args)
defp mutate_fast(args), do: run_mutation("mutation_scope_fast.txt", args)
defp run_mutation(scope_file, args) do
Mix.Task.run("muex", mutation_args(scope_file, args))
end
@doc false
def mutation_args(scope_file, extra_args \\ []) do
files = read_mutation_scope(scope_file)
# `--fail-at 0` comes first so an ad-hoc override cannot turn the sensor
# into a gate. Extra args come before our defaults, and OptionParser keeps
# the first value, so `mix mutate --files lib/blogex/seo.ex` targets one
# file while `mix mutate` covers the whole scope file.
["--fail-at", "0"] ++
extra_args ++
["--files", Enum.join(files, ","), "--mutators", @mutation_mutators]
end
@doc false
def read_mutation_scope(scope_file) do
path = Path.expand(scope_file, Path.dirname(Mix.Project.project_file()))
files =
case File.read(path) do
{:ok, contents} ->
contents
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
{:error, reason} ->
Mix.raise(
"mutation scope #{scope_file} is unreadable (#{:file.format_error(reason)}); " <>
"refusing to run the mutation sensor over an unknown scope"
)
end
if files == [] do
Mix.raise(
"mutation scope #{scope_file} is empty; refusing to run the mutation sensor over nothing"
)
end
files
end
defp package do
[
licenses: ["MIT"],
+16 -9
View File
@@ -1,25 +1,32 @@
%{
"boundary": {:hex, :boundary, "0.11.0", "56cff565b5565930765e02782281b022935fe8c2e1504543c8c1b44b85893b40", [:mix], [], "hexpm", "b9297a96ea14c0e63dbd77b8135e0d483f9c6f34ed1c9dee4b3fea82c596f55a"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"},
"earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"ex_crap": {:hex, :ex_crap, "0.1.1", "c1770e7ce1843f304a27f5d32bfa867a340140e337684ee1929c770b2499f884", [:mix], [{:boundary, "~> 0.10", [hex: :boundary, repo: "hexpm", optional: false]}], "hexpm", "ada5d59bce442c1a8a987cb5328cbf4469fb56eb8d04641886f2199569a75b12"},
"ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"makeup": {:hex, :makeup, "1.2.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"},
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
"mdex_native": {:hex, :mdex_native, "0.2.4", "a74b9d799d3fc487b1f97b70ee9b04108c7af0514920d56828e173d19312c2a9", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d71185c43c5209c6939f4b65f903f71c892512c93ea6843171227855d2d976f5"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"muex": {:hex, :muex, "0.12.0", "80d8f1b2f7bfcba8ecb46b7b43dce55b555d33da54b84e74feedc4079fa2bd36", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a4b395b87d23b8d34f4508d0c13494b734f1fff8156eca2360fd279a0799d52e"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_publisher": {:hex, :nimble_publisher, "2.0.0", "f4fc100949ccdf1831094ff303b79f27c6f04b2ead829409426ee0d9ed3aa55b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:mdex_native, "~> 0.1", [hex: :mdex_native, repo: "hexpm", optional: false]}], "hexpm", "88997f74b763c24a15d20a90d7cd4ef1fd01172a2b4651e54d8021a44659f2c8"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.4.0", "be29b070ec9298df8c3c90a2ae0712d4c9e37c214f6a6ca81341bd19f1bde00b", [:mix], [], "hexpm", "80776f727460ed104c95ecddcacc5098d3cd31f40e5f05741e1219abfb20f4a8"},
"phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"},
"plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},
"plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"},
"rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
}
+8
View File
@@ -0,0 +1,8 @@
lib/blogex/link_validator.ex
lib/blogex/seo.ex
lib/blogex/feed.ex
lib/blogex/post.ex
lib/blogex/external_links.ex
lib/blogex/registry.ex
lib/blogex/not_found_error.ex
lib/blogex/link_error.ex
+3
View File
@@ -0,0 +1,3 @@
lib/blogex/link_validator.ex
lib/blogex/seo.ex
lib/blogex/post.ex
@@ -0,0 +1,8 @@
%{
title: "Draft Post (test fixture)",
author: "Test Author",
description: "A draft used by the show_drafts? tests",
published: false
}
---
This post is a draft and is hidden unless :show_drafts is on.
@@ -0,0 +1,7 @@
%{
title: "Internal Link Post",
author: "Test Author",
description: "A post that links to another post"
}
---
This post links to [hello world](/blog/engineering/hello-world) in its body.
+72 -1
View File
@@ -59,7 +59,7 @@ defmodule Blogex.BlogIntegrationTest do
""")
[{TestBlogValid, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file)
assert TestBlogValid.title() == "Test Blog"
assert apply(TestBlogValid, :title, []) == "Test Blog"
File.rm!(tmp_file)
end
@@ -93,4 +93,75 @@ defmodule Blogex.BlogIntegrationTest do
File.rm!(tmp_file)
end
end
describe "validation sees compiled post bodies" do
# `_validate_links/2` runs on `post.body` *after* NimblePublisher rendered
# the markdown, so validation only works if extraction reads the compiled
# HTML. This pins that contract against a real compiled body: if
# extraction ever goes back to scanning markdown syntax only, it fails.
test "extract_links finds internal links in a compiled post body" do
tmp_file = Path.join(System.tmp_dir!(), "test_blog_compiled_body.ex")
File.write!(tmp_file, """
defmodule TestBlogCompiledBody do
use Blogex.Blog,
blog_id: :test,
app: :blogex,
from: "priv/blog/test/2026/01-03-internal-link-post.md",
title: "Test Blog",
base_path: "/blog/test"
end
""")
[{TestBlogCompiledBody, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file)
# `apply/3`: the module is compiled at runtime, so a static call would
# warn ("module is not available") on every test compile.
[post] = apply(TestBlogCompiledBody, :all_posts, [])
# The markdown source is `[hello world](/blog/engineering/hello-world)`.
assert post.body =~ "/blog/engineering/hello-world"
assert "/blog/engineering/hello-world" in Blogex.LinkValidator.extract_links(post.body)
File.rm!(tmp_file)
end
end
describe "show_drafts?/0" do
# The only path through `Blogex.show_drafts?/0` (blogex.ex): `use Blogex.Blog`
# compiles a real module over a real draft fixture, so the flag is read by
# the generated `all_posts/0` rather than asserted in isolation.
#
# One test, one compile: compiling the same module name twice would print a
# "redefining module" warning, and warnings are fatal everywhere in this repo.
test "drafts are hidden by default and visible when :show_drafts is on" do
tmp_file = Path.join(System.tmp_dir!(), "test_blog_show_drafts.ex")
File.write!(tmp_file, """
defmodule TestBlogShowDrafts do
use Blogex.Blog,
blog_id: :test,
app: :blogex,
from: "priv/blog/test/2026/01-03-draft-post.md",
title: "Test Blog",
base_path: "/blog/test"
end
""")
[{TestBlogShowDrafts, _bytecode}] = Code.compile_file(tmp_file, __ENV__.file)
on_exit(fn ->
File.rm!(tmp_file)
Application.delete_env(:blogex, :show_drafts)
end)
assert apply(TestBlogShowDrafts, :all_posts, []) == []
Application.put_env(:blogex, :show_drafts, true)
assert [post] = apply(TestBlogShowDrafts, :all_posts, [])
assert post.title == "Draft Post (test fixture)"
end
end
end
@@ -0,0 +1,27 @@
defmodule Blogex.BoundaryDependencyTest do
use ExUnit.Case, async: true
alias Mix.Dep.Lock
# blogex declares no boundaries and does not run the boundary compiler, so
# this dependency is inert today. It is declared directly anyway (app/mix.exs
# does the same) so the compiler cannot arrive or vanish as a side effect of
# `ex_crap`, which supplies it transitively. This pins "direct, and locked to a
# version that satisfies the requirement"; if boundary is ever wired up here,
# the compile gate is that change's pin, not this test.
describe "boundary dependency" do
test "boundary is a direct dependency, not one inherited from ex_crap" do
assert {:boundary, requirement, opts} =
List.keyfind(Mix.Project.config()[:deps], :boundary, 0)
assert opts[:runtime] == false, "boundary is compile-time only; it must not ship at runtime"
assert Version.match?(locked_version(:boundary), requirement)
end
end
defp locked_version(app) do
Lock.read()
|> Map.fetch!(app)
|> elem(2)
end
end
+223
View File
@@ -0,0 +1,223 @@
defmodule Blogex.ComponentsTest do
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
import Blogex.Test.PostBuilder
@base_path "/blog/engineering"
describe "post_index/1" do
test "links each post title to its own page under base_path" do
html =
render_post_index([
build(id: "first-post", title: "First post"),
build(id: "second-post", title: "Second post")
])
assert html =~ ~s(href="#{@base_path}/first-post")
assert html =~ ">First post</a>"
assert html =~ ~s(href="#{@base_path}/second-post")
assert html =~ ">Second post</a>"
end
test "shows each post's description" do
html =
render_post_index([
build(id: "processes-post", description: "Everything about processes"),
build(id: "supervision-post", description: "Everything about supervision")
])
assert html =~ "Everything about processes"
assert html =~ "Everything about supervision"
end
test "shows date, author and tags for each post" do
html =
render_post_index([
build(
id: "authored-post",
date: ~D[2026-03-10],
author: "Ada Lovelace",
tags: ["elixir"]
)
])
assert html =~ "March 10, 2026"
assert html =~ "by Ada Lovelace"
assert html =~ ~s(href="#{@base_path}/tag/elixir")
end
end
describe "post_show/1" do
test "renders the post title as the page heading" do
html = render_post_show(build(id: "heading-post", title: "Rendering Rendered HTML"))
assert html =~ "<h1>Rendering Rendered HTML</h1>"
end
test "renders the body as markup, not escaped text" do
html =
render_post_show(build(id: "markup-post", body: "<p>Hello <strong>world</strong></p>"))
assert html =~ "<p>Hello <strong>world</strong></p>"
refute html =~ "&lt;p&gt;"
refute html =~ "&lt;strong&gt;"
end
test "shows the post's date, author and tags" do
html =
render_post_show(
build(
id: "meta-post",
date: ~D[2026-03-10],
author: "Grace Hopper",
tags: ["compilers", "navy"]
)
)
assert html =~ ~s(<time datetime="2026-03-10">)
assert html =~ "March 10, 2026"
assert html =~ "by Grace Hopper"
assert html =~ ~s(href="#{@base_path}/tag/compilers")
assert html =~ ~s(href="#{@base_path}/tag/navy")
end
end
describe "post_meta/1" do
test "renders the date as a machine-readable time element and a human date" do
html = render_post_meta(build(date: ~D[2026-03-10]))
assert html =~ ~s(<time datetime="2026-03-10">)
assert html =~ "March 10, 2026"
end
test "shows the author when the post has one" do
html = render_post_meta(build(author: "Ada Lovelace"))
assert html =~ ~s(<span class="blogex-post-author">)
assert html =~ "by Ada Lovelace"
end
test "has no author line when the post has no author" do
html = render_post_meta(build(author: nil))
refute html =~ "blogex-post-author"
refute html =~ "by "
end
test "links every tag under base_path" do
html = render_post_meta(build(tags: ["elixir", "otp"]))
assert html =~ ~s(href="#{@base_path}/tag/elixir")
assert html =~ ~s(href="#{@base_path}/tag/otp")
end
test "marks only the current tag as active" do
html = render_post_meta(build(tags: ["elixir", "otp"]), current_tag: "elixir")
assert occurrences(html, "blogex-tag-active") == 1
assert [_, after_active] =
String.split(html, ~s(class="blogex-tag-link blogex-tag-active"))
assert after_active =~ ~r/^>\s*elixir\s*<\/a>/
end
end
describe "tag_list/1" do
test "links every tag under base_path" do
html = render_tag_list(["elixir", "otp"])
assert html =~ ~s(href="#{@base_path}/tag/elixir")
assert html =~ ~s(href="#{@base_path}/tag/otp")
end
test "marks only the current tag as active" do
html = render_tag_list(["elixir", "otp"], current_tag: "elixir")
assert occurrences(html, "blogex-tag-active") == 1
assert [_, after_active] =
String.split(html, ~s(class="blogex-tag-link blogex-tag-active"))
assert after_active =~ ~r/^>\s*elixir\s*<\/a>/
end
end
describe "pagination/1" do
test "renders nothing when there is only one page" do
assert render_pagination(page: 1, total_pages: 1) |> String.trim() == ""
end
test "on the first of several pages there is an Older link and no Newer link" do
html = render_pagination(page: 1, total_pages: 3)
assert html =~ "Page 1 of 3"
assert html =~ ~s(href="#{@base_path}?page=2")
assert html =~ "Older"
refute html =~ "Newer"
end
test "on a middle page there are both Newer and Older links" do
html = render_pagination(page: 2, total_pages: 3)
assert html =~ "Page 2 of 3"
assert html =~ ~s(href="#{@base_path}?page=1")
assert html =~ ~s(href="#{@base_path}?page=3")
assert html =~ "Newer"
assert html =~ "Older"
end
test "on the last page there is a Newer link and no Older link" do
html = render_pagination(page: 3, total_pages: 3)
assert html =~ "Page 3 of 3"
assert html =~ ~s(href="#{@base_path}?page=2")
assert html =~ "Newer"
refute html =~ "Older"
end
end
defp render_post_index(posts, current_tag \\ nil) do
render_component(&Blogex.Components.post_index/1,
posts: posts,
base_path: @base_path,
current_tag: current_tag
)
end
defp render_post_show(post) do
render_component(&Blogex.Components.post_show/1, post: post, base_path: @base_path)
end
defp render_post_meta(post, opts \\ []) do
render_component(&Blogex.Components.post_meta/1,
post: post,
base_path: @base_path,
current_tag: opts[:current_tag]
)
end
defp render_tag_list(tags, opts \\ []) do
render_component(&Blogex.Components.tag_list/1,
tags: tags,
base_path: @base_path,
current_tag: opts[:current_tag]
)
end
defp render_pagination(opts) do
render_component(&Blogex.Components.pagination/1,
page: opts[:page],
total_pages: opts[:total_pages],
base_path: @base_path
)
end
defp occurrences(html, token) do
html
|> String.split(token)
|> length()
|> Kernel.-(1)
end
end
+50 -2
View File
@@ -10,7 +10,7 @@ defmodule Blogex.ExternalLinksTest do
rewritten = ExternalLinks.rewrite(html)
assert rewritten ==
~s(<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia</a>.</p>)
~s|<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>.</p>|
end
test "also rewrites external http links" do
@@ -26,7 +26,7 @@ defmodule Blogex.ExternalLinksTest do
rewritten = ExternalLinks.rewrite(html)
assert rewritten ==
~s(<p><a href="https://a.example/" target="_blank" rel="noopener" class="blogex-external">a</a> and <a href="https://b.example/" target="_blank" rel="noopener" class="blogex-external">b</a></p>)
~s|<p><a href="https://a.example/" target="_blank" rel="noopener" class="blogex-external">a<span class="blogex-external-hint"> (opens in a new tab)</span></a> and <a href="https://b.example/" target="_blank" rel="noopener" class="blogex-external">b<span class="blogex-external-hint"> (opens in a new tab)</span></a></p>|
end
test "preserves other attributes on the anchor" do
@@ -56,6 +56,48 @@ defmodule Blogex.ExternalLinksTest do
end
end
describe "rewrite/1 accessible hint" do
test "appends a visually hidden hint inside the external anchor" do
html = ~s(<p>See <a href="https://en.wikipedia.org/wiki/Blog">Wikipedia</a>.</p>)
assert ExternalLinks.rewrite(html) ==
~s|<p>See <a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>.</p>|
end
test "adds one hint per external link" do
html = ~s(<a href="https://a.example/">a</a> and <a href="https://b.example/">b</a>)
assert html |> ExternalLinks.rewrite() |> hints() == 2
end
test "places the hint after inner markup, keeping it untouched" do
html = ~s(<a href="https://example.com/"><code>docs</code> and more</a>)
assert ExternalLinks.rewrite(html) =~
~s|<code>docs</code> and more<span class="blogex-external-hint"> (opens in a new tab)</span></a>|
end
test "does not add a second hint when applied twice" do
once = ExternalLinks.rewrite(~s(<a href="https://example.com/">once</a>))
twice = ExternalLinks.rewrite(once)
assert twice == once
assert hints(twice) == 1
end
test "adds no hint to links that are not external" do
for html <- [
~s(<a href="/blog/engineering/swim-the-test">internal</a>),
~s(<a href="mailto:hi@example.com">mail</a>),
~s(<a href="#section">jump</a>)
] do
assert ExternalLinks.rewrite(html) == html
end
end
defp hints(html), do: length(Regex.scan(~r/class="blogex-external-hint"/, html))
end
describe "rewrite/1 leaves other links untouched" do
test "internal relative links are unchanged" do
html = ~s(<a href="/blog/engineering/swim-the-test">Swim the Test</a>)
@@ -75,6 +117,12 @@ defmodule Blogex.ExternalLinksTest do
assert ExternalLinks.rewrite(html) == html
end
test "anchors without an href are unchanged" do
html = ~s(<a name="top">top</a>)
assert ExternalLinks.rewrite(html) == html
end
test "plain text without anchors is unchanged" do
assert ExternalLinks.rewrite("<p>no links here</p>") == "<p>no links here</p>"
end
+132
View File
@@ -0,0 +1,132 @@
defmodule Blogex.LayoutTest do
use ExUnit.Case, async: true
import Phoenix.LiveViewTest
import Blogex.Test.PostBuilder
@base_path "/blog/engineering"
@blog_title "Engineering Blog"
@blog_description "Notes from the platform team"
describe "page/1" do
test "titles the document and wraps the inner content" do
html = render_page("A document title")
assert html =~ "<!DOCTYPE html>"
assert html =~ ~s(<html lang="en">)
assert html =~ "<title>A document title</title>"
end
test "renders inner_content as markup, not escaped text" do
html = render_page("A document title", Phoenix.HTML.raw("<p>inner</p>"))
assert html =~ "<p>inner</p>"
refute html =~ "&lt;p&gt;"
end
end
describe "index_page/1" do
test "shows the blog title and description" do
html = render_index_page([])
assert html =~ "<h1>#{@blog_title}</h1>"
assert html =~ @blog_description
end
test "lists the posts with their titles linked" do
html = render_index_page([build(id: "first-post", title: "First post")])
assert html =~ ~s(href="#{@base_path}/first-post")
assert html =~ ">First post</a>"
end
test "shows no pagination when the blog has a single page" do
html = render_index_page([build(id: "only-post")], page: 1, total_pages: 1)
refute html =~ "Older"
refute html =~ "Newer"
refute html =~ "Page 1 of 1"
end
test "shows pagination when there is more than one page" do
html = render_index_page([build(id: "paged-post")], page: 2, total_pages: 3)
assert html =~ "Page 2 of 3"
end
end
describe "show_page/1" do
test "uses the post title as the document title" do
post = build(id: "titled-post", title: "A Post About Layout")
assert render_show_page(post) =~ "<title>#{post.title}</title>"
end
test "offers a link back to the blog index" do
html = render_show_page(build())
assert html =~ ~s(<a href="#{@base_path}">&larr; Back</a>)
end
test "renders the post body as markup, not escaped text" do
html = render_show_page(build(body: "<p>Hello <strong>world</strong></p>"))
assert html =~ "<p>Hello <strong>world</strong></p>"
refute html =~ "&lt;p&gt;"
refute html =~ "&lt;strong&gt;"
end
end
describe "tag_page/1" do
test "titles the page with the blog title and the tag" do
html = render_tag_page(tag: "elixir", posts: [])
assert html =~ "<title>#{@blog_title} — #elixir</title>"
end
test "announces the tag and lists the posts it was given" do
html =
render_tag_page(
tag: "elixir",
posts: [
build(id: "tagged-post", title: "Tagged post"),
build(id: "another-tagged-post", title: "Another tagged post")
]
)
assert html =~ ~s(<h1>Posts tagged "elixir"</h1>)
assert html =~ ~s(href="#{@base_path}/tagged-post")
assert html =~ ">Tagged post</a>"
assert html =~ ~s(href="#{@base_path}/another-tagged-post")
assert html =~ ">Another tagged post</a>"
end
end
defp render_page(title, inner_content \\ Phoenix.HTML.raw("<p>inner</p>")) do
render_component(&Blogex.Layout.page/1, title: title, inner_content: inner_content)
end
defp render_index_page(posts, opts \\ []) do
render_component(&Blogex.Layout.index_page/1,
blog_title: @blog_title,
blog_description: @blog_description,
posts: posts,
base_path: @base_path,
page: Keyword.get(opts, :page, 1),
total_pages: Keyword.get(opts, :total_pages, 1)
)
end
defp render_show_page(post) do
render_component(&Blogex.Layout.show_page/1, post: post, base_path: @base_path)
end
defp render_tag_page(opts) do
render_component(&Blogex.Layout.tag_page/1,
blog_title: @blog_title,
tag: opts[:tag],
posts: opts[:posts],
base_path: @base_path
)
end
end
+44
View File
@@ -0,0 +1,44 @@
defmodule Blogex.LinkErrorTest do
@moduledoc """
Pins the rendered exception message, not just the struct.
The message is what a compile-time failure shows a human: the indentation and
the newline between errors are load-bearing. This is a characterization test
written before `message/1` was rewritten (`Enum.map/2 |> Enum.join/2` ->
`Enum.map_join/3`), so it passes on both sides of that change by design - it
exists to make the rewrite safe, not to prove new behaviour.
"""
use ExUnit.Case, async: true
test "message/1 renders one indented line per error" do
error = %Blogex.LinkError{
blog: :engineering,
post_id: "my-post",
errors: [
{1, "/blog/unknown/broken", "unknown blog ID: unknown", [post_id: "my-post"]},
{7, "/blog/engineering/", "empty slug", [post_id: "my-post"]}
]
}
assert Exception.message(error) == """
invalid internal blog links in engineering (post: my-post)
line 1: /blog/unknown/broken — unknown blog ID: unknown
line 7: /blog/engineering/ — empty slug
"""
end
test "message/1 omits the post label when there is no post id" do
error = %Blogex.LinkError{
blog: :engineering,
post_id: nil,
errors: [{3, "/blog/unknown/broken", "unknown blog ID: unknown", []}]
}
assert Exception.message(error) == """
invalid internal blog links in engineering
line 3: /blog/unknown/broken — unknown blog ID: unknown
"""
end
end
+130
View File
@@ -13,6 +13,37 @@ defmodule Blogex.LinkValidatorTest do
]
end
test "extracts internal blog links from HTML body" do
body =
~s(<p>Check out <a href="/blog/engineering/hello-world">hello world</a> and <a href="/blog/releases/v1-0-0">release v1</a>.</p>)
assert LinkValidator.extract_links(body) == [
"/blog/engineering/hello-world",
"/blog/releases/v1-0-0"
]
end
test "extracts tag page links from HTML body" do
body = ~s(<p>See <a href="/blog/engineering/tag/pi.dev">pi.dev posts</a>.</p>)
assert LinkValidator.extract_links(body) == ["/blog/engineering/tag/pi.dev"]
end
# HTML anchors are not "the href right after <a (" - they are whatever the
# renderer emits. Pinned with the shapes a hand-rolled scan misses.
test "extracts internal blog links whatever the anchor's attributes look like" do
body =
~s(<p><a class="post-link" href="/blog/engineering/first">first</a>) <>
~s( <a href=/blog/releases/second>second</a>) <>
~s( <a data-x="1" aria-label="third" href='/blog/engineering/third'>third</a></p>)
assert LinkValidator.extract_links(body) == [
"/blog/engineering/first",
"/blog/releases/second",
"/blog/engineering/third"
]
end
test "ignores external links" do
body = "See [GitHub](https://github.com) and [internal](/blog/engineering/post)."
@@ -132,6 +163,31 @@ defmodule Blogex.LinkValidatorTest do
assert LinkValidator.validate_link("/blog/engineering/post#section") == :ok
end
test "allows tag page links" do
assert LinkValidator.validate_link("/blog/engineering/tag/pi.dev") == :ok
assert LinkValidator.validate_link("/blog/releases/tag/v1") == :ok
end
test "allows tag page links with fragments" do
assert LinkValidator.validate_link("/blog/engineering/tag/pi.dev#top") == :ok
end
# Tags are user-defined: the documented rule restricts *slugs*, not tags.
test "accepts tag links with dots and capitals" do
assert LinkValidator.validate_link("/blog/engineering/tag/pi.dev") == :ok
assert LinkValidator.validate_link("/blog/engineering/tag/Pi.dev") == :ok
end
test "rejects tag page link with empty tag" do
assert LinkValidator.validate_link("/blog/engineering/tag/") ==
{:error, "empty tag in tag link"}
end
test "rejects unknown blog ID in tag page link" do
assert LinkValidator.validate_link("/blog/unknown/tag/pi.dev") ==
{:error, "unknown blog ID: unknown"}
end
test "rejects non-blog path" do
assert LinkValidator.validate_link("/about") ==
{:error, "not a blog link: /about"}
@@ -189,6 +245,24 @@ defmodule Blogex.LinkValidatorTest do
end
describe "validate_body/2" do
# The guard is a conjunction, not a disjunction: both arguments must have
# the documented types. Mutating `and` to `or` would let a string blog id
# (or a non-binary body) through the guard to crash somewhere else, so pin
# the contract at the door.
test "rejects a non-atom blog id at the function clause guard" do
assert_raise FunctionClauseError, fn ->
# apply/3 keeps the compiler's type checker quiet: the point is the
# runtime guard, and the test deliberately passes an invalid type.
apply(LinkValidator, :validate_body, ["Just text, no links.", "engineering"])
end
end
test "rejects a non-binary body at the function clause guard" do
assert_raise FunctionClauseError, fn ->
apply(LinkValidator, :validate_body, [[], :engineering])
end
end
test "returns :ok when body has no internal blog links" do
body = "Just text, no links."
@@ -232,5 +306,61 @@ defmodule Blogex.LinkValidatorTest do
]
}
end
# `_validate_links/2` hands this module `post.body`, which NimblePublisher
# already rendered to HTML, so failure reporting has to work on HTML bodies.
# These are the two reports the compile-time integration tests depend on.
test "reports an unknown blog ID in an HTML body" do
body = ~s(<p><a href="/blog/unknown/broken">bad</a></p>)
assert LinkValidator.validate_body(body, :engineering, post_id: "html-post") == {
:error,
[
{
1,
"/blog/unknown/broken",
"unknown blog ID: unknown",
post_id: "html-post"
}
]
}
end
test "reports an invalid slug in an HTML body" do
body = ~s(<p><a href="/blog/engineering/My-Post">bad</a></p>)
assert LinkValidator.validate_body(body, :engineering, post_id: "html-post") == {
:error,
[
{
1,
"/blog/engineering/My-Post",
"slug must be lowercase alphanumeric with hyphens: My-Post",
post_id: "html-post"
}
]
}
end
end
describe "parse_blog_link/1" do
# `slug_end/1` decides where the slug stops so `?query` / `#fragment` are
# not validated as part of it. Pinned directly on `parse_blog_link/1`
# (both are `@doc false`) because `validate_link/1` only reaches them via
# "valid slug" cases, and a wrong cut would still validate by luck.
test "strips query strings and fragments from the slug" do
assert LinkValidator.parse_blog_link("/blog/engineering/hello-world?utm=1") ==
{"engineering", "hello-world"}
assert LinkValidator.parse_blog_link("/blog/engineering/hello-world#top") ==
{"engineering", "hello-world"}
end
# Only the paths that cannot be a blog link return nil; a nested slug
# ("/blog/eng/a/post") still parses and is rejected later by validate_slug/1.
test "returns nil for anything that is not /blog/<blog>/<slug>" do
refute LinkValidator.parse_blog_link("/blog/engineering")
refute LinkValidator.parse_blog_link("/notblog/engineering/a-post")
end
end
end
+1 -1
View File
@@ -51,7 +51,7 @@ defmodule Blogex.PostTest do
post = Post.build("x/2026/01-01-x.md", valid_attrs(), html)
assert post.body =~
~s(<a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia</a>)
~s|<a href="https://en.wikipedia.org/wiki/Blog" target="_blank" rel="noopener" class="blogex-external">Wikipedia<span class="blogex-external-hint"> (opens in a new tab)</span></a>|
end
test "leaves internal links in stored body untouched" do
+1 -36
View File
@@ -1,43 +1,8 @@
defmodule Blogex.RegistryTest do
use ExUnit.Case
import Blogex.Test.PostBuilder
alias Blogex.Registry
defmodule AlphaBlog do
def blog_id, do: :alpha
def all_posts,
do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)]
def all_tags, do: ["elixir"]
def unfiltered_posts,
do: [
Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha),
Blogex.Test.PostBuilder.build(
id: "a-draft",
date: ~D[2026-03-05],
blog: :alpha,
published: false
)
]
end
defmodule BetaBlog do
def blog_id, do: :beta
def all_posts,
do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)]
def all_tags, do: ["devops"]
def unfiltered_posts,
do: [
Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta),
Blogex.Test.PostBuilder.build(id: "b-future", date: ~D[2099-01-01], blog: :beta)
]
end
alias Blogex.Test.{AlphaBlog, BetaBlog}
setup do
Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog])
+104 -1
View File
@@ -1,6 +1,7 @@
defmodule Blogex.RouterTest do
use ExUnit.Case
use Plug.Test
import Plug.Test
import Plug.Conn, only: [put_req_header: 3]
import Blogex.Test.PostBuilder
alias Blogex.Test.FakeBlog
@@ -35,6 +36,18 @@ defmodule Blogex.RouterTest do
|> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog))
end
defp call_html(path) do
conn(:get, path)
|> put_req_header("accept", "text/html")
|> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog))
end
defp call_json(path) do
conn(:get, path)
|> put_req_header("accept", "application/json")
|> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog))
end
describe "GET /feed.xml" do
test "returns RSS XML" do
conn = call(:get, "/feed.xml")
@@ -153,6 +166,96 @@ defmodule Blogex.RouterTest do
end
end
# The browser arms were previously unexercised: every test above goes through
# `call/2`, which sends no Accept header, so `wants_html?/1` was always false.
describe "GET / (browser)" do
test "returns an HTML page with the blog title and the published posts" do
conn = call_html("/")
assert conn.status == 200
assert get_content_type(conn) =~ "text/html"
assert conn.resp_body =~ "Test Blog"
assert conn.resp_body =~ "First"
assert conn.resp_body =~ ~s(href="/blog/test/first-post")
refute conn.resp_body =~ "future-post"
end
test "leaves JSON clients alone" do
conn = call_json("/")
assert get_content_type(conn) =~ "application/json"
body = Jason.decode!(conn.resp_body)
assert body["title"] == "Test Blog"
assert body["total_entries"] == 2
end
end
describe "GET /:slug (browser)" do
test "returns the post as an HTML page" do
conn = call_html("/first-post")
assert conn.status == 200
assert get_content_type(conn) =~ "text/html"
assert conn.resp_body =~ "First"
assert conn.resp_body =~ "<p>Post body.</p>"
refute conn.resp_body =~ "&lt;p&gt;"
end
test "leaves JSON clients alone" do
conn = call_json("/first-post")
assert get_content_type(conn) =~ "application/json"
assert Jason.decode!(conn.resp_body)["id"] == "first-post"
end
end
describe "GET /tag/:tag (browser)" do
test "returns HTML with only the posts carrying that tag" do
conn = call_html("/tag/elixir")
assert conn.status == 200
assert get_content_type(conn) =~ "text/html"
assert conn.resp_body =~ "First"
refute conn.resp_body =~ "Second"
end
test "leaves JSON clients alone" do
conn = call_json("/tag/elixir")
assert get_content_type(conn) =~ "application/json"
assert Jason.decode!(conn.resp_body)["tag"] == "elixir"
end
end
describe "unknown routes" do
test "answers 404 Not found" do
conn = call(:post, "/whatever")
assert conn.status == 404
assert conn.resp_body == "Not found"
end
end
describe "Helpers.base_url/1" do
# `port_suffix/2` has one clause per (scheme, default port) pair plus the
# catch-all, and the feeds embed this URL. All four shapes are pinned so a
# clause cannot be dropped or reordered without a failure.
test "omits the port for the default scheme ports" do
assert base_url(:http, 80) == "http://www.example.com"
assert base_url(:https, 443) == "https://www.example.com"
end
test "keeps a non-default port" do
assert base_url(:http, 4000) == "http://www.example.com:4000"
assert base_url(:https, 8443) == "https://www.example.com:8443"
end
defp base_url(scheme, port) do
%{Plug.Test.conn(:get, "/") | scheme: scheme, port: port}
|> Blogex.Router.Helpers.base_url()
end
end
defp get_content_type(conn) do
conn
|> Plug.Conn.get_resp_header("content-type")
+67
View File
@@ -0,0 +1,67 @@
defmodule BlogexTest do
@moduledoc """
Behaviour tests for the `Blogex` facade.
The facade is a thin delegating layer over `Blogex.Registry`, but callers
(Phoenix controllers, LiveViews) only ever see the answers, so these tests
assert the answers: the exact module lists, post orderings and tag sets the
fixtures imply. Asserting `Blogex.x() == Blogex.Registry.x()` would pin the
delegation instead of the behaviour and would keep passing if both sides
drifted together.
Non-async: the registry is configured through the process-global `:blogex`
application env.
"""
use ExUnit.Case
alias Blogex.Test.{AlphaBlog, BetaBlog}
setup do
Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog])
on_exit(fn -> Application.delete_env(:blogex, :blogs) end)
end
describe "blogs/0" do
test "returns the configured blog modules" do
assert Blogex.blogs() == [AlphaBlog, BetaBlog]
end
end
describe "get_blog!/1 and get_blog/1" do
test "return the module registered for a blog_id" do
assert Blogex.get_blog!(:alpha) == AlphaBlog
assert Blogex.get_blog(:beta) == BetaBlog
end
test "an unknown blog_id returns nil and raises" do
assert Blogex.get_blog(:nope) == nil
assert_raise Blogex.NotFoundError, fn -> Blogex.get_blog!(:nope) end
end
end
describe "all_posts/0" do
# Newest first across both blogs: beta's 2026-03-15 post, then alpha's
# 2026-03-01. Drafts (a-draft) and future-dated posts (b-future) are not
# visible to readers, so neither appears.
test "merges published posts from every blog, newest first" do
assert ids(Blogex.all_posts()) == ["b1", "a1"]
end
end
describe "all_posts_unfiltered/0" do
# Everything, newest first: the 2099 scheduled post, then b1, the alpha
# draft, and alpha's oldest.
test "includes drafts and future-dated posts, newest first" do
assert ids(Blogex.all_posts_unfiltered()) == ["b-future", "b1", "a-draft", "a1"]
end
end
describe "all_tags/0" do
test "returns the unique tags across blogs, sorted" do
assert Blogex.all_tags() == ["devops", "elixir"]
end
end
defp ids(posts), do: Enum.map(posts, & &1.id)
end
+66
View File
@@ -0,0 +1,66 @@
defmodule Blogex.CredoConfigTest do
@moduledoc """
Pins the *policy* in `.credo.exs`, not just the file's presence.
The gate is `mix credo --strict` (see the `credo` target in `Makefile`), so
the config is load-bearing: a check silently re-enabled here changes what
every future commit has to satisfy. These assertions are deliberately coarse
- "this check sits in that list", "the disable says why" - so they survive
credo's own boilerplate churn but not a policy change.
"""
use ExUnit.Case, async: true
@config_path ".credo.exs"
@complexity_check "Credo.Check.Refactor.CyclomaticComplexity"
test "cyclomatic complexity is disabled in favour of CRAP" do
checks = default_config()[:checks]
assert @complexity_check in check_modules(checks[:disabled])
refute @complexity_check in check_modules(checks[:enabled])
end
test "the disable carries a rationale comment" do
rationale = rationale_above(@complexity_check)
refute rationale == [], "no comment block above the #{@complexity_check} entry"
assert Enum.join(rationale, "\n") =~ ~r/crap/i
end
defp default_config do
{config, _bindings} = Code.eval_file(@config_path)
Enum.find(config.configs, &(&1.name == "default"))
end
defp check_modules(checks) do
Enum.map(checks, fn
{module, _opts} -> module_name(module)
module -> module_name(module)
end)
end
# `Atom.to_string/1` keeps the "Elixir." prefix that `inspect/1` drops; the
# assertions read better (and diff better) without it.
defp module_name(module) do
module |> Atom.to_string() |> String.replace_prefix("Elixir.", "")
end
# The contiguous `#` block immediately above the check's own line. The entry
# line is the one that is not itself a comment: a comment mentioning the
# check by name must not be mistaken for the entry.
defp rationale_above(check_name) do
lines = @config_path |> File.read!() |> String.split("\n")
index =
Enum.find_index(lines, fn line ->
String.contains?(line, check_name) and not comment?(line)
end)
lines
|> Enum.take(index)
|> Enum.reverse()
|> Enum.take_while(&comment?/1)
end
defp comment?(line), do: line |> String.trim() |> String.starts_with?("#")
end
+82
View File
@@ -0,0 +1,82 @@
defmodule Blogex.MutationConfigTest do
# The mutation sensor is opt-in: a dependency and two aliases, deliberately
# absent from `make verify`'s VERIFY_SENSORS. These pins keep the wiring
# honest (dev/test only, never runtime) and keep the aliases reading the
# scope files rather than duplicating a list in mix.exs.
use ExUnit.Case
test "muex is a dev/test-only, non-runtime dependency" do
deps = Mix.Project.config()[:deps]
assert {:muex, requirement, opts} = dep!(deps, :muex)
assert requirement =~ "0.12"
assert opts[:only] == [:dev, :test]
assert opts[:runtime] == false
end
test "mutate and mutate.fast aliases are defined" do
aliases = Mix.Project.config()[:aliases]
assert is_function(aliases[:mutate], 1)
assert is_function(aliases[:"mutate.fast"], 1)
end
test "mutation args always include the scope, the curated mutators and --fail-at 0" do
args = Blogex.MixProject.mutation_args("mutation_scope_fast.txt", [])
scope = Blogex.MixProject.read_mutation_scope("mutation_scope_fast.txt")
assert "--fail-at" in args
assert Enum.at(args, Enum.find_index(args, &(&1 == "--fail-at")) + 1) == "0"
assert files_arg(args) == Enum.join(scope, ",")
mutators = Enum.at(args, Enum.find_index(args, &(&1 == "--mutators")) + 1)
assert mutators ==
"arithmetic,boolean,comparison,conditional,function_call,literal,return_value"
end
test "extra CLI args pass through so a single file can be targeted" do
args =
Blogex.MixProject.mutation_args("mutation_scope_fast.txt", ["--files", "lib/blogex/seo.ex"])
assert args
|> Enum.chunk_every(2, 1, :discard)
|> Enum.any?(fn
["--files", "lib/blogex/seo.ex"] -> true
_ -> false
end)
end
test "an empty scope fails loudly instead of running over nothing" do
path =
Path.join(System.tmp_dir!(), "muex-empty-scope-#{System.unique_integer([:positive])}.txt")
File.write!(path, "\n \n")
on_exit(fn -> File.rm(path) end)
assert_raise Mix.Error, ~r/empty/, fn ->
Blogex.MixProject.read_mutation_scope(path)
end
end
defp dep!(deps, name) do
case Enum.find(deps, fn
{^name, _requirement} -> true
{^name, _requirement, _opts} -> true
_ -> false
end) do
{^name, requirement} -> {name, requirement, []}
{^name, requirement, opts} -> {name, requirement, opts}
nil -> flunk("expected #{inspect(name)} in deps/0")
end
end
defp files_arg(args) do
args
|> Enum.chunk_every(2, 1, :discard)
|> Enum.find_value(fn
["--files", files] -> files
_ -> nil
end)
end
end
+64
View File
@@ -0,0 +1,64 @@
defmodule Blogex.MutationScopeTest do
# The mutation scope files are the single source of truth for what `mix
# mutate` / `mix mutate.fast` are allowed to touch. These pins keep the
# sensor honest: a scope that quietly shrinks to an empty (or stale) set
# would report a score for a check it never ran.
use ExUnit.Case
@root Path.expand("..", __DIR__)
@full "mutation_scope.txt"
@fast "mutation_scope_fast.txt"
# Pure modules only. These are excluded deliberately:
# blogex.ex facade + moduledoc, nothing to mutate
# blogex/blog.ex the `__using__` macro (NimblePublisher expansion)
# router.ex Phoenix router DSL
# components.ex Phoenix component DSL
# layout.ex Phoenix/LiveView layout DSL
@shell_modules [
"lib/blogex.ex",
"lib/blogex/blog.ex",
"lib/blogex/router.ex",
"lib/blogex/components.ex",
"lib/blogex/layout.ex"
]
test "full scope lists only existing files" do
missing = Enum.reject(scope(@full), &File.exists?(Path.join(@root, &1)))
assert missing == [],
"mutation scope names files that do not exist: #{inspect(missing)}"
end
test "full scope is not empty" do
assert scope(@full) != []
end
test "fast scope is not empty" do
assert scope(@fast) != []
end
test "fast scope is a subset of full scope" do
out_of_scope = MapSet.difference(MapSet.new(scope(@fast)), MapSet.new(scope(@full)))
assert MapSet.size(out_of_scope) == 0,
"mutate.fast names files outside the audited scope: #{inspect(MapSet.to_list(out_of_scope))}"
end
test "scope excludes the macro and shell modules" do
scoped = MapSet.new(scope(@full) ++ scope(@fast))
present = Enum.filter(@shell_modules, &MapSet.member?(scoped, &1))
assert present == [],
"shell/macro modules belong outside the mutation scope: #{inspect(present)}"
end
defp scope(file) do
@root
|> Path.join(file)
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
end
+53
View File
@@ -0,0 +1,53 @@
defmodule Blogex.Test.AlphaBlog do
@moduledoc """
A minimal blog module for cross-blog tests.
Implements the interface `Blogex.Registry` queries (and nothing else), so a
test can configure `:blogex, :blogs` with real modules without compiling a
`use Blogex.Blog` module or touching the filesystem.
`all_posts/0`/`unfiltered_posts/0` are hard-coded — the dates are literals so
tests can assert exact orderings rather than re-deriving them.
"""
alias Blogex.Test.PostBuilder
def blog_id, do: :alpha
def all_posts,
do: [PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)]
def all_tags, do: ["elixir"]
def unfiltered_posts,
do: [
PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha),
PostBuilder.build(
id: "a-draft",
date: ~D[2026-03-05],
blog: :alpha,
published: false
)
]
end
defmodule Blogex.Test.BetaBlog do
@moduledoc """
The second blog module for cross-blog tests; see `Blogex.Test.AlphaBlog`.
"""
alias Blogex.Test.PostBuilder
def blog_id, do: :beta
def all_posts,
do: [PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)]
def all_tags, do: ["devops"]
def unfiltered_posts,
do: [
PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta),
PostBuilder.build(id: "b-future", date: ~D[2099-01-01], blog: :beta)
]
end
+4 -2
View File
@@ -5,6 +5,8 @@ defmodule Blogex.Test.Setup do
import Blogex.Test.PostBuilder
alias Blogex.Test.FakeBlog
@doc """
Starts a FakeBlog with a standard set of posts.
Returns `%{blog: module, posts: posts}`.
@@ -13,9 +15,9 @@ defmodule Blogex.Test.Setup do
posts = Keyword.get(opts, :posts, default_posts())
blog_opts = Keyword.drop(opts, [:posts])
{:ok, _} = Blogex.Test.FakeBlog.start(posts, blog_opts)
{:ok, _} = FakeBlog.start(posts, blog_opts)
Map.merge(context, %{blog: Blogex.Test.FakeBlog, posts: posts})
Map.merge(context, %{blog: FakeBlog, posts: posts})
end
@doc "A small set of posts covering common scenarios."
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Some files were not shown because too many files have changed in this diff Show More