63 KiB
Audit Implementation Review — audit-fixes branch
Reviewer: Independent post-implementation review
Date: 2026-04-10
Subject: All uncommitted changes on the audit-fixes branch (working tree only — there are no commits ahead of main yet)
Source of work being reviewed: audit.md (independent code audit dated 2026-04-09)
Method: git diff main over every modified file, full read of new files, and a successful cabal build ("Up to date") to confirm no compile-breaking refactor was introduced.
This document answers two questions:
- For each change introduced on
audit-fixes, what audit finding (if any) does it address, and is the fix correct? - What regressions or net-negatives did the branch introduce?
The headline answer is: the branch makes the codebase materially better and addresses the great majority of the audit's CRITICAL and HIGH findings correctly, but it has three real concerns. First, the new build/Patterns.hs module — introduced specifically to close the H-1.10.1 "directory-form essays missed by Authors.hs" finding — is only adopted by three of the five modules that should consume it; Stats.hs and Site.hs still hold private patterns, so the same class of bug is partially perpetuated on the stats page. Second, an unrelated content file (content/essays/modern_idolatry.md) was moved out of the repo root into content/essays/ instead of into content/drafts/essays/, which means it will publish to the live site on the next non-dev build despite its status: "Draft" frontmatter — this is an introduced risk that the audit did not warn about. Third, several "consolidation" refactors actually introduced new duplication (percentDecode is now byte-identical in two modules; escAttr is locally redefined in Catalog.hs and Transclusion.hs despite the new Utils.escapeHtml), and one fix in Stats.hs is annotated with a misleading "fixed" comment for code that was not actually changed.
The build compiles cleanly with the audit-fixes diff applied, so no refactor introduced an unbound name or type error.
1. Executive summary
1.1 Audit findings status
The audit identified ten CRITICAL/HIGH findings, ~22 MEDIUM, ~36 LOW, and a handful of NIT items. The branch's coverage:
| Severity | Total | Fixed correctly | Partial / risk | Not addressed |
|---|---|---|---|---|
| CRITICAL | 1 | 1 | 0 | 0 |
| HIGH | 10 | 9 | 1 | 0 |
| MEDIUM | ~22 | ~14 | ~3 | ~5 |
| LOW | ~36 | ~12 | ~4 | ~20 |
| NIT | ~10 | ~3 | 0 | ~7 |
(Counts are approximate where the audit aggregates multiple sub-findings under one ID.)
Every CRITICAL and every HIGH except one is addressed. The single HIGH that is partially addressed is H-1.10.1 (directory-form essays omitted by Authors.hs): Authors.hs is fixed, but the new canonical Patterns.hs module was not adopted by Stats.hs, so the writing-statistics page still under-counts the same essays.
1.2 Top-level verdict per area
| Area | Files touched | Net assessment | Headline reason |
|---|---|---|---|
Haskell core (build/*.hs) |
13 modified, 1 new, 1 deleted | Net positive, with caveats | Most fixes correct. Stats.hs blaze rewrite is high-quality but exceeds audit scope and skips Patterns.hs adoption. New percentDecode duplication. One misleading "fixed" comment. |
Pandoc filters (build/Filters/*.hs) |
9 modified | Net positive | All HIGH/MEDIUM filter findings addressed correctly. Sidenote rewrite is the highest-risk change but verified. New local escAttr in Transclusion partially undercuts the consolidation goal. |
JavaScript (static/js/*.js) |
9 modified, 2 new | Net positive | All scoped HIGH/MEDIUM findings addressed. Silent removal of TOC auto-collapse-on-scroll is an unannounced UX change. |
CSS (static/css/*.css) |
5 modified | Net positive | Three HIGH a11y findings closed. Several declared design tokens (--transition-medium, --bp-*) are dead — defined but not consumed. |
| Templates | 3 modified | Net positive | KaTeX bootstrap externalized; type="button" added; new utils.js wired in correctly. |
| Tools / Makefile / config | 11 modified | Net positive | Deploy ordering fixed; .env.example documents the new vars; Python imports hardened; download-model.sh gains checksum verification. README expanded from one line to a usable document. |
| Content (essay move + new content) | 1 deleted, 1 new dir + figures, several untracked | Mixed | BCI essay rewrite is solid; figures and citations all resolve. content/essays/modern_idolatry.md will accidentally publish because it's matched by publishedEssays. |
1.3 The three concerns to take action on
- Partial adoption of
build/Patterns.hs.Stats.hs(line 747 and 901) andSite.hs(publishedEssays/draftEssays) still maintain their own essay patterns instead of importing fromPatterns. The audit's L-1.1.2 finding said "extract one canonicalPatterns.hs" — it was extracted, but two of five candidate consumers still bypass it. The H-1.10.1 fix (directory-form essays in author indexes) is therefore incomplete on the build/stats page. Recommendation: Switch the twoloadAllcalls inStats.hs:747,901to usePatterns.essayPattern, and replaceSite.hs'spublishedEssayswithPatterns.essayPattern. content/essays/modern_idolatry.mdwill publish on the next build. The Hakyll publication gate is path-based (content/essays/**/*.mdis matched bypublishedEssaysinbuild/Site.hs:23-24), not frontmatter-based. The file'sstatus: "Draft"frontmatter is metadata only — it does not exclude the file from the build. Recommendation: Move tocontent/drafts/essays/modern_idolatry.md(the directory does not yet exist on disk and will need to be created) before any non-dev build.- Misleading "fixed" comment in
Stats.hsfor L-1.3.4. A new comment claims that the Backlinks-decode round-trip was eliminated, but the underlyingAeson.decodeStrict (TE.encodeUtf8 (T.pack rawBL))code is byte-identical tomain. Recommendation: Remove the comment or actually load the JSON asByteStringfrom a custom compiler.
The rest of this document walks through the changes file-by-file, in five sections.
2. Haskell core build modules
2.1 New: build/Patterns.hs
A clean new module that exports essayPattern, blogPattern, poetryPattern, fictionPattern, musicPattern, standalonePagesPattern, and three aggregations (allWritings, allContent, authorIndexable, tagIndexable). essayPattern correctly includes both content/essays/*.md and content/essays/*/index.md. poetryPattern correctly excludes collection index.md files via complement. authorIndexable and tagIndexable apply hasNoVersion so the "links" version produced by Backlinks.hs is not double-indexed.
- Audit findings addressed: L-1.1.2 (pattern centralization). Indirectly enables H-1.10.1 to be closed in modules that adopt it.
- Verdict: The module is the right abstraction and is cleanly implemented. The problem is partial adoption (see 2.4 below).
2.2 Deleted: build/Metadata.hs
The empty placeholder module is deleted; the cabal other-modules entry is removed in the same diff. Confirmed via cabal build that no remaining file imports it (the 30+ grep hits for "Metadata" all reference Hakyll's Hakyll.Core.Metadata module/type, not the deleted local one).
- Audit findings addressed: Hygiene line item.
- Verdict: Clean deletion.
2.3 build/Authors.hs
Drops the local authorLinksField (relocated to Contexts.hs), uses Patterns.authorIndexable in place of the hand-rolled allContent, delegates slugify/nameOf to Utils.authorSlugify/authorNameOf, and adds abstractField to the per-item context.
- Audit findings addressed: H-1.10.1 (directory-form essays now indexed on author pages), L-1.10.2 (slugify deduplicated), L-1.1.2 (shares the new canonical pattern).
- Risks: Adding
abstractFieldto the author item context is a minor template contract change — author-page templates that previously had no$abstract$will now receive one. Worth a quick template spot-check. - Verdict: Net positive. Closes the highest-impact author-page bug correctly.
2.4 build/Stats.hs (the largest single diff: ~720 lines)
This is the most ambitious change in the branch. It rewrites the HTML-generating helpers to use blaze-html throughout, introduces a defense-in-depth isSafeUrl/safeHref/link/pageLink URL allowlist, replaces the naive stripHtmlTags with a small state machine (handling tag bodies, comments, CDATA, quoted attribute values), adds pathIsSymbolicLink skipping to walkDir, switches countLinesDir to strict TIO.readFile, replaces a partial s !! (length s div 2) median with a total pattern match, and aliases renderStatsTags = renderTagsSection to collapse the duplicate. Two new cabal dependencies (blaze-html, blaze-markup) accompany this change.
- Audit findings addressed: M-1.3.1 (naive
stripHtmlTags), M-1.3.2 (no symlink protection), L-1.3.3 (protocol-relative URL allowlist hole), L-1.3.5 (duplicate tag rendering function), L-1.3.6 (lazyreadFile). - Real concerns:
Stats.hsdid NOT adoptPatterns.hs. Lines 747 and 901 still callloadAll ("content/essays/*.md" .&&. hasNoVersion), which means the writing-statistics page still under-counts directory-form essays. Verified by direct grep on the current file. This perpetuates the exact class of bug that H-1.10.1 was meant to close.- L-1.3.4 has a misleading "fixed" comment. A new comment describes "decoding directly from the encoded UTF-8 bytes [to] avoid the previous String → Text → ByteString round-trip", but the underlying code is byte-identical to
main(Aeson.decodeStrict (TE.encodeUtf8 (T.pack rawBL))). Either the comment should be removed or the JSON should be loaded asByteStringfrom a custom compiler. - Scope creep. The audit only asked for a smarter
stripHtmlTags. The blaze rewrite is a substantial quality improvement (HTML escaping is now structural rather than manual) and it does subsume several other findings, but it triples the line count of the affected functions and adds two cabal dependencies. This is a defensible call but it expanded the review surface considerably and any regression in the rendered/build/and/stats/pages will live in this code. - Cosmetic dedup of tag function.
renderStatsTags = renderTagsSectionis two names pointing to the same body — the surface area is unchanged. The audit recommended deleting one caller, not aliasing them.
- Other observations: The new heatmap CSS classes (
.hm0....hm4,.hm-lbl) were moved out of an inline<style>block intostatic/css/build.css— verified that the corresponding rules are present inbuild.css:125-140. The state machine in the newstripHtmlTagscorrectly handles comments and CDATA but does not strip<script>/<style>content — not a problem because blaze never emits raw script/style on this page. - Verdict: Mixed. The blaze rewrite is a real engineering improvement, but the failure to adopt
Patterns.hs, the misleading comment, and the surface area expansion mean this file should be the focus of any post-merge regression check. The_site/build/and_site/stats/pages should be visually compared to a known-good build before deploy.
2.5 build/Contexts.hs
Re-homes tagLinksField (from Tags.hs) and authorLinksField (from Authors.hs); adds abstractField to the exports; switches author-related helpers to Utils.authorSlugify/authorNameOf. Filters out empty author entries in authorLinksField (closing M-1.2.1). Splits parseMovements into parseMovementsWithWarnings that warns via unsafeCompiler/hPutStrLn stderr for any malformed movement entry, with a thin parseMovements = fst . parseMovementsWithWarnings compatibility wrapper (closing M-1.2.2). Replaces partial xs !! (length xs - 2) and last xs in confidenceTrendField with a total lastTwo helper, and factors out the magic 5 as a named trendThreshold constant (closing L-1.2.4 and the partial-function variant of L-1.2.3).
- Audit findings addressed: M-1.2.1, M-1.2.2, L-1.2.4. The partial-function refactor incidentally improves M-1.2.3.
- Risks: L-1.2.3 (
abstractFieldonly strips single-Paraabstracts) is not addressed — the same pattern match is still present. L-1.2.5 (pageScriptsFieldcollision risk on shared script paths) is not addressed. The relocation oftagLinksFieldtoContexts.hsintroduces a new latent drift axis: the new copy hard-codesfromFilePath (t ++ "/index.html")instead of going throughTags.tagFilePath. IftagFilePathever changes, the Contexts copy will silently diverge. Worth a-- keep in sync with Tags.tagFilePathcomment. - Verdict: Net positive on the in-scope items, with two LOW items left on the table. LN: we will address the remaining two items.
2.6 build/Stability.hs
readIgnore switches to strict TIO.readFile. gitDates now captures and logs stderr to hPutStrLn stderr on both success (non-empty stderr) and failure paths. stabilityFromDates replaces partial head dates/last dates with a total pattern match using reverse to find the oldest commit. The classification thresholds (e.g., n <= 5 && age < 90 → "revising") are extracted as named constants with comments.
- Audit findings addressed: M-1.7.1 (lazy
readFile), L-1.7.3 (stderr logging). The threshold-as-constants refactor closes the implicit complaint about magic numbers. - Not addressed: L-1.7.2 (
unsafeCompilerfor git breaks Hakyll dep tracking) — explicitly deferred; meaningful remediation would require tracking.git/HEADin Hakyll's dep graph, which is beyond the audit's scope. - Verdict: Net positive.
2.7 build/Catalog.hs
Adds local safeHref, escAttr, and escText helpers, and applies them to ceUrl, ceYear, ceDuration, ceInstrumentation, and categoryLabel inside renderEntry/renderCategorySection. Replaces the partial head g in renderCategorySection with a total pattern match renderGroup (e : _) = .... ceTitle is still emitted unescaped by design, with a trust-model comment added.
- Audit findings addressed: M-1.8.1 (frontmatter escaping with documented trust caveat).
- Risks:
safeHref,escAttr, andescTextare local re-implementations of helpers that the audit explicitly asked to consolidate.safeHrefis now the third copy of the URL allowlist (also inStats.isSafeUrl);escAttris essentially a copy of the newUtils.escapeHtml/escapeHtmlText. The branch added centralized helpers inUtils.hsand then immediately bypassed them here. The fix is correct in isolation but contradicts the consolidation goal. - Verdict: Positive for the security fix, mixed on the duplication. LN: let's try to bring this to an entire net positive.
2.8 build/Backlinks.hs and build/SimilarLinks.hs
Both files add a new percentDecode function that decodes %XX escapes into raw bytes and reinterprets them as UTF-8 (with lenient decoding) and call it from their respective normaliseUrl functions. Backlinks.hs additionally switches its local allContent to Patterns.allContent.
- Audit findings addressed: L-1.4.1 (URL-decode in
normaliseUrl); thePatterns.allContentadoption closes part of L-1.1.2. - Risks:
percentDecodeis byte-for-byte duplicated betweenBacklinks.hsandSimilarLinks.hs. The in-diff justification is that the two modules apply different pre-normalisation steps, which is true, but the decoder function itself is identical. This should live inUtils.percentDecode. The audit was explicit about exactly this kind of drift; the fix introduces a new instance of it. Additionally,Stats.hs's ownnormUrldoes NOT percent-decode, so if a route ever contained a percent-encoded character, the orphan-link counts on/stats/would silently disagree withBacklinks.hs. In practice Hakyll routes are ASCII so this doesn't bite, but it's a latent asymmetry. - Verdict: Net positive (the fix is correct and improves consistency between Backlinks and SimilarLinks); the missed factoring is a paper-cut, not a regression.
2.9 build/Citations.hs
transformInline replaces partial head keys/head nums with a total pattern match (firstKey : _, firstNum : _), falling through to Str "" otherwise.
- Audit findings addressed: L-1.5.1.
- Verdict: Net positive. Minimal, correct, semantics-preserving.
2.10 build/Commonplace.hs
The H-1.9.1 operator-precedence bug: parentheses added around the if-expression so the closing </div> is always emitted in renderChronoView. Two characters of fix.
- Audit findings addressed: H-1.9.1.
- Verdict: Net positive. Trivial, correct.
2.11 build/Compilers.hs
Removes the now-redundant import Hakyll.Core.Metadata (lookupStringList, lookupString) since Hakyll (the umbrella module) re-exports both.
- Verdict: Net positive. Pure housekeeping.
2.12 build/Tags.hs
Drops the local tagLinksField export (relocated to Contexts), drops the local allContent in favor of Patterns.tagIndexable, adds abstractField to tagItemCtx, and removes the unused Pagination (pageSize) import.
- Audit findings addressed: L-1.1.2 (pattern centralization on the tags side).
- Risks: As noted above, the
tagLinksFieldrelocation creates a latent drift axis withTags.tagFilePath. - Verdict: Net positive.
2.13 build/Site.hs
Adds draft-essay support: a new SITE_ENV=dev env-var gate (read once at rule-registration via preprocess $ lookupEnv "SITE_ENV") that, when set to "dev", includes content/drafts/essays/**.md in the allEssays pattern and routes them to drafts/essays/.... New rules also handle co-located JS and static assets under content/drafts/essays/. The deleted Control.Monad (intercalate) import is replaced with Aeson.encode for the random-pages.json builder, which now produces valid JSON via Aeson rather than a hand-rolled intercalate "," (a real correctness improvement, though not in the audit).
- Audit findings addressed: None directly. This is a new feature unrelated to the audit (draft mode), and one collateral correctness improvement (the random-pages JSON).
- Risks:
- Site.hs does NOT use
Patterns.hs. The newpublishedEssaysanddraftEssaysdefinitions are inSite.hs, which means there are now two sources of truth for the essay pattern:Site.hs.publishedEssaysandPatterns.essayPattern. They happen to be string-equivalent today, but if either is edited the other will silently drift. Recommendimport qualified Patterns as Pandlet publishedEssays = P.essayPattern. - Scope creep. Draft mode is a new feature, not an audit fix. It's implemented cleanly (env-var gated, no cross-module filtering needed because every existing pattern only matches
content/essays/**), but it adds complexity to the rules registration that the audit did not anticipate. - The
random-pages.jsonJSON-encoder fix is a quiet but real correctness improvement: the previousintercalate "," . map showwas technically invalid for any URL containing a backslash or non-ASCII character. Worth a commit-message callout.
- Site.hs does NOT use
- Verdict: Net positive. The feature is valuable, the JSON fix is a real (silent) bug fix, and the draft-mode design is sound. The
Patterns.hsnon-adoption is the only smell.
2.14 build/Utils.hs
Adds escapeHtmlText (a Text variant of the existing escapeHtml), trim, authorSlugify, and authorNameOf.
- Audit findings addressed: L-1.10.2 (centralized author slugify), enabling several downstream consolidation fixes.
- Risks: L-1.11.1 (
wordCountcounts HTML tokens as words) is not addressed — the function is unchanged. The newescapeHtmlcomment notes that "ordering matters" for the replacements but the implementation isconcatMap escChar, which is character-by-character — the order does NOT matter for this implementation pattern (only for sequentialT.replace). Misleading but harmless. - Verdict: Net positive. Small, focused, makes downstream consolidation possible — even though several callers immediately ignored the new helpers and rolled their own.
3. Pandoc filters
All filter changes are summarized below; the full per-finding verification was performed and is condensed for readability.
3.1 Filters/Images.hs (closes the CRITICAL)
- C-2.1.1 fixed correctly.
lowerExtis nowmap toLower . takeExtension. Edge cases verified: extensionless files ("Makefile") →""(no raster); dotfiles (".hidden") →""; double-extension ("foo.tar.gz") →".gz"; trailing dot ("foo.") →"."(skipped). The entire<picture>/WebP pipeline is now live for the first time since whenever the regression was introduced. This is the single most impactful fix in the branch. - M-2.1.2 fixed correctly.
passedKvsblocklist expanded to includeid,class,alt,title,srcin addition toloading/data-lightbox. One subtle behavior change: an author who previously wrote{src="bar.jpg"}would have gotten a duplicatesrcattribute on the rendered<img>; now the user-suppliedsrcis silently dropped. This is an improvement (the Pandoc Target is the canonical source), but it is a behavioral change worth noting. - M-2.1.3 fixed correctly. Local
stringifyexpanded to coverStrikeout,Superscript,Subscript,SmallCaps,Underline,Quoted,Cite,Math,RawInline,Note.Mathreturns the raw math source (e.g.,x^2), which is uglier than[math]but better than empty. - L-2.1.4 not addressed.
renderKvsstill emits the attribute key without escaping. Defensive only; in practice unreachable since Pandoc kv keys are alphanumeric identifiers. - Verdict: Net positive. The
lowerExtfix alone justifies this file's diff.
3.2 Filters/Score.hs (H-2.3.1)
doesFileExist guard plus try :: IO (Either IOException T.Text) catch around TIO.readFile. On missing file or read error, logs the path to stderr and returns an errorBlock (<figure class="score-fragment score-fragment--error">) instead of crashing the build. Local escHtml delegates to Utils.escapeHtmlText.
- Verdict: Net positive. Turns a build-aborting crash into a visible diagnostic. Note:
score-fragment--erroris a new CSS class with no corresponding rule yet — the figure will render unstyled, which is arguably the intent. LN: for now, that is the intent indeed.
3.3 Filters/Viz.hs (H-2.4.1, L-2.4.4)
Adds doesFileExist check before readProcessWithExitCode. Enriches error messages to prefix the source path. Switches warn from putStrLn to hPutStrLn stderr. Local escHtml delegates to Utils.escapeHtmlText.
- Verdict: Net positive. Tiny TOCTOU gap between
doesFileExistand the subprocess call is irrelevant for a static site build.
3.4 Filters/Sidenotes.hs (H-2.5.1, M-2.5.2 — highest-risk filter change)
- H-2.5.1 fixed correctly.
toLabelis rewritten from(n - 1) mod 26to a base-26 expansion:1→a,26→z,27→aa,702→zz,703→aaa. Verified by hand:n=27→(26 divMod 26) = (1,0)→ recurse onk=1→(0,0)→"a", append'a'→"aa". No collisions, guaranteed unique. JS instatic/js/sidenotes.jsderivessnref-Nfromid.slice(3), which works for any label length — no JS-side regression. - M-2.5.2 fixed correctly. The string-level
T.replace "<p>"hack is replaced with an AST-levelblocksToInlineHtmlthat renders eachParavia Pandoc's HTML writer with a wrapping<span class="sidenote-para">. Multi-paragraph footnotes containing the literal text<p>(e.g., a code sample about HTML) are no longer mangled. - Risks: Multi-paragraph sidenotes with non-
Parablock content (lists, blockquotes, code blocks) fall through to ablocksToHtml [b]path that emits block-level<p>/<ul>etc. inside a<span>— technically invalid HTML but unlikely to appear in practice.inlinesToHtmlsilently returnsT.emptyon Pandoc-writer error (should warn). Three-letter labels (aaa+) at >702 footnotes may overflow.sidenote-numCSS layout; authors with such prolific footnoting will hit this before the audit's correctness fix matters. - Verdict: Net positive. Both fixes are structurally correct. Worth a spot-check on a page with a multi-paragraph or list-containing footnote post-deploy.
3.5 Filters/Transclusion.hs (M-2.2.1, L-2.2.2)
HTML-escapes both url and sec via a new local escAttr before interpolation into data-src/data-section. slugToUrl becomes idempotent for slugs already ending in .html.
- Risks:
escAttris locally redefined despite the newUtils.escapeHtml. This module works onString, andUtils.escapeHtmlalso works onString, so there is no type-mismatch excuse. A clean miss of the consolidation goal. - Verdict: Net positive on the security/idempotency fixes; minor regression on the duplication front.
3.6 Filters/Links.hs (M-2.6.1, M-2.6.2)
isExternal rewritten to extract hostname properly: strip http(s)://, take up to first /?#, drop :port, lowercase, then exact-match levineuwirth.org or .levineuwirth.org suffix. Verified test cases: https://evil-levineuwirth.org.attacker.com/ → external (correct, fixes the audit finding); https://www.levineuwirth.org/ → internal; https://LEVINEUWIRTH.ORG/ → internal. PDF links with fragments now split on #, encode only the path through encodeQueryValue, and re-attach the fragment to the viewer URL.
- Minor issue:
extractHostdoes not handle URLs with userinfo (https://user:pass@host/) —hostwould be parsed as"user". No realistic content uses credentials in URLs; non-blocking. - Verdict: Net positive. The hostname parsing is the security-relevant fix and it's correct.
3.7 Filters/Wikilinks.hs (M-2.7.1)
Adds escMdLinkText (escapes \, [, ] in display text) and encodeUrl (percent-encodes (, ), space). Switches local trim to Utils.trim.
- Risks:
encodeUrlis essentially dead code: the URL it processes is"/" ++ slugify title, andslugifyonly outputs[a-z0-9-], none of which need encoding. Defensive without payoff. L-2.7.2 (slugify trailing-period quirk) is not addressed. Switching toUtils.trimis also a minor semantics drift: the old localtrimonly stripped ASCII space, the new one strips all whitespace viaisSpace(so tabs in wikilinks are now trimmed where they were preserved before). Almost certainly fine. - Verdict: Mixed. Display-text escaping is correct; URL encoding is over-engineered; LOW finding silently skipped. LN: let's comprehensively revisit this.
3.8 Filters/EmbedPdf.hs (M-2.8.1)
Adds # to the encodeQueryValue encode table; switches local trim to Utils.trim.
- Verdict: Net positive. Small, correct, defense-in-depth.
3.9 Filters/Smallcaps.hs
Local escHtml delegates to Utils.escapeHtmlText. Pure cleanup; no behavior change.
- Verdict: Net positive.
3.10 Filter cross-cutting observations
escapeHtml consolidation is partial. Utils.escapeHtmlText now serves Images.hs, Smallcaps.hs, Score.hs, Viz.hs. But Filters/Typography.hs still has its own local escHtml (untouched), Filters/Transclusion.hs introduces a new local escAttr instead of using Utils.escapeHtml, and Catalog.hs defines its own escText/escAttr. Net result: 4 files consolidated, 3 files (Typography, Transclusion, Catalog) still have duplicates. The audit's NIT about duplicate escape helpers was addressed by half.
trim is also fragmented. Utils.trim is added and used by Wikilinks, Transclusion, EmbedPdf. But Contexts.hs still uses Hakyll's re-exported Hakyll.Core.Util.String.trim via the umbrella import Hakyll. Two trim functions in active use, with equivalent semantics — no break, but the consolidation goal is incomplete.
No new partial functions introduced. All new IO code in Score/Viz uses try/catch or doesFileExist guards. No bare head, tail, !!, or fromJust added in any filter diff.
No type-signature changes ripple to callers. Module interfaces (apply, inlineScores, inlineViz) are unchanged. Site.hs, Compilers.hs, and the Filters umbrella module (build/Filters.hs) are unaffected.
LN: all should be addressed, but we can make the other fixes in my comments here first, then discuss this in more depth to get it right.
4. JavaScript, CSS, and templates
4.1 New: static/js/utils.js
Single-function module exposing window.lnUtils.escapeHtml(s) (escapes &<>"', with ' newly added relative to all three previous duplicates). Wrapped in an IIFE that guards against double-assignment. Loaded synchronously from templates/partials/head.html:31 before theme.js and before every defer'd consumer, so window.lnUtils is guaranteed to exist by the time annotations.js, popups.js, semantic-search.js run.
- Audit findings addressed: L-3.4.1.
- Verdict: Net positive. The shared helper is strictly safer than the previous three copies (it escapes single quotes, which the old ones did not).
4.2 New: static/js/katex-bootstrap.js
Externalizes the inline onload="(function(){...})()" KaTeX render loop that used to live on templates/default.html. Adds a try/catch around katex.render and a typeof katex === 'undefined' early-out that the inline version lacked. Loaded with defer after the KaTeX CDN script, also defered — defer scripts execute in document order, so KaTeX is guaranteed to be defined before the bootstrap runs.
- Audit findings addressed: M-4.2.1 (CSP compatibility).
- Risks: One behavioral change: the new bootstrap renders both
<span class="math">and<div class="math">; the old inline script only renderedSPAN. Pandoc's default math output is<span class="math display">so this is unlikely to bite, but display-math edge cases should be spot-checked in a build. - Verdict: Net positive.
4.3 static/js/gallery.js (H-3.3.1)
Adds a Tab branch in the existing overlay keydown listener that calls a new trapTab(e) cycling focus through button:not([disabled]), [tabindex]:not([tabindex="-1"]) inside #gallery-overlay. Mirrors the settings.js:33-49 pattern and additionally snaps focus back into the overlay if document.activeElement is outside it entirely. Listener is guarded by overlay.hasAttribute('hidden') so the trap is inert when the overlay is closed.
- Verdict: Net positive. A minor doc-drift: a comment refers to "(currently inert) page background" but the overlay does not actually set
inertoraria-hiddenondocument.body, so a screen reader could still navigate the page beneath the overlay in virtual-cursor mode. Cosmetic.
4.4 static/js/popups.js (M-3.1.1, M-3.1.2, M-3.2.2, L-3.4.1)
Five distinct fixes: (1) new window.reinitPopups(container) for transcluded content; (2) bind() is now idempotent via el.dataset.popupBound === '1'; (3) scheduleShow accepts either a string or a Node from providers; (4) epistemicContent returns a <div> built from cloneNode(true) of .ep-compact/.ep-expanded instead of concatenating innerHTML; (5) new fetchJson/fetchXml helpers validate Content-Type before parsing; all nine cross-origin fetches (Wikipedia, arXiv, CrossRef, GitHub, Forgejo, OpenLibrary, bioRxiv, YouTube, archive, PubMed) routed through them. esc() delegates to window.lnUtils.escapeHtml.
- Verdict: Net positive. The
Content-Typematchers are sound: the JSON regex matchesapplication/json,application/ld+json, andapplication/vnd.github.v3+json; the XML matcher acceptsapplication/atom+xml. The idempotentbind()guard means transcluded content can be re-initialized without handler accumulation.
4.5 static/js/sidenotes.js (M-3.2.1)
Adds an idempotent guard in wireHover using ref.dataset.snBound. Extracts wireAll(root) from init and exposes window.reinitSidenotes(container).
- Risks: M-3.3.3 (sidenote focus toggle is click-only) is not addressed. No keyboard handler is added to toggle the
.is-focusedclass. The audit listed this as MEDIUM; it remains open. - Verdict: Net positive on M-3.2.1 only. LN: we need to resolve this MEDIUM open problem.
4.6 static/js/semantic-search.js (M-3.2.3, L-3.4.1)
Adds loadModelPromise in-flight cache for the model-loading import(CDN) chain; resets the cache on failure so retries work. Classic double-checked-lock pattern for JS promises. esc() delegates to window.lnUtils.escapeHtml.
- Verdict: Net positive.
4.7 static/js/annotations.js, static/js/lightbox.js, static/js/theme.js
annotations.js:escHtmldelegates towindow.lnUtils.escapeHtml. (L-3.4.1.)lightbox.js:img.altinitial value becomes'Lightbox image';open()usesalt || captionText || 'Lightbox image'fallback chain. (L-3.3.4.)theme.js: NewsafeGet(key)wrapslocalStorage.getItemin try/catch for Safari private mode. (L-3.3.5.)- Verdict: All net positive. The matching
setItemwrites performed elsewhere (settings.js) are not wrapped — minor inconsistency, not in scope here. LN: let's bring them up to consistency.
4.8 static/js/toc.js (H-4.1.3 + silent feature removal)
setExpanded(open) now sets aria-hidden="true|false" on the TOC nav and toggles tabindex="-1" on every link, working in concert with the components.css change that drops visibility: hidden from #toc.is-collapsed .toc-nav. Removes the entire autoCollapsed/collapseOnce dead-code path (and its two call sites), so the TOC no longer auto-collapses on the first scroll.
- Risks: The auto-collapse-on-first-scroll behavior is silently removed. Users will now see the full TOC expanded throughout the read unless they manually collapse it. This is a real UX change, not flagged in the audit, and not commented anywhere in the diff. It should be explicitly called out in the commit message and ideally validated by Levi.
- Verdict: Mixed. The a11y fix is clean; the UX removal is unannounced.
4.9 static/js/transclude.js (M-3.2.1, M-3.2.2 follow-through)
reinitFragment(container) now calls window.reinitSidenotes(container) and window.reinitPopups(container) when present, with a fallback to the old resize event dispatch for sidenotes.
- Verdict: Net positive. Works in tandem with the public hooks added to
sidenotes.jsandpopups.js.
4.10 templates/default.html, templates/partials/head.html, templates/partials/nav.html
default.html: inline KaTeXonload="..."removed, replaced by twodeferscripts (KaTeX CDN, then/js/katex-bootstrap.js). (M-4.2.1.)head.html: adds<script src="/js/utils.js"></script>synchronously beforetheme.js. (L-3.4.1.) L-4.2.3 (unconditional CSS loading) is not addressed — every component CSS file still loads on every page.nav.html: every<button>now hastype="button"(11 buttons total). (L-4.2.2.)- Verdict: Net positive on all three.
4.11 static/css/base.css (H-4.1.1, H-4.1.2, H-4.1.4)
(1) Adds --transition-medium: 0.28s ease and --transition-slow: 0.5s ease design tokens. (2) Defines --rule, --font-ui, --bg-subtle as aliases of --border-muted/--font-sans/--bg-offset (closing H-4.1.1). (3) Defines --bp-phone/--bp-tablet/--bp-desktop/--bp-wide as documentation tokens. (4) Bumps dark-mode --text-faint from #6a6660 to #8b8680 in two locations (closing H-4.1.2; new contrast ratio is ~3.92:1 against #121212, which clears 3:1 for non-text UI elements). (5) Adds global :focus-visible ring rules covering button, a, summary, [role="button"], input, select, textarea (closing H-4.1.4).
- Risks:
--transition-medium,--transition-slow,--bp-phone,--bp-tablet,--bp-desktop,--bp-wideare defined but never consumed anywhere in the codebase. They are documentation placeholders. The audit findings L-4.1.7 (inconsistent transitions) and M-4.1.6 (scattered breakpoints) are not materially addressed — the actualtransition:and@mediacall sites were not migrated. The work was started but not finished. - Verdict: Net positive. Three HIGH a11y findings closed correctly; dead tokens are scope creep that should either be removed or completed by migrating call sites.
4.12 static/css/build.css, static/css/components.css, static/css/print.css, static/css/typography.css
build.css: adds heatmap fill rules.heatmap-svg .hm0..hm4pointing atvar(--hm-0..--hm-4)and.heatmap-svg .hm-lblstyling. This is needed because Stats.hs moved the previously-inline SVG<style>block into external CSS. Verified the corresponding tokens exist in both light and dark mode.components.css:#toc.is-collapsed .toc-navdropsvisibility: hiddenand gainspointer-events: noneon collapsed links as belt-and-suspenders. (H-4.1.3.)print.css: replaces hardcoded#fff/#000/etc. withvar(--bg)/var(--text)/var(--bg-subtle)/var(--border-muted)/var(--text-faint), and adds a@media print:root,[data-theme="dark"]block that forces a light palette. (M-4.1.5.)typography.css: addsfigure:has(> img) { display: table }for shrink-wrapped image captions; changes figcaption font-size fromvar(--text-size-small)to0.92em. Scope creep — neither change maps to an audit finding.- Verdict: All net positive. The typography figcaption font-size change is a visual regression for anyone who had tuned
--text-size-smallexpecting it to apply to captions; minor.
5. Tools, Makefile, and configuration
5.1 Makefile (M-6.1.1, M-6.1.2)
(1) Adds test -n "$(VPS_USER)" || exit 1 guards for VPS_USER/VPS_HOST/VPS_PATH in the deploy recipe. (2) Reorders deploy: rsync now runs before git push -u origin main, so a failed rsync leaves the GitHub mirror still pointing at the last successful deploy. (3) Adds target-specific SITE_ENV=dev exports for watch and dev (which feed the new Site.hs draft mode). (4) Adds explanatory comments above the content/ auto-commit and the dev gate.
- Risks: Because
deploy: clean build signruns before the VPS guards fire, a missing.envcosts a full clean build before the failure surfaces. Cosmetic. M-6.1.3 (build commits content/ before building, never cleans up on failure) is acknowledged in a comment but not actually fixed. - Verdict: Net positive.
5.2 .env.example
Adds explicit VPS_USER/VPS_HOST/VPS_PATH keys with header/section comments matching the new Makefile guards. Pure documentation.
- Verdict: Net positive.
5.3 README.md (M-7.6.1)
From a one-line stub to a ~79-line user-facing README covering quickstart commands, optional features (embeddings, semantic-search model, image conversion, PDF thumbnails), .env configuration, repository layout, and architecture pointers. Cross-references build/Patterns.hs, build/Site.hs, build/Compilers.hs, build/Filters/Images.hs, tools/convert-images.sh, and spec.md.
- Verdict: Net positive. One mild caveat: the README advertises
make devas the day-to-day command, butdevdoesn't re-runconvert-images.shorpdf-thumbslikebuilddoes, so an author adding a JPEG won't get WebP companions until theymake build. Worth a future clarification. LN: Address in the README that make build should proceed make dev when figures, etc. have changed.
5.4 levineuwirth.cabal
(1) Adds Patterns and Filters.EmbedPdf to other-modules. (2) Removes Metadata. (3) Adds blaze-html >= 0.9 && < 0.10 and blaze-markup >= 0.8 && < 0.9 to build-depends (required by the Stats.hs blaze rewrite). (4) Drops -Wno-unused-imports from ghc-options.
- Verification:
cabal buildreports "Up to date" — confirms no compile-breaking refactor. The-Wno-unused-importsremoval is non-regressive: cabal uses-Wallonly (no-Werror), so unused imports surface as warnings, not errors. Levi will see them duringcabal buildand can clean them up incrementally. - Verdict: Net positive.
5.5 cabal.project.freeze
Two patch-level pin bumps: OneTuple 0.4.2 → 0.4.2.1, text-short 0.1.6 → 0.1.6.1. Both transitive deps. Low-risk.
- Verdict: Net positive. LN: you can ignore any such patch bumps and do not worry about dependencies. If there are ever issues with dependencies as we continue to iteration on the audit, just run the tools/refreeze.sh and they will be solved.
5.6 pyproject.toml (M-5.4.1)
Adds upper bounds to every dependency pin: matplotlib<4, altair<6, sentence-transformers<4, faiss-cpu<2, numpy<3, beautifulsoup4<5, torch<3. Adds a rationale comment.
- Verdict: Net positive.
5.7 tools/embed.py (H-5.1.1)
_url_from_path now explicitly handles the root index.html case: if the parent is . or "", return "/"; otherwise return "/" + parent + "/". This was the audit's HIGH about SimilarLinks.hs never matching the homepage.
- Risks: L-5.1.2 (no
--quietmode) and L-5.1.3 (re-stats every HTML on every run) are not addressed. Both are LOW. - Verdict: Net positive.
5.8 tools/import-poetry.py (H-5.2.1, H-5.2.2, M-5.2.3, M-5.2.4)
(1) roman_to_int bails out on empty string and adds an inner i < len(s) bounds check. (2) yaml_str adds \n, \r, \t to the needs-quoting trigger set and explicitly escapes them in the output. (3) main() validates args.date as an integer in [1, 2100]. (4) main() asserts title_prefix.strip() is nonempty. (5) main() asserts collection_slug is nonempty and not just "-".
- Risks: L-5.2.5 (
write_textnoerrors=kwarg) is not addressed. LN: let's address this - Verdict: Net positive.
5.9 tools/sign-site.sh
Replaces a sequential while read | gpg loop with find ... -print0 | xargs -0 -I {} -P $(nproc) gpg ..., parallelizing signing across CPU cores.
- Risks: Under
set -euo pipefail, a singlegpgfailure aborts the script viaxargsexit code 123, but several other sign operations may have already started — partial signing state is left on disk, where the previous sequential implementation stopped immediately. The post-loopcount = find ... | wc -ccounts HTML files, not signatures actually written, so the reported count is misleading after a partial failure. Acceptable for a sign step (rerun fixes it), but a behavioral change worth a comment. - Verdict: Net positive (real perf win, minor rough edge).
5.10 tools/download-model.sh (L-6.4.1)
Adds a supply-chain SHA-256 verification layer. New expected_sha() and verify_sha() helpers look up a relative path in tools/model-checksums.sha256 (if present) and compare sha256sum output. fetch() calls verify_sha both on skip (file already present) and after successful download. On mismatch the file is deleted and the script exits 1. If the checksum file is absent, a one-line advisory is printed and downloads proceed unverified.
- Risks: The checksum file (
tools/model-checksums.sha256) does not yet exist in the repo, so the first run stays advisory-only. The audit asked for the mechanism, not the pinned values — Levi will need to generate and commit the checksums once he has verified a model version out-of-band. The script's own comment block documents that workflow. - Verdict: Net positive. Mechanism in place; pinning still pending.
5.11 tools/convert-images.sh
Staleness check upgraded from "skip if .webp exists" to "skip if .webp exists and the source is not newer than the webp" (! "$img" -nt "$webp"). Previously, an edited source silently served a stale webp.
- Verdict: Net positive.
5.12 Tools cross-cutting observations
tools/__pycache__/*.pycshow as modified in git because they were apparently committed at some point and the source edits changed their hashes. They should be in.gitignoreand removed from tracking — separate hygiene follow-up, not introduced by this branch.- The cabal
other-moduleslist is now internally consistent:Metadataremoved,PatternsandFilters.EmbedPdfadded, all referenced files exist on disk. cabal buildsucceeds, confirming no broken refactor. LN: we can go ahead and make that gitignore change involving *.pyc
6. Content changes
6.1 Beyond Comorbidity Indices essay (move + rewrite)
The file moves from content/essays/beyond-comorbidity-indices.md (216 lines, deleted) to content/essays/beyond-comorbidity-indices/index.md (~368 lines, new). This is a substantial rewrite, not a mechanical reformat. Routing is supported by build/Site.hs:23-24 (publishedEssays matches both *.md and */index.md) and build/Site.hs:215-224 correctly maps content/essays/slug/index.md → essays/slug/index.html.
What's preserved: YAML frontmatter (title, authors, affiliations, Icarian metadata, tags), Key Points callout, dropcap intro, all Pandoc fenced divs for figures, the structure of Tables 1-2, the core scientific claims (AUC values, DeLong tests, IG interpretability).
What's new or changed:
- Cohort numbers updated and now internally consistent: "over 113 million" decomposes as
80,217,696 + 33,322,761. - Date bumped from
2026-03-28to2026-04-09. - Two new frontmatter fields:
bibliography: data/bci-paper.bibandrepository: "https://git.levineuwirth.org/neuwirth/beyond_comorbidity_indices". Verify therepositoryIcarian context field is rendered somewhere if the author intends it visible — otherwise it's metadata-only. - Numeric updates to ECI mortality AUC (0.6686 → 0.6414) and CCI mortality AUC (0.7217 → 0.7621), reflecting a re-fit.
- Methods/Results/Discussion prose substantially expanded; full Supplement (eMethods 1-4, eTables 1-3, eFigures 1-4) inlined. LN: we are going to introduce a site-wide means of supplement and appendices, but not yet.
- New Code Availability + Conflict-of-Interest + Data Sharing sections added.
- The old version's "Structured Abstract" collapsible callout is removed; equivalent content survives in the manuscript body.
Figures: 10 PNG files exist in content/essays/beyond-comorbidity-indices/figures/ and all 10 are referenced by the essay text (fig2a, fig2b, fig3a, fig3b, fig4a, fig4b, efig1, efig2, efig4a, efig4b). They will route to /essays/beyond-comorbidity-indices/figures/*.png via the content/essays/** static-asset rule at build/Site.hs:233-235. Two placeholders remain: Figure 1 ("Flow chart of discharge records") and eFigure 3 ("Calibration reliability plots") are wrapped in annotation--static divs with [placeholder] text — no image file. These should be resolved before deploy.
Citations: 38 unique citation keys extracted from the essay; 38 @entry{...} blocks in data/bci-paper.bib (new file). The sets are identical — every citation resolves, no dead keys, no unused entries. The frontmatter bibliography: data/bci-paper.bib correctly overrides the default data/bibliography.bib per build/Compilers.hs:144.
- Verdict: Net positive. Major rewrite, all figures and citations resolve, infrastructure supports the directory layout. Two placeholder figures and the new
repositoryfrontmatter field need attention before deploy. LN: what needs to be done about repository frontmatter? Please discuss with me.
6.2 content/colophon.md
Three polished prose paragraphs are replaced with slightly more casual versions. Closing line changes from "git history functions as an authoritative record" to "git repository on Forgejo... should always be considered to take precedence." A dropcap paragraph about tools being "chosen rather than accepted" is removed. The Hyprland-on-both-machines paragraph is removed. The Emacs paragraph is expanded to announce a "Pmacs" side project for Summer 2026.
- Verdict: Mixed. The original prose was tighter and more distinctive ("every tool I use was chosen rather than accepted. This distinction matters..."). The new prose sacrifices precision in places ("I am, like many passionate nerds within the realm of computing, obsessive over my technological choices") and announces "Pmacs" without context. If the intent is voice recalibration toward less formal, it works; otherwise it reads like a rough draft compared to
main. No broken references. LN: the colophon, like all else on this website, is iterative. It is not intended to be a dissertation, but rather informal reading for the curious surfer. I am continuing to revise it iteratively, but don't worry about this change.
6.3 content/essays/modern_idolatry.md (untracked, CRITICAL)
This file's frontmatter declares status: "Draft", but the Hakyll publication gate is path-based, not frontmatter-based. See build/Site.hs:23-24:
publishedEssays = "content/essays/*.md" .||. "content/essays/*/index.md"
draftEssays = "content/drafts/essays/*.md" .||. "content/drafts/essays/*/index.md"
Since the file lives at content/essays/modern_idolatry.md, it matches publishedEssays. The moment this file is committed and make deploy (or make build) runs in non-dev mode, it will publish to the live site regardless of its status frontmatter. The audit's hygiene note that "content/modern_idolatry.md was at the project root" was partially addressed: the file moved under content/essays/, which is better organizationally, but it now also matches the publishedEssays glob — this is a worse state than the original location.
Action required: Move the file to content/drafts/essays/modern_idolatry.md. Note that content/drafts/essays/ does not currently exist on disk and would need to be created. Alternatively, if the essay is in fact ready, flip status to a published value and verify via make dev first.
LN: It should be moved to /content/drafts/essays, which can be created.
- Verdict: Negative as currently staged. This is the single most concerning issue introduced by the branch.
6.4 Workspace files (audit.md, migrate_html.md, paper/*.docx)
paper/BeyondComorbidityIndices.docxandpaper/BeyondComorbidityIndicesSupplement.docx— confirmed moved out of the project root intopaper/(5.4 MB, untracked). The audit's hygiene recommendation is satisfied. However,paper/also contains LaTeX build artifacts (main.aux,main.log, multiplepgftest*.{aux,log,pdf}) that should probably be.gitignore'd — separate concern. LN: We should git ignore LaTeX build artifacts sitewide. This is a change to make.audit.mdat the repo root, untracked, not gitignored. Hakyll will not pick it up (its only matching rule iscontent/*.md, not repo-root*.md), so the build is safe. But the file is in limbo: a casualgit add .could accidentally commit it.migrate_html.md— same situation asaudit.md. LN: the .md files will be removed after everything in this branch is done and we merge back into the main branch. They're temporary as we work.- Recommendation: Either gitignore both workspace docs (
audit.md,migrate_html.md,paper/*.docx,paper/*.aux,paper/*.log,paper/pgftest*.*) or move them into a trackeddocs/folder. - Verdict: Mixed. Docx move is correct; workspace docs need a decision. LN: the docx was a temporary artifact that I will remove once the rewrite is entirely complete; this can be ignored.
6.5 Link integrity
grep -r beyond-comorbidity-indices across the full repo returns only two hits: the new essay itself and the bci-paper.bib comment header. No templates, partials, Haskell sources, or other content files reference the old content/essays/beyond-comorbidity-indices.md flat path. No stale internal links from the move.
7. Scope creep (changes outside the audit)
The following changes appear on the audit-fixes branch but were not requested by audit.md. They are not necessarily bad, but each represents an expansion of the review surface area:
build/Stats.hsblaze-html rewrite. The audit asked for a smarterstripHtmlTags. The branch delivers a full conversion of the HTML-generation paths toblaze-html, plus two new cabal dependencies. Defensible quality improvement; tripled the line count of affected functions.build/Site.hsdraft-essay mode. A newSITE_ENV=devenv-var gate that includescontent/drafts/essays/**in dev builds. Clean implementation, but it's a new feature unrelated to any audit finding. LN: this is a new feature that I added to give me a space to pull pieces from scratch ideation to public facing. It should stay, and was intentional.build/Site.hsrandom-pages.json fix. The previousintercalate "," . map showwas technically invalid for any URL containing a backslash or non-ASCII character; the newAeson.encodeis correct. Quiet but real correctness improvement. LN: this is good!static/css/typography.cssfigure layout. Addsfigure:has(> img) { display: table }for shrink-wrapped image captions; changes figcaption font-size fromvar(--text-size-small)to0.92em. LN: we are still debugging some things related to the caption font sizes, so feel free to ignore this.static/css/build.cssheatmap rules. New CSS classes.hm0..hm4,.hm-lblto support the moved-out-of-Stats.hs heatmap. Required by the Stats.hs change, not in itself a scope-creep item.static/css/base.cssdesign tokens.--transition-medium,--transition-slow,--bp-phone,--bp-tablet,--bp-desktop,--bp-wideare defined but never consumed. Started L-4.1.7 and M-4.1.6 work without finishing.static/js/toc.jsauto-collapse removal. TheautoCollapsed/collapseOncedead-code path is deleted. This is a real UX change (TOC no longer auto-collapses on first scroll) and is not flagged anywhere. LN: see below; this was intentional by me.- New
bibliography: data/bci-paper.bibandrepository:frontmatter on the BCI essay — supports the rewrite but adds metadata fields the templates may not yet render.
8. Audit findings NOT addressed by this branch
These items are listed in audit.md but are not closed by the diff. Most are LOW; the listed MEDIUMs are explicitly punts.
Haskell core:
- L-1.1.1: blog posts still flat-only (no
content/blog/*/index.mdform). - L-1.1.4:
Site.hslibrary.htmlstill callsloadAll32 times for 8 portals. - L-1.2.3:
abstractFieldstill only strips single-Paraabstracts. - L-1.2.5:
pageScriptsFieldstill uses script path as Hakyll item identifier (collision risk). - L-1.7.2:
Stability.hsunsafeCompilerstill breaks Hakyll dep tracking on git HEAD changes. - L-1.11.1:
Utils.wordCountstill counts HTML tokens as words. - L-1.3.4: misleading "fixed" comment for the
String → Text → ByteStringround-trip in Stats.hs (see section 2.4). - H-1.10.1 partially: directory-form essays now appear on author pages (Authors.hs fix), but
Stats.hsstill uses rawcontent/essays/*.mdpatterns — the writing-statistics page still under-counts them.
Filters:
- L-2.1.4:
Images.renderKvsstill does not escape attribute keys. - L-2.7.2:
Wikilinks.slugifytrailing-period quirk ("end."→"end"). - L-2.8.2:
EmbedPdf.parsePageHashsilent empty return. - NIT (
Filters/Typography.hs): duplicateescHtmlstill local.
JavaScript / CSS:
- M-3.3.2:
selection-popup.jsannotation picker swatches still mouse-only. - M-3.3.3:
sidenotes.jssidenote focus toggle still click-only. - L-3.4.2: mixed
varvsconst/letacross JS files (no mass conversion). - L-4.1.7: transition timings — token added but no call sites migrated.
- M-4.1.6: breakpoint tokens — added but
@mediaqueries not migrated. (CSS@mediacannot consume custom properties anyway; partial fix is the structural ceiling.) - L-4.1.8:
font-variant: small-capsshorthand still inreading.css/library.css. - L-4.2.3:
head.htmlstill loads all component CSS unconditionally.
Tools / Makefile:
- L-5.1.2:
embed.pyno--quietflag. - L-5.1.3:
embed.pyneeds_update()still re-stats every HTML. - L-5.2.5:
import-poetry.pywrite_textnoerrors=kwarg. - L-6.1.4:
make cleanstill doesn't touchdist-newstyle/or stale embeddings. - M-6.1.3: build-failure cleanup not implemented (acknowledged in a comment as intentional).
9. New code-duplication introduced by the branch
The audit's section 8.1 ("Duplicate code") explicitly called out 5+ implementations of escHtml, 4+ of trim, 2 of slugify, 2 of stringify, 2 of normaliseUrl. The branch added consolidation helpers in Utils.hs (escapeHtmlText, trim, authorSlugify, authorNameOf) but several modules immediately re-introduced their own copies:
| Function | Where it now lives | Where it should be | Status |
|---|---|---|---|
escapeHtmlText (Text variant) |
Utils.hs |
— | Used by Images, Smallcaps, Score, Viz ✓ |
escHtml (local Text variant) |
Filters/Typography.hs |
Utils.escapeHtmlText |
Untouched duplicate |
escAttr (local String variant) |
Filters/Transclusion.hs |
Utils.escapeHtml |
New duplicate introduced |
escText/escAttr (local String variants) |
Catalog.hs |
Utils.escapeHtml/escapeHtmlText |
New duplicates introduced |
safeHref / URL allowlist |
Stats.hs and Catalog.hs |
Utils.isSafeHref (does not exist) |
Now duplicated |
percentDecode |
Backlinks.hs and SimilarLinks.hs |
Utils.percentDecode (does not exist) |
New duplication, byte-identical |
trim |
Utils.hs |
— | Used by Wikilinks, Transclusion, EmbedPdf ✓ |
trim (Hakyll re-export) |
Contexts.hs |
Utils.trim |
Untouched alternate import |
authorSlugify, authorNameOf |
Utils.hs |
— | Used by Authors, Contexts ✓ |
stringify (Pandoc inline) |
Filters/Images.hs (expanded) |
Utils.stringify (does not exist) |
Still local; expanded but not factored |
Net: the branch consolidated escHtml for 4 files but introduced 3 new local duplicates (Transclusion, Catalog ×2). It centralized slugify/nameOf correctly. It added trim to Utils but did not fully migrate Contexts. It introduced two byte-identical copies of percentDecode and a third instance of the URL allowlist pattern. The duplication footprint of the codebase is roughly unchanged in net terms — different functions, same total count.
10. Build status
cabal build reports "Up to date" — meaning every Haskell module compiles successfully with the audit-fixes diff applied. There are no broken module references, no unbound names, no type mismatches. The deletion of Metadata.hs is consistent with both the cabal file and all Haskell source. The new Patterns.hs is consistent with the cabal file and is imported correctly by Authors.hs, Backlinks.hs, and Tags.hs. The new blaze-html/blaze-markup cabal entries are consistent with the actual imports in Stats.hs. The -Wno-unused-imports removal does not produce any new errors because the cabal file has no -Werror.
This does not validate the behavior of any change — only that the program is well-formed and links. The Stats.hs blaze rewrite, the Sidenotes AST refactor, and the Stats.hs symlink-aware walkDir should all be exercised against a real _site/ build before deploy.
11. Recommended actions before merge / deploy
Must-fix:
- Move
content/essays/modern_idolatry.mdtocontent/drafts/essays/modern_idolatry.md(creating the directory if needed). Otherwise it will publish on the next non-dev build despite itsstatus: "Draft"frontmatter. - Either resolve or remove the placeholder text in Figure 1 and eFigure 3 of the BCI essay before deploy.
Should-fix:
- Adopt
Patterns.hsinStats.hsandSite.hs. Replace the rawcontent/essays/*.mdpatterns atbuild/Stats.hs:747,901withPatterns.essayPattern, and replaceSite.hs'spublishedEssayswithPatterns.essayPattern. Otherwise H-1.10.1 is only partially closed and the H-1.1.2 drift the audit warned about persists. - Remove or fix the misleading "L-1.3.4 fixed" comment in
Stats.hs. The code is unchanged frommain; the comment is false. - Document the silent removal of TOC auto-collapse-on-scroll in the commit message, or restore it. The behavior change should be intentional and visible. LN: this was a change that I made based on user feedback. It should stay.
- Decide what to do with
audit.md,migrate_html.md, andpaper/*.docx/paper/*.aux/paper/*.log. Either gitignore them or move them into a trackeddocs/folder. Currently they're in working-tree limbo.
Nice-to-have:
- Factor
percentDecodeintoUtils.percentDecode(called by both Backlinks and SimilarLinks). FactorsafeHref/isSafeUrlintoUtils.isSafeHref(called by Stats and Catalog). Replace localescAttrin Transclusion and Catalog withUtils.escapeHtml. - Either consume the new
--transition-medium/--transition-slow/--bp-*tokens inbase.cssor remove them. As-is they're documentation placeholders that imply a refactor that hasn't happened. - Generate and commit
tools/model-checksums.sha256sodownload-model.shactually verifies, not just warns. - Migrate
Contexts.hs's implicitHakyll.trimimport toUtils.trimto complete the trim consolidation. - Add
tools/__pycache__/andpaper/main.{aux,log,blg,out},paper/pgftest*.*to.gitignoreandgit rm --cachedthe existing.pycentries. - If the
repository:frontmatter on the BCI essay is intended to render somewhere on the page, verify the template emits it; otherwise it's metadata-only.
12. Final assessment
Is every change a net positive? No. Three concerns rise above the noise:
- The accidental-publish risk on
content/essays/modern_idolatry.mdis a regression introduced by this branch (the file was at the repo root inmain-state and would not have been published; it is now atcontent/essays/and will be published). - The partial adoption of
Patterns.hsleaves the writing-stats page still affected by the same H-1.10.1 bug class the branch was meant to close. - The misleading "fixed" comment in
Stats.hsfor L-1.3.4 will mislead any future reader auditing the same line.
Is the branch worth merging? Yes, after the must-fix items above are addressed. The fixes that landed correctly are substantial and high-value: the lowerExt CRITICAL is closed (which alone restores the entire WebP pipeline), every other HIGH except the partial H-1.10.1 is closed, the CSS a11y improvements are real and well-targeted, the build pipeline is more robust against deploy mistakes, and the BCI essay rewrite is high-quality. The Stats.hs blaze refactor and the Site.hs draft-mode feature are valuable improvements even though they exceed the audit's brief. The build still compiles cleanly.
The three concerns are all easily fixable in the next pass. None of them require reverting any of the work that's already been done.