Compare commits

...
71 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
97 changed files with 4768 additions and 299 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
+4
View File
@@ -20,5 +20,9 @@ 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
+81 -31
View File
@@ -1,37 +1,87 @@
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) {
let inFlight: AbortController | undefined;
pi.on("session_start", async (_event, ctx) => {
try {
const { stdout, stderr, code } = await pi.exec("make", ["test"]);
const output = stdout + stderr;
// 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);
});
});
if (code === 0) {
ctx.ui.notify("make test passed", "info");
} else {
ctx.ui.notify(`make test failed (exit ${code})`, "error");
}
// Also send the output as a message so it's visible in the session
pi.sendMessage({
customType: "run-make-test",
content: [
{
type: "text",
text: `Here's the result of running \`make test\`:\n\n\`\`\`\n${output}\`\`\``,
},
],
display: true,
}, { deliverAs: "followUp" });
} catch (error) {
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" });
}
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 {
ctx.ui.notify(`make test failed (exit ${code})`, "error");
}
// Also send the output as a message so it's visible in the session
pi.sendMessage({
customType: "run-make-test",
content: [
{
type: "text",
text: `Here's the result of running \`make test\`:\n\n\`\`\`\n${output}\`\`\``,
},
],
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);
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
+128 -9
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
# =============================================================================
@@ -94,4 +171,46 @@ playwright-full: playwright-docker-start
# Run Credo static analysis
credo:
$(MISE_EXEC) mix credo --strict
$(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;
}
+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"
@@ -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.
"""
+179 -6
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.
@@ -50,7 +66,14 @@ defmodule Firehose.MixProject do
{:phoenix_live_reload, "~> 1.2", only: :dev},
{: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,
@@ -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
+26 -19
View File
@@ -1,22 +1,26 @@
%{
"bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [: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.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"},
"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"},
"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.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
"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.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.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"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.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]},
@@ -24,38 +28,41 @@
"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.2", "882d46dc0905e9ff7abf2aab61a7e6b3dcc555533977d8a23b06019e6c89ac94", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9a1a24e5b343b8ae16abea0822c10a6f75da27af7fa802ada5251f7579bfccfa"},
"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"},
"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.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.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"},
"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.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_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.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"},
"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.28.1", "1a836e6a938a93bd2dbd99734267bda876894473e76b510ece20e5681510ceb1", [: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", "d84e08c445f70617afd3a31633db2ea02ceabdfec482bde4b824da63b8150bdc"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"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.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"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.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"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.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
@@ -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
----
@@ -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
}
@@ -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
}
@@ -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: 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
@@ -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/
+116 -4
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
$(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
+31 -9
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
|> ensure_attribute("target", "_blank")
|> ensure_rel("noopener")
|> ensure_class(@marker_class)
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}
+59 -42
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
@@ -50,21 +56,25 @@ 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 both markdown link syntax `[text](url)` and HTML `<a href="url">`.
(Post bodies are HTML, rendered by NimblePublisher at compile time.)
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>")
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)")
@@ -72,21 +82,24 @@ defmodule Blogex.LinkValidator do
"""
@spec extract_links(String.t()) :: [String.t()]
def extract_links(body) when is_binary(body) do
markdown_links =
~r/\[([^\]]+)\]\(([^)]+)\)/
|> Regex.scan(body)
|> Enum.map(fn [_, _, path] -> path end)
html_links =
~r/<a\s+href=["']([^"']*)["']/i
|> Regex.scan(body)
|> Enum.map(fn [_, path] -> path end)
(markdown_links ++ html_links)
(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)
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
case parse_blog_link(path) do
{_, _} -> true
@@ -116,23 +129,25 @@ defmodule Blogex.LinkValidator do
@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}"}
{blog_id_str, slug_part} ->
case Map.fetch(@valid_blog_ids, blog_id_str) do
{:ok, _blog_atom} ->
if String.starts_with?(slug_part, "tag/") do
validate_tag(String.replace_prefix(slug_part, "tag/", ""))
else
validate_slug(slug_part)
end
:error -> {:error, "unknown blog ID: #{blog_id_str}"}
end
nil -> {:error, "not a blog link: #{link}"}
{blog_id_str, path} -> validate_blog_path(blog_id_str, path)
end
end
# 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_path(path)
:error -> {:error, "unknown blog ID: #{blog_id_str}"}
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.
@@ -207,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
slug = String.slice(rest, 0, slug_end(rest))
{blog_id, slug}
else
_ -> nil
case String.split(path, "/", parts: 4) do
["", "blog", blog_id, rest] ->
slug = String.slice(rest, 0, slug_end(rest))
{blog_id, slug}
_ ->
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
+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"],
+7
View File
@@ -1,13 +1,20 @@
%{
"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"},
"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.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"},
+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.
@@ -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
@@ -29,6 +29,21 @@ defmodule Blogex.LinkValidatorTest do
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)."
@@ -157,6 +172,12 @@ defmodule Blogex.LinkValidatorTest 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"}
@@ -224,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."
@@ -267,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 -35
View File
@@ -2,41 +2,7 @@ defmodule Blogex.RegistryTest do
use ExUnit.Case
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])
+103
View File
@@ -1,6 +1,7 @@
defmodule Blogex.RouterTest do
use ExUnit.Case
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+243
View File
@@ -0,0 +1,243 @@
# Improve verification for firehose
Plan for bringing the "internal code health" sensor stack to firehose, based on
what worked in stickies (Oct 2026) and
[Velasco's ExCrap blog post](https://www.germanvelasco.com/blog/i-wrote-excrap-without-looking-at-the-code).
Two kinds of verification (the post's frame):
1. **External** — is the product what we wanted? Visible: the site, the blogs.
Firehose already has decent coverage here: Playwright suites for the app
(`make playwright*`), controller/LiveView tests.
2. **Internal** — is the codebase healthy and evolving well? Invisible unless
we install **sensors and signals**: coverage, CRAP scores, credo, sobelow,
mutation testing, boundary checks. This plan is about the sensors.
## Where firehose differs from stickies
- **Two OTP apps.** `app/` (Phoenix, the Firehose site) and `blogex/`
(publishable library, NimblePublisher-based blog engine, no DB). Every sensor
must run per-app; the root `Makefile` orchestrates (`make test` already runs
both).
- **blogex is a library** (has `package`/`docs` config). Quality bar is
higher; it's also mostly *pure* code (post parsing, link validation, feeds,
SEO) — the ideal mutation-testing target, like stickies' `lib/cardgame/domain`.
- **blogex has no mix aliases at all** (no precommit, no format in aliases).
`app` has a `precommit` alias (compile --warnings-as-errors, deps.unlock,
format, test).
- **A gitea remote already exists** — unlike stickies at the start, a pre-push
hook will actually fire on every push from day one.
- Already present: credo dep in `app` (default config, not tuned/strict);
nothing else yet. No coverage config, no boundary/ex_crap/sobelow/muex in
either app. Current suites green: app 178 tests, blogex 156.
- `AGENTS.md` has a **zero-defects policy** ("never continue when there are
errors") — the fast/push split must be written to fit that policy: Lens
failures are triaged and reported, never ignored (see triage classes below).
## Phases
Order matters: the first yak is the *container* the other sensors wire into
(same conclusion as stickies — `pre-push-verification-loop-and-lens-review-practice`).
### Phase 1 — Fast/push split (the container)
Keep `precommit` cheap and fast (commit freely); move heavy checks to a
**Lens pass** = `make verify` + `.githooks/pre-push`.
- [ ] Root `Makefile`: `verify` target that loops over `app` and `blogex`,
running the heavy checks cheapest-first (credo → sobelow → coverage+CRAP).
- [ ] `.githooks/pre-push` + `core.hooksPath=.githooks` (wire into `make`/setup).
Fail loudly with a summary; trivially skippable via `git push --no-verify`.
- [ ] `blogex/mix.exs`: add an `aliases` block with `precommit`
(compile --warnings-as-errors, format, test) so both apps share the loop.
- [ ] AGENTS.md: write the **Lens practice** down — review is a separate pass
with its own context; don't chase hook nits inline while building; file a
yak instead. Add the **triage classes** for Lens failures:
*real bug* (fix inline) / *environment-dependent test* (the test is the
bug — make it deterministic by construction, no ambient state as
fixtures) / *style or design nit* (note or file a yak). Reconcile with
the zero-defects policy: "never continue silently" ≠ "fix nits inline";
it means report, triage, decide.
- [ ] Watch hook runtime: `app`'s `test` alias drops/creates/migrates the DB
every run. If the pre-push run re-tests, suite time doubles. Consider a
`test.crap` alias reusing an exported coverage run (stickies:
`test --cover --export-coverage default` + `test.coverage` + `crap`).
### Phase 2 — Coverage floor + exclusions policy
- [ ] Add `test_coverage: [threshold: N]` to **both** `mix.exs` files
(blogex first — it's pure and should be near-perfect; app likely lower).
Run coverage once to see actual numbers before picking the floor.
- [ ] Make the **exclusions policy decision early**, not later (in stickies
this came late, commit `af12c2d`): exclude framework-owned glue
(`Firehose.Repo`, `ErrorHTML/JSON`, `Telemetry`, `Mailer`) — counting it
inflates the floor without reflecting app behavior.
- [ ] Rule from stickies: the threshold is a **floor, ratcheted up as yaks
land** — never lowered.
### Phase 3 — Behavior tests for the gaps (the bulk of the work)
Run per-app coverage, list the uncovered modules, file yaks per area
(stickies: "behavior tests: boundary shell", "…: web LiveViews", "…:
DevToolsPage"). See *Steering the model on missing tests* below for the
principles — this is where most steering happened.
Expected gaps in firehose, by module type:
- `blogex/*` — pure library, should be drivable to very high coverage through
behavior tests (post → rendered HTML, invalid links → errors, feed output).
- `app/lib/firehose/accounts*` — context behavior (stickies did this for
MultiTenancy: narrate from the actors' view).
- `firehose_web/live/*` and controllers — outcome tests through
`Phoenix.LiveViewTest` / conn tests, not HTML-string assertions.
- Editorial features (`editor_dashboard_live`, `microprints_live`) — likely the
thinnest coverage; behavior-test them like stickies' DevToolsPage.
- [ ] Coverage-driven yak list per app; work them one by one.
- [ ] After each yak: **ratchet the floor** up to the new total + small margin.
### Phase 4 — CRAP scores with ex_crap
- [ ] Add `{:ex_crap, "~> 0.1", only: [:dev, :test], runtime: false}` to both
apps. **Sandbox gotcha:** `mix deps.get` cannot fetch new hex packages
from inside the sandbox — edit `mix.exs`, then ask the user to run
`mix deps.get` outside; stop there. Same for every dep below.
- [ ] Wire `mix crap` into the Lens pass (needs the coverage export from
Phase 1's `test.crap` alias).
- [ ] Baseline at default `--max-score 30`. Fix offenders the stickies way:
* pure data refactor where the complexity is real logic (9-branch
`case` → compile-time lookup map);
* for shell glue (LiveView `handle_event`), **close coverage gaps
instead of refactoring** — a fully-covered complexity-22 function
scores 24.48, under the default threshold.
- [ ] CRAP formula reminder: `comp^2*(1-cov)^3 + comp`; coverage ≥ ~75% of a
comp-22 function clears threshold 30.
### Phase 5 — Credo strict, tuned
- [ ] Tune `.credo.exs` (both apps; blogex has none): **defaults, zero
findings under `--strict` at introduction** — the config is the policy.
Deliberate deviation: disable `Credo.Check.Refactor.CyclomaticComplexity`
in favor of CRAP scores (a wide `case` with good coverage is not a
problem; that was the original itch behind ExCrap).
- [ ] Runs in the Lens pass, not precommit.
- [ ] When strict flags code: refactor along **pure-data lines** (lookup
tables, small pure classifiers, pure helpers that return values) — never
split orchestration/shell code into async calls or scattered processes
just to satisfy a metric (user, 2026-10-10: "I don't want an agent to
arbitrarily break them down, but respect our design principles").
### Phase 6 — Sobelow
- [x] Add to the Lens pass for `app` (Phoenix surface). blogex is a library
without endpoints; decide whether to run it there anyway — in ex_crap's
own history sobelow caught a `String.to_atom` in a library, so it's not
strictly web-only value. Decided 2026-10-28: **app only**; blogex's
sobelow scope is a separate yak.
- [x] **Fail when sobelow is not installed** (stickies: user explicitly
removed the skip — a silently-skipped check is a lying sensor).
`make -C app sobelow` has no fallback: a missing task is a hard Mix
error, and `verify`'s `|| exit 1` loop fails the pass.
- [x] Fix findings or add per-check skips *with rationale comments*
(stickies: `Config.CSP` skipped because the plug is invisible to sobelow).
Baseline 2026-10-28 (sobelow 0.17.0), 3 findings: `Config.HTTPS` (High),
`Config.CSP` (High), `Traversal.FileModule` (Low). All three are skipped
in `app/.sobelow-skips`, each with a written rationale: HTTPS and CSP are
deployment/design decisions with follow-up yaks, and the Traversal flag
is a false positive (the path is matched against the server-scanned list
before `File.read`). Unlike stickies, the CSP skip is *not* "the plug is
invisible" — this app genuinely has no CSP yet.
### Phase 7 — Boundary
Different shape here than in stickies:
- The **app/blogex split already enforces the biggest boundary** via the mix
dep (`{:blogex, path: "../blogex"}`): app code can only use blogex's public
API. The library direction is naturally clean.
- [ ] Inside `app`: declare boundaries for `Firehose` (contexts),
`FirehoseWeb` (exports Endpoint/Telemetry), and make **`Firehose.Repo` a
top-level sibling boundary with `deps: []`**, removed from `Firehose`'s
exports — so web→Repo calls become compile-time violations, not
convention (stickies commits `35f2eff`, `337a0c8`).
- [ ] Enable the boundary compiler in `:dev` **and** `:test` so precommit's
`compile --warning-as-errors` gates on violations. Watch for
unclassified-module warnings from test support modules (declare an
ignored top-level boundary for them, as stickies did for `CardgameTest`).
- [ ] External apps whitelist: `[:ecto, :phoenix, :plug, :phoenix_live_view]`
with per-module lists where boundary splits them.
- [ ] *Optional, not used in stickies:* Velasco's `boundary.spec.check` /
`boundary.spec.accept` tasks — an agent-proof sensor that fails when the
boundary definition itself changed without human approval. Cheap to add
once boundaries exist; worth considering given firehose's zero-defects
culture.
### Phase 8 — Mutation testing (muex)
- [ ] Add muex (dev-only, `runtime: false`) + `mix mutate` / `mix mutate.fast`
aliases. **Scope tightly to pure code**: `blogex/lib/**` (link_validator,
seo, feed, post parsing) and any pure modules in `app` — stickies scoped
to `lib/cardgame/{domain,events}` and left shell code alone.
- [ ] Curated mutator set: `arithmetic,boolean,comparison,conditional,
function_call,literal,return_value` (excludes macro/use-based mutators —
in ex_crap itself, `use Boundary` removals produced false-positive
survivors).
- [ ] `--fail-at 0`: mutation testing stays a **sensor, not a gate**; documented
false positives (guard `and`↔`or` mutants, removed log lines) are noted,
not chased.
- [ ] Genuine survivors → new tests that pin the behavior (e.g. per-field
error tests pinning chained guards).
### Phase 9 — Ratchets, decisions, handoff
- [ ] Ratchet coverage floors after each behavior yak lands.
- [ ] Decide (with the user, don't assume): CRAP `--max-score` below 30?
boundary.spec.check adoption? Playwright in the Lens pass or kept as a
separate external-verification loop?
- [ ] "Landing the plane": after each session with code changes, run
`make precommit` always; run `make verify` (or push, which fires the
hook) before handing off.
## Steering the model on missing tests (what made stickies work)
The sensors tell you *where* tests are missing; the steering below is what
made the resulting tests good rather than line-chasing. Recorded from the
stickies session history (Oct 10–12) so it can be pasted into firehose
sessions or this repo's AGENTS.md:
1. **Behavior, not state.** Narrate tests from the actors' view — "host starts
a team, members join, roles change, members leave" — and assert *outcomes*
(the next actor's experience), not internal struct/assign shapes. Pin
current behavioral decisions explicitly when a test does so (e.g. "same-name
teams cannot both exist").
2. **Functional core, imperative shell.** Details of persistence (how joining
works internally) stay out of most tests; the test drives the public API of
the core. GenServers/stores get *outcome* tests (persistence failure → state
unchanged; restart → replay), not process-internals tests.
3. **Don't test dead code — delete it.** Coverage gaps that are unreachable
mount branches (plug guarantees the precondition) are removed with a comment
stating the contract, not tested.
4. **Leave genuinely defensive branches uncovered, deliberately.** Store
failure paths only reachable by DB failure, fallbacks the router guarantees
— chasing them with artificial mocks tests lines, not behavior. Note them
in the yak context so the next ratchet isn't confused.
5. **Refactor along pure-data lines when a metric flags code**; never split a
linear shell script into async tasks to satisfy a metric. A flat lookup
`case` that is genuinely clearer than a table may stay — note it, move on.
6. **Don't chase Lens nits inline** while building; file a yak, let the Lens
pass handle them.
7. **Sandbox:** new hex deps need the user to run `mix deps.get` outside the
sandbox (read-only `~/.hex/packages`). Never work around it.
## Suggested yak list (for `yx` in firehose)
1. Fast/push split: make verify + pre-push hook + Lens practice in AGENTS.md
2. Coverage floors + framework-glue exclusions policy (both apps)
3. Behavior tests: blogex library (probably 2–3 yaks by area)
4. Behavior tests: accounts context
5. Behavior tests: firehose_web LiveViews/controllers
6. CRAP scores with ex_crap (both apps)
7. Credo strict tuned config (both apps)
8. Sobelow in the Lens pass
9. Boundary: Repo as sibling boundary, web→Repo forbidden
10. Mutation testing with muex (blogex + pure app code)
+163
View File
@@ -0,0 +1,163 @@
# Mutation testing with muex
Mutation testing is a **sensor, not a gate**. It deliberately breaks
production code one mutant at a time and re-runs the suite: a mutant *killed*
by a test is evidence the test pins that behaviour; a mutant that *survives*
is a question for a Lens session. Runs always pass `--fail-at 0`, so a survivor
is reported and triaged, never a failing build.
Nothing here is wired into `VERIFY_SENSORS`. `make check`, `make verify`,
coverage floors and `.githooks/pre-push` are unchanged by this yak.
## How to run it
Both apps declare two aliases in `mix.exs`; both read a plain-text scope file
at run time, so the file stays the single source of truth.
| Command | Scope | Blogex wall clock (2026-10-28) |
|---------|-------|--------------------------------|
| `mix mutate.fast` | `mutation_scope_fast.txt` (3 modules) | ~3 s |
| `mix mutate` | `mutation_scope.txt` (8 modules) | ~4 s |
The app declares the same aliases but both scope files are empty (see
"Scope: app" below), so `mix mutate` there raises a clear Mix error instead of
running a sensor over nothing.
Extra arguments pass through to muex, so a one-off target is explicit:
```bash
cd blogex
mix mutate --files lib/blogex/seo.ex # one module, ad hoc
mix mutate.fast --no-optimize # full mutant list, slower
```
The aliases pin `--fail-at 0` first, so an ad-hoc flag cannot turn the sensor
into a gate. `mix mutate` does not enable `--cover`: muex runs each mutant's
suite under `MIX_ENV=test`, and coverage would let a code-deleting mutant trip
the project's coverage floor and abort the run.
## Scope: blogex
| In scope (`mutation_scope.txt`) | Why |
|---------------------------------|-----|
| `lib/blogex/link_validator.ex` | Pure link parsing/validation; the yak's example module. |
| `lib/blogex/seo.ex` | Pure sitemap/JSON-LD/OpenGraph string building. |
| `lib/blogex/feed.ex` | Pure feed generation. |
| `lib/blogex/post.ex` | Pure post parsing/visibility helpers. |
| `lib/blogex/external_links.ex` | Pure HTML rewriting for external links. |
| `lib/blogex/registry.ex` | Module-registry lookup; no I/O. |
| `lib/blogex/not_found_error.ex` | Exception definition. |
| `lib/blogex/link_error.ex` | Exception definition. |
| Out of scope | Why |
|--------------|-----|
| `lib/blogex.ex` | Facade + moduledoc: delegation and docs, no logic to mutate. |
| `lib/blogex/blog.ex` | A `defmacro __using__` that expands `NimblePublisher`; mutating macro bodies produces noise, not behaviour. |
| `lib/blogex/router.ex` | Phoenix router DSL (`use`, `scope`, `get`); macro-generated. |
| `lib/blogex/components.ex` | Phoenix component DSL (`use`, `attr`, `slot`, `~H`). |
| `lib/blogex/layout.ex` | LiveView layout DSL. |
`mutation_scope_fast.txt` is a subset (`link_validator`, `seo`, `post`) so the
inner loop stays seconds long; a pin in
`test/mutation_scope_test.exs` fails if it ever drifts outside the audited
scope. Another pin fails if any scoped path stops existing, so a rename cannot
silently leave the sensor reporting a score for a reduced set.
## Mutators
The aliases pass an explicit curated set (muex 0.12 CLI names):
```
arithmetic, boolean, comparison, conditional, function_call, literal, return_value
```
Macro/`use`-based mutators are deliberately absent. The scoped modules are plain
functions, and the macro-heavy modules are out of scope; macro mutations produce
false-positive survivors rather than behavioural questions. The `arithmetic`,
`boolean`, `comparison` and `conditional` families are the behavioural core;
`function_call`, `literal` and `return_value` catch removed calls, changed
constants and stubbed return values.
## Baseline (blogex, 2026-10-28)
Command: `mix mutate` (default muex optimization, `--fail-at 0`).
Result: **19 mutants, 12 killed, 0 survived, 0 invalid, 7 equivalent — 100%.**
Wall clock: 3.5 s. The run that found the survivor below was 11 killed / 1
survived (91.67%) before its pin landed.
| Module | Mutants | Killed | Survived | Equivalent |
|--------|--------:|-------:|---------:|-----------:|
| `link_validator.ex` | 7 | 4 | 0 | 3 |
| `external_links.ex` | 6 | 5 | 0 | 1 |
| `registry.ex` | 4 | 1 | 0 | 3 |
| `post.ex` | 1 | 1 | 0 | 0 |
| `seo.ex` | 1 | 1 | 0 | 0 |
| `feed.ex` | 0 | 0 | 0 | 0 |
| `not_found_error.ex` | 0 | 0 | 0 | 0 |
| `link_error.ex` | 0 | 0 | 0 | 0 |
muex's file analyser gives `not_found_error.ex` a complexity score of 0 and
skips it under the default filter. The scope file still lists it: it is pure
code and stays audited if it grows logic. `feed.ex` is analysed but the curated
mutator set generates nothing for it.
### Survivors found and killed
| Mutant | Resolution |
|--------|------------|
| `link_validator.ex:201` — `Boolean: and to or` in `validate_body/3`'s guard `when is_binary(body) and is_atom(blog)` | Genuine: the mutant let a string blog id through the guard. Pinned by two `FunctionClauseError` tests in `test/blogex/link_validator_test.exs`; `mix mutate.fast` then reports 0 survivors. |
## False-positive classes
These are not gaps in behaviour; do not weaken a test to "kill" them.
- **Commutative argument swaps.** `function_call` mutates `a and b` to
`b and a`, `a == b` to `b == a`, and `a || b` to `b || a`. These compile to
the same behaviour, and muex's trivial-compiler-equivalence check marks them
`equivalent` (7 of the 19 baseline mutants). They are noise from the
mutator, not survivors.
- **Removed log lines.** A removed `Logger` call changes no return value; the
suite cannot observe it. Such a mutant is documented here instead of adding
a log-capture assertion that pins an implementation detail.
- **`and`/`or` in guards is *not* automatically a false positive.** The
baseline survivor above was a real guard-contract gap; only commutative
rewrites are equivalent.
- **Invalid mutants.** Mutated code that does not compile is counted
`invalid`, not `survived`, and excluded from the score.
## Deeper runs
muex's default `balanced` optimization reduced 1304 generated mutants to 12
tested ones (99.1%) on the blogex full scope. That is what keeps the sensor in
seconds, but it is sampled, not exhaustive. `mix mutate --no-optimize` tests
everything; on the three-module fast scope it took ~2 min 15 s and produced
434 mutants (329 killed, 19 survived, 83 invalid, 3 equivalent) before the
guard pin. Use it when triaging a specific module, not in a loop.
Re-baseline after a yak that changes the scoped modules (notably the Boundary
work, which adds `use` lines inside them); do not compare scores across such a
change.
## Scope: app
**Deliberately empty; no app baseline was run.** The candidate pure modules
are `Firehose.Accounts.User`, `Firehose.Accounts.Scope` and
`Firehose.Accounts.UserToken`, but every test that exercises them lives in
`test/firehose/accounts_test.exs`, which uses `Firehose.DataCase`, fixtures and
`Repo`; the SQL sandbox needs PostgreSQL. The yak's Q45 decision was: use those
modules only after confirming their tests need no database, otherwise leave the
app scope empty. They need one, so the scope is empty.
Consequences, all pinned by `app/test/mutation_scope_test.exs` and
`app/test/mutation_config_test.exs`:
- Both app scope files list nothing, so `mix mutate` / `mix mutate.fast` raise
`mutation scope … is empty; refusing to run the mutation sensor over nothing`.
- Ad-hoc `--files` arguments do not bypass the guard: the scope is validated
before muex arguments are built.
- No scope entry may ever begin with `lib/firehose_web` (controllers,
LiveViews, components).
There is no app mutation baseline to report. Do not add one until the scoped
modules have tests that run without the database; change the scope file and
these pins in the same commit.
+3 -3
View File
@@ -1,5 +1,5 @@
[tools]
elixir = "latest"
erlang = "latest"
elixir = "1.20.4-otp-27"
erlang = "27.3.4.19"
node = "latest"
go = "1.26"
go = "1.27"
+1
View File
@@ -0,0 +1 @@
*.elc
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# check-lens-sensors.sh - pin the Lens sensor wiring that must not drift.
#
# Why this exists: `make verify` echoes VERIFY_SENSORS and runs each name from
# the app's sensor loop. A sensor missing from that list is never run, and a
# sensor target that can fall back (or skip the tool when it is absent) exits 0
# when it did not check anything - in both cases the pass reports a check it
# did not run. AGENTS.md: "A gate that reports a check it did not run is worse
# than no gate."
#
# This script is static and grep-only: no mise, no elixir, no network, no
# database. It pins load-bearing text (the VERIFY_SENSORS line and the sobelow
# recipe) and the rationale rule for per-finding skips. Renaming the target or
# the variable means updating this script in the same commit - that is the
# point: the wiring stays a reviewed decision rather than an accident.
#
# Run standalone, or via root `make check`.
set -euo pipefail
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
app_makefile="$root/app/Makefile"
skips="$root/app/.sobelow-skips"
status=0
# Verified 2026-10-28 against sobelow 0.17.0:
# `cd app && mix sobelow` with 3 findings -> exit 0 (findings alone are NOT
# fatal); `cd app && mix sobelow --exit` -> exit 1. `--exit` defaults to the
# `low` confidence threshold, which is what we want: every finding counts.
SOBELOW_FAIL_FLAG='--exit'
fail() { echo "FAIL: $*" >&2; status=1; }
# --- T1: sobelow is wired and listed first in VERIFY_SENSORS ---------------
# `make verify` echoes this list, so a missing name means the pass reports a
# sensor it never runs. Cheapest-first ordering is part of the contract.
check_verify_sensors() {
local line sensors i
line=$(grep -E '^VERIFY_SENSORS[[:space:]]*:=' "$app_makefile" || true)
if [ -z "$line" ]; then
fail "app/Makefile has no VERIFY_SENSORS := line"
return
fi
sensors=$(printf '%s' "${line#*:=}" | tr -s '[:space:]' ' ')
local -a order
read -r -a order <<<"$sensors"
idx() {
local n
for n in "${!order[@]}"; do
if [ "${order[$n]}" = "$1" ]; then
echo "$n"
return
fi
done
echo 999
}
for sensor in sobelow coverage crap; do
[ "$(idx "$sensor")" -ne 999 ] || fail "VERIFY_SENSORS does not list '$sensor' (line: $line)"
done
[ "$(idx sobelow)" -lt "$(idx coverage)" ] ||
fail "VERIFY_SENSORS puts coverage before sobelow (cheapest first)"
[ "$(idx sobelow)" -lt "$(idx crap)" ] ||
fail "VERIFY_SENSORS puts crap before sobelow (cheapest first)"
}
# --- T2: the sobelow target fails loudly and has no skip/fallback path -----
# The stickies regression: the user explicitly removed a "not installed,
# skipping" fallback because a silently-skipped check is a lying sensor.
check_sobelow_recipe() {
local recipe
recipe=$(awk '
/^sobelow:/ { found = 1; next }
found && /^\t/ { print; next }
found { exit }
' "$app_makefile")
if [ -z "$recipe" ]; then
fail "app/Makefile has no sobelow: target with a recipe"
return
fi
case "$recipe" in
*"mix sobelow"*) ;;
*) fail "sobelow recipe does not invoke 'mix sobelow': $recipe" ;;
esac
case "$recipe" in
*"$SOBELOW_FAIL_FLAG"*) ;;
*) fail "sobelow recipe is missing $SOBELOW_FAIL_FLAG, so findings would exit 0: $recipe" ;;
esac
local banned
for banned in '||' 'command -v sobelow' 'skipping' '2>/dev/null'; do
case "$recipe" in
*"$banned"*) fail "sobelow recipe contains a fallback/skip path ('$banned'): $recipe" ;;
esac
done
}
# --- T3: every per-finding skip carries a written rationale ----------------
# Sobelow 0.17 keeps per-finding skips as fingerprinted lines in
# `app/.sobelow-skips` (enabled with `--skip`); `.sobelow-conf` holds only
# options. A skip entry without a reason is a defect: the next reader cannot
# tell a false positive from a debt someone hid. Comments are preserved by
# sobelow's skip loader (they sort first and never match a fingerprint).
check_skips() {
[ -f "$skips" ] || return 0
local prev="" line n=0
while IFS= read -r line || [ -n "$line" ]; do
n=$((n + 1))
case "$line" in
"" | "#"*)
prev="$line"
continue
;;
esac
case "$prev" in
"#"*) ;;
*) fail "app/.sobelow-skips:$n: skip entry has no rationale comment on the line above: $line" ;;
esac
prev="$line"
done <"$skips"
}
check_verify_sensors
check_sobelow_recipe
check_skips
if [ $status -eq 0 ]; then
echo "lens sensors ok: sobelow wired first in VERIFY_SENSORS, fail-loud recipe, app skips rationalised"
else
echo >&2 "fix the Lens sensor wiring/skips above, then re-run."
fi
exit $status
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# check-pins.sh - keep mise.toml and the Dockerfile base-image pins in sync.
#
# The Phoenix build has two sources of truth for toolchain versions:
# - mise.toml [tools]: local dev toolchain (what `mise install` gives you)
# - Dockerfile ARG pins: the hexpm/elixir builder image used for deploys
# They must agree, otherwise local builds and deployed images run different
# Elixir/OTP combinations. Run standalone, or via `make check`.
#
# Also verifies that the BUILDER_IMAGE / RUNNER_IMAGE ARGs still expand to the
# tags composed from the pins (catches hand-edited image references).
#
# Pure shell + sed: no mise, docker or elixir needed, so it runs in CI and in
# sandboxes before the toolchain is installed. Keep regexes POSIX-BRE: GNU
# extensions like \+ silently break under /usr/bin/sed on macOS (it treats
# \+ as a literal plus), which false-fails every pin as "not found".
set -euo pipefail
root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
mise_toml="$root/mise.toml"
dockerfile="$root/Dockerfile"
status=0
die() { echo "MISMATCH: $*" >&2; status=1; }
# sed-extract a `key = "value"` (or `ARG KEY=value`) line; dies when absent.
grab() { # grab <description> <key-regex> <file>
local value
value=$(sed -n "s/^$2[[:space:]]*=[[:space:]]*//p" "$3")
if [ -z "$value" ]; then
die "$1 not found in $3 (expected key = value)"
fi
value=${value%\"}; value=${value#\"} # strip optional surrounding quotes
value=$(printf '%s' "$value" | sed 's/[[:space:]]*$//') # trim trailing ws
printf '%s' "$value"
}
mise_elixir=$(grab 'mise elixir' 'elixir' "$mise_toml")
mise_erlang=$(grab 'mise erlang' 'erlang' "$mise_toml")
d_elixir=$(grab 'Dockerfile ELIXIR_VERSION' 'ARG[[:space:]]\{1,\}ELIXIR_VERSION' "$dockerfile")
d_otp=$(grab 'Dockerfile OTP_VERSION' 'ARG[[:space:]]\{1,\}OTP_VERSION' "$dockerfile")
d_debian=$(grab 'Dockerfile DEBIAN_VERSION' 'ARG[[:space:]]\{1,\}DEBIAN_VERSION' "$dockerfile")
if [ $status -eq 0 ]; then
mise_elixir_ver=${mise_elixir%%-otp-*}
mise_elixir_otp=${mise_elixir##*-otp-}
d_otp_major=${d_otp%%.*}
# Elixir: full version must match exactly.
[ "$mise_elixir_ver" = "$d_elixir" ] ||
die "Elixir: mise.toml has $mise_elixir_ver, Dockerfile ELIXIR_VERSION is $d_elixir"
# mise elixir builds embed their OTP major (…-otp-27); must match the
# major of the Dockerfile OTP_VERSION.
[ "$mise_elixir_otp" = "$d_otp_major" ] ||
die "OTP major: mise.toml elixir built for otp-$mise_elixir_otp, Dockerfile OTP_VERSION major is $d_otp_major"
# mise `erlang` IS OTP: must match the Dockerfile pin exactly.
[ "$mise_erlang" = "$d_otp" ] ||
die "OTP: mise.toml erlang is $mise_erlang, Dockerfile OTP_VERSION is $d_otp"
# BUILDER_IMAGE must expand to the tag composed from the pins.
expected_builder="docker.io/hexpm/elixir:${d_elixir}-erlang-${d_otp}-debian-${d_debian}"
d_builder=$(grab 'Dockerfile BUILDER_IMAGE' 'ARG[[:space:]]\{1,\}BUILDER_IMAGE' "$dockerfile")
expanded_builder=$(printf '%s' "$d_builder" |
sed -e "s/\${ELIXIR_VERSION}/$d_elixir/" \
-e "s/\${OTP_VERSION}/$d_otp/" \
-e "s/\${DEBIAN_VERSION}/$d_debian/")
[ "$expanded_builder" = "$expected_builder" ] ||
die "BUILDER_IMAGE expands to $expanded_builder, expected $expected_builder"
# RUNNER_IMAGE must expand to the Debian pin.
expected_runner="docker.io/debian:${d_debian}"
d_runner=$(grab 'Dockerfile RUNNER_IMAGE' 'ARG[[:space:]]\{1,\}RUNNER_IMAGE' "$dockerfile")
expanded_runner=${d_runner/\$\{DEBIAN_VERSION\}/$d_debian}
[ "$expanded_runner" = "$expected_runner" ] ||
die "RUNNER_IMAGE expands to $expanded_runner, expected $expected_runner"
fi
if [ $status -eq 0 ]; then
echo "pins ok: Elixir $d_elixir, OTP $d_otp, Debian $d_debian (mise and Dockerfile agree)"
echo " builder: $expected_builder"
echo " runner: $expected_runner"
echo " (upstream drift of these pins is reported by dokku-updates 'firehose check')"
else
echo >&2 "fix mise.toml and/or the Dockerfile ARG pins so they agree, then re-run."
fi
exit $status