48 KiB
48 KiB
bDS2 Elixir Anti-Pattern & Best-Practice Audit
Audited: 2026-05-06 Scope: Elixir application, Phoenix LiveView UI, Ecto DB layer, Desktop (wx) integration, Rendering/Generation pipelines
How to use this file
- Pick a section.
- Search the codebase for the file/line references.
- Write a failing test that reproduces the issue.
- Fix the code.
- Run the full test suite and
mix dialyzer. - Delete the item from this file.
Critical (Fix Immediately)
CSM-001 — Atom Table Exhaustion Vulnerability ✅ FIXED
- Fixed: 2026-05-06
- What was done:
- Added
BDS.MapUtils.safe_atomize_key/1andBDS.MapUtils.safe_atomize_keys/1— usesString.to_existing_atom/1with rescue fallback to keep unknown keys as strings. - Replaced all 6 affected
String.to_atomcall sites:lib/bds/import_definitions.ex—atomize_keys/1→MapUtils.safe_atomize_keys/1lib/bds/import_execution.ex—normalize_report/1→MapUtils.safe_atomize_keys/1lib/bds/ai/catalog.ex—atomize_map_keys/1→MapUtils.safe_atomize_keys/1,parse_modality/1→MapUtils.safe_atomize_key/1lib/bds/ai/chat_tools.ex—metadata_attrs/2→MapUtils.safe_atomize_key/1lib/bds/desktop/automation.ex—atomize_map/1→MapUtils.safe_atomize_keys/1
- Replaced lower-risk
String.to_atomwithString.to_existing_atom/1:lib/bds/ui/menu_bar.ex— sidebar view and singleton editor command IDslib/bds/ui/workbench.ex—normalize_type/1lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex—map_value/3lib/bds/release_packaging.ex—normalize_platform/1
- Updated
test/bds/bounded_atoms_test.exsto enforce noString.to_atomon dynamic data (replaced oldString.to_existing_atomban).
- Added
CSM-002 — Search Loads Entire Tables into Memory ✅ FIXED
- Fixed: 2026-05-07
- What was done:
- Replaced
search_posts/3andsearch_media/3with SQL-level filtering and pagination. - Blank queries now use pure Ecto queries with
whereclauses for status, language, year/month, date range, tags, categories, and missing translations. - Non-blank (FTS) queries use a CTE (
WITH fts_results AS (...)) to preservebm25ordering, joined with the posts/media table, with all filters applied in SQL. - Tag and category overlap filtering uses
json_eachinEXISTSsubqueries. - Missing-translation filtering uses a
NOT EXISTScorrelated subquery. - Count uses
select count+Repo.oneinstead oflength(all_records). - Pagination uses SQL
LIMIT/OFFSETinstead ofEnum.drop/Enum.take. - Removed all old Elixir-side filter helpers:
candidate_post_ids,load_posts_in_order,filter_posts,paginate,matches_status?,matches_overlap?, etc. - Added comprehensive tests for blank-query and non-blank-query filtering across all filter dimensions.
- Replaced
CSM-003 — Non-Atomic Side Effects in Post CRUD ✅ FIXED
- Fixed: 2026-05-07
- What was done:
- Replaced all 11
Repo.delete!call sites withRepo.delete+{:error, _}handling:lib/bds/posts.ex—delete_post/1lib/bds/scripts.ex—delete_script/1lib/bds/media.ex—delete_media/1,delete_media_translation/3lib/bds/templates.ex—delete_template/2,remove_orphan_templates/2lib/bds/tags.ex—delete_tag/1,merge_tags/2lib/bds/projects.ex—delete_project/1lib/bds/posts/translations.ex—delete_post_translation/1lib/bds/posts/translation_validation.ex—fix_invalid_database_row/1
- Reordered
delete_post/1to performRepo.deletefirst, then clean up files/embeddings/search/links. Side effects now only run after DB commit succeeds. - Same reordering applied to
delete_script/1,delete_media/1,delete_template/2, anddelete_post_translation/1. delete_media/1now wraps translation + media deletes in aRepo.transactionfor atomicity.- Tags and projects already used
Repo.transaction; replaced innerRepo.delete!withRepo.delete+Repo.rollbackon error. - Added tests for delete atomicity and not-found handling.
- Replaced all 11
CSM-004 — Blocking init/1 + Missing terminate/2 in Job Runner ✅ FIXED
init/1 + Missing terminate/2 in Job Runner- Fixed: 2026-05-08
- What was done:
- Moved
JobStore.attach_runner/2frominit/1to a newhandle_continue(:attach_and_start)callback, so supervisor startup is no longer blocked by the synchronous call. - Added
terminate/2callback that callsJobStore.detach_runner/2(withtry/catchfor shutdown safety), centralizing cleanup that was previously scattered across individual exit paths. - Added
handle_info({:EXIT, _pid, _reason})clause to handle trapped exit signals from linked processes. - Removed redundant inline
detach_runnercalls fromhandle_call(:cancel), task result handler, and:DOWNhandler —terminate/2now handles all detach cleanup. - Changed
restart: :temporarysince job runners are one-shot processes that should not auto-restart on failure. - Added
@impl trueto allhandle_infoclauses. - Fixed pre-existing bug in
JobStore.detach_runnerhandler whereupdate_in/2macro result was incorrectly double-wrapped, corrupting state. - Added test: start a runner, kill it externally (not via cancel), assert
JobStoreno longer contains the dead PID.
- Moved
CSM-005 — Client-Side Filtering of Entire Tables ✅ FIXED
- Fixed: 2026-05-08
- What was done:
- Sidebar (
lib/bds/ui/sidebar.ex):- Removed
list_posts/1andlist_media/1that loaded all records into memory. - Replaced
apply_post_filters/1andapply_media_filters/1(Elixir-side filtering) with SQLWHEREclauses using Ecto dynamic queries and SQLitejson_eachfragments. - Page/non-page split now uses
EXISTS (SELECT 1 FROM json_each(categories) WHERE lower(value) = 'page')in SQL. - Search, year/month, tag, and category filters all push to SQL via
maybe_where_search,maybe_where_year,maybe_where_month,maybe_where_all_tags,maybe_where_all_categories. - Aggregate queries (
year_month_counts,available_tags,available_categories) useEcto.Adapters.SQL.query!withjson_eachcross-joins,GROUP BY, andDISTINCT. - Pagination uses SQL
LIMITinstead ofEnum.take. tag_count/1replaceslist_tags/1+length/1withRepo.one(select: count(tag.id)).- Fixed
group_posts/1O(n²)acc.draft ++ [post]pattern — now usesEnum.group_by/2(also fixes CSM-024).
- Removed
- Tags (
lib/bds/tags.ex):posts_with_tag/2now usesEXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)instead of loading all posts.posts_with_any_tag/2now usesjson_eachcross-join with a JSON parameter for the tag name list.post_tag_names/1now selects only thetagscolumn instead of loading full post records.
- Dashboard (
lib/bds/ui/dashboard.ex):post_statsusesGROUP BY post.status, SELECT {status, count(id)}— no longer loads all posts.media_statsusesSELECT count(id), coalesce(sum(size), 0)and a separate image count query withLIKE 'image/%'.tag_cloud_itemsandcategory_countsuse raw SQL withjson_eachcross-joins andGROUP BY.timeline_entriesuses SQLstrftime+GROUP BYfor year/month aggregation.recent_postsuses SQLORDER BY updated_at DESC LIMIT 5.
- Posts (
lib/bds/posts.ex):dashboard_stats/1usesGROUP BY post.status, SELECT {status, count(id)}instead of loading all statuses.
- Capabilities (
lib/bds/scripting/capabilities/):tag_post_ids/2usesjson_eachfragment +SELECT post.idinstead of loading all posts.names_with_counts/2uses raw SQL withjson_each+GROUP BYinstead of loading all posts.posts_by_status/2filters at SQL level instead of loading all posts and filtering in Elixir.
- Added 20 tests in
test/bds/csm005_sql_filtering_test.exscovering dashboard stats, tag cloud, sidebar page/post separation, tag/search/year-month filters, available aggregates, and media filtering.
- Sidebar (
High Severity
CSM-006 — N+1 Queries in Reindexing & Rendering ✅ FIXED
- Fixed: 2026-05-08
- What was done:
- Batch INSERT for reindexing: Replaced per-row
Repo.query!INSERT inreindex_posts/2andreindex_media/2with multi-row batch INSERTs. Rows are chunked at 166 per batch (SQLite 999-parameter limit ÷ 6 columns). Translations were already preloaded in batch; fixed O(n²)acc ++ [translation]pattern inpreload_post_translationsandpreload_media_translationsby replacing withEnum.group_by. - Rendering — preloaded post records:
PostRendering.post_assigns/2now accepts an optional:_post_recordkey in assigns, skipping theRepo.get(Post, id)re-query when the record is already available. - Generation outputs pass records:
build_page_outputsandbuild_post_outputsinoutputs.exnow pass the already-loaded post/translation records via:_post_record, eliminating per-post DB queries during generation. - ListArchive already used
load_post_records_batch(batch query) — no change needed. - Added telemetry-based query counting tests: reindex 100 posts/media and assert total query count <10.
- Batch INSERT for reindexing: Replaced per-row
CSM-007 — Monolithic State Rebuild ("God Function") ✅ FIXED
- Fixed: 2026-05-09
- What was done:
- Decomposed
reload_shell/2into four focused updaters:refresh_layout/2— No DB queries. Recomputes workbench-derived assigns (activity_buttons, panel_tabs, current_tab, status_bar, sidebar_header, editor_meta) from existing socket.assigns.refresh_sidebar/2— Queries sidebar data only, then callsrefresh_layout.refresh_content/2— Queries projects, dashboard, git badge, and sidebar data, then callsrefresh_layout.reload_shell/2— Full refresh: tab_meta sync, task status, static data, then callsrefresh_content. Kept for mount, project switch, session restore, and settings changes.
- Replaced all call sites with the minimal refresh needed:
- Layout-only (
refresh_layout): toggle_sidebar, toggle_panel, toggle_assistant_sidebar, select_panel_tab, sync_layout, resize_panel, open_tasks_panel, select_tab, close_tab, toggle_offline_mode, layout menu actions (toggle, close_tab). - Sidebar (
refresh_sidebar): select_view, all sidebar filter events, sidebar menu actions (view_posts, view_media, edit_preferences, etc.), chat/import editor tab_meta updates. - Content (
refresh_content): entity_changed (CLI sync), tags_changed, sidebar create/delete. - Full reload (
reload_shell): mount, activate_project, restore_workbench_session, set_page_language, settings_changed.
- Layout-only (
- Updated Bridges callbacks to use focused refreshers:
refresh_layoutfor toggle events and close_tab,refresh_sidebarfor view switches and tab meta updates,refresh_contentfor entity/tag changes. - Split
@local_menu_actionsinto@layout_menu_actionsand@sidebar_menu_actionsfor correct dispatch. - Fixed
false || truebug inrefresh_layoutwhereoffline_mode = assigns[:offline_mode] || trueincorrectly defaultedfalsetotrue. - Added 7 tests in
test/bds/csm007_reload_shell_test.exsusing telemetry-based query counting: toggle_sidebar (0 queries), toggle_panel (0 queries), sync_layout (0 queries), select_panel_tab (0 queries), toggle_offline_mode (1 query — settings write only), select_view (sidebar queries but no dashboard/projects), sidebar_search (no dashboard queries).
- Decomposed
CSM-008 — DB Queries During Render Path ✅ FIXED
- Fixed: 2026-05-09
- What was done:
- Panel renderer (
lib/bds/desktop/shell_live/panel_renderer.ex):render_post_linksandrender_git_logno longer call DB functions during render. Instead they read from pre-computed assigns (panel_post_links,panel_git_entries).- Renamed
post_link_entries/1→fetch_post_link_entries/1andgit_log_entries/1→fetch_git_log_entries/1, made them public for use by event handlers.
- Shell LiveView (
lib/bds/desktop/shell_live.ex):- Added
refresh_panel_data/1that fetches panel data (post links or git log) based on the active panel tab and stores results in assigns. refresh_layout/2detects whencurrent_taborpanel.active_tabchanged and callsrefresh_panel_data/1only when stale — no DB queries on re-renders.- Initialized
panel_post_linksandpanel_git_entriesassigns in mount.
- Added
- Tab meta (
lib/bds/desktop/shell_live/tab_helpers.ex):sync_tab_metanow skipsderived_tab_metaDB queries when existing meta already has both title and subtitle populated (meta_complete?/1guard).
- Added 5 tests in
test/bds/csm008_render_path_test.exs: post_links re-render (0 queries), git_log re-render (0 queries), output panel switch (0 queries), tasks panel switch (0 queries), tab meta skip for complete meta (0 queries).
- Panel renderer (
CSM-009 — Thumbnail Generation: Missing Error Handling ✅ FIXED
- Fixed: 2026-05-09
- What was done:
- Replaced all bang variants with non-bang error-tuple handling:
Image.autorotate!→Image.autorotatewith{:ok, {image, rotation_info}}destructuring.Image.thumbnail!→Image.thumbnailreturning{:ok, image}/{:error, reason}.Image.embed!→Image.embedwithwithchain.Image.flatten!→Image.flattenwithwithchain.Image.write!→Image.writewith{:ok, _}/{:error, reason}handling.
File.mkdir_presult is now checked — errors halt thumbnail generation with{:error, reason}.write_all_thumbnailsusesEnum.reduce_whileto stop on first error and return{:error, reason}.ensure_thumbnailsspec updated to:ok | {:error, term()}.regenerate_thumbnailspropagates{:error, reason}fromensure_thumbnails.regenerate_missing_thumbnailsreplacedtry/rescuewithcaseon the new error tuples.- Call sites in
BDS.Media(import_media,replace_media_binary) uselog_thumbnail_error/2— media operations succeed even if thumbnails fail, with a warning logged. - Added 6 tests in
test/bds/csm009_thumbnail_error_handling_test.exs: corrupt image returns{:error, _}, non-image returns:ok, missing source returns{:error, _}, regenerate corrupt returns{:error, _}, regenerate_missing counts failures, import succeeds despite thumbnail failure.
- Replaced all bang variants with non-bang error-tuple handling:
CSM-010 — rescue for Control Flow in Data Layer ✅ FIXED
rescue for Control Flow in Data Layer- Fixed: 2026-05-09
- What was done:
- Added
BDS.Repo.ready?/0— a lightweight probe that queriessqlite_master(parameterized) to check if core tables exist, without raising exceptions. - Replaced all 4
rescueblocks inShellData(project_snapshot/0,dashboard/1,sidebar_view/3,git_badge_count/2) with upfrontRepo.ready?()checks. - All four functions now return
{:ok, result}/{:error, :not_ready}tuples instead of silently returning defaults via rescue. - Updated callers in
ShellLive.refresh_content/2andShellLive.refresh_sidebar/2to pattern-match the new tuples and fall back to empty defaults only on{:error, :not_ready}. - Made
default_project_snapshot/0public for use by callers handling the not-ready case. - Added 10 tests in
test/bds/csm010_rescue_control_flow_test.exs:Repo.ready?returns true when DB is available, each of the 4 functions returns{:ok, _}when DB is ready and{:error, :not_ready}when the Repo is stopped.
- Added
Medium Severity
CSM-011 — No URL State / Deep Linking ✅ FIXED
- Fixed: 2026-05-09
- What was done:
mount/3now reads?view=and?tab=<type>:<id>query params and applies them to the initial workbench state, enabling deep linking on page load.- Added
push_url_state/1— after state-changing events (select_view,select_tab,close_tab,open_sidebar_item, sidebar menu actions, project switch), pushes aurl-stateevent to the client with the serialized URL. - Added JS handler in the
AppShellhook that callshistory.replaceStateto update the browser URL without triggering navigation. - URL encoding:
?view=<sidebar_view>(omitted whenposts, the default) and?tab=<type>:<id>(omitted when no tab is active). Invalid or unknown params are silently ignored. - Used
push_event+history.replaceStateinstead ofpush_patch/handle_paramsto maintain compatibility with existinglive_isolatedtests. - Added 10 tests in
test/bds/csm011_url_state_test.exs: mount with?view=media, mount with default, mount with invalid view, mount with?tab=post:<id>, mount with both params,select_viewpushes url-state,select_viewposts pushes clean URL,select_tabpushes url-state,close_tabremoves tab from URL,open_sidebar_itempushes url-state.
CSM-012 — Desktop File Dialog Blocks Event Handler ✅ FIXED
- Fixed: 2026-05-09
- What was done:
- Replaced synchronous
FilePicker.choose_file/1call inSidebarCreate.create/4for the "media" kind withTask.async, storing the task ref in a newfile_picker_tasksocket assign. - Added
handle_file_picker_result/2private function inShellLivewith clauses for{:ok, _media},:cancel,{:error, %{message: _}}, and{:error, reason}. - Extended the existing
handle_info({ref, result}, socket)andhandle_info({:DOWN, ref, ...}, socket)handlers to match onfile_picker_taskref. - Added
BDS_DESKTOP_AUTOMATIONguard toFilePicker.choose_file/1— returns:cancelimmediately in automation/test mode, preventing native dialogs from opening during tests. - Initialized
file_picker_task: nilassign in mount. - Added 5 tests in
test/bds/csm012_file_picker_async_test.exs: event handler returns within 100ms, LiveView handles other events while task is pending, task completion doesn't crash LiveView, cancel is handled gracefully, error results don't crash LiveView.
- Replaced synchronous
CSM-013 — Bang Functions in Rendering Pipelines ✅ FIXED
- Fixed: 2026-05-09
- What was done:
lib/bds/rendering/filters.ex—render_macro_template:- Replaced
Liquex.parse!withLiquex.parse(non-bang) andcasematch on{:ok, ast}/{:error, reason, line}. - Wrapped
Liquex.render!intry/rescuecatchingLiquex.Errorspecifically (no non-bangrenderexists in Liquex). - Removed broad
rescue _error -> ""— errors now log viaLogger.warningwith template path and reason before returning"".
- Replaced
lib/bds/rendering/template_selection.ex—render_template:Liquex.parsewas already non-bang; addedelseclause to normalize the 3-tuple{:error, reason, line}into{:error, "reason at line N"}.- Wrapped
Liquex.render!intry/rescuecatchingLiquex.Errorspecifically, returning{:error, message}. - Removed broad
rescue error -> {:error, error}.
lib/bds/rendering/post_rendering.ex—post_data_json_value:- Replaced
Jason.encode!withJason.encodeandcasematch — returns"{}"on encode failure instead of crashing.
- Replaced
- Added 5 tests in
test/bds/csm013_bang_rendering_test.exs: template syntax error returns{:error, _}fromrender_template, broken template inrender_post_pagereturns{:error, _},{% break %}render error returns{:error, _}, normal post context produces valid JSON, non-encodable data returns"{}"fallback.
CSM-014 — O(n²) Loops from length/1 Inside Iteration ✅ FIXED
length/1 Inside Iteration- Fixed: 2026-05-09
- What was done:
lib/bds/generation/outputs.ex—build_category_outputs:- Bound
total_pages = length(paginated_posts)andtotal_items = length(posts)before the nested loop. Previously calledlength/14 times per page × language iteration.
- Bound
lib/bds/generation/outputs.ex—build_root_outputs:- Bound
total_items = length(posts)before the loop, reused bypagination_for_page. Previously calledlength(posts)on every page iteration.
- Bound
lib/bds/generation/outputs.ex—build_paginated_archive_outputs:- Bound
total_items = length(posts)before the loop. Previously calledlength(posts)inside the nested page × language loop.
- Bound
lib/bds/rendering/list_archive.ex—build_day_blocks:- Bound
last_index = length(grouped_blocks) - 1before theEnum.map. Previously calledlength(grouped_blocks)on every iteration.
- Bound
lib/bds/publishing.ex—run_upload:- Bound
target_count = max(length(targets), 1)before theEnum.reduce_while. Negligible impact (3 targets) but fixed for consistency.
- Bound
lib/bds/ui/sidebar.exacc.draft ++ [post]was already fixed by CSM-005 (replaced withEnum.group_by).- Added 3 tests in
test/bds/csm014_length_in_loop_test.exs: multi-page pagination correctness, single-page pagination correctness, 1000-post linear time completion.
CSM-015 — Missing DB Indexes on Foreign Keys ✅ FIXED
- Fixed: 2026-05-09
- What was done:
- Added migration
20260509145208_add_missing_indexes.exswith indexes for all missing foreign keys and frequently filtered columns:- FK indexes:
media.project_id,post_media.post_id,post_media.media_id,chat_messages.conversation_id,embedding_keys.post_id,embedding_keys.project_id,dismissed_duplicate_pairs.project_id,import_definitions.project_id,publish_jobs.project_id. - Filter indexes:
posts.status,posts.published_at,posts.language. - Composite index:
db_notifications(entity_type, entity_id).
- FK indexes:
- Added 12 tests in
test/bds/csm015_missing_indexes_test.exsverifying viaEXPLAIN QUERY PLANthat all indexed columns use index lookups.
- Added migration
CSM-016 — String Concatenation for Paths ✅ FIXED
- Fixed: 2026-05-09
- What was done:
lib/bds/rendering/file_system.ex— Extractedensure_liquid_ext/1usingPath.extname/1to check before appending.liquid, preventing double-extension bugs (e.g."header.liquid.liquid").lib/bds/rendering/metadata.ex—menu_item_hreffor:pagekind now appliesURI.encode/1to the slug (matching the existing:category_archivepattern).href_for_language/1now usesString.trim_trailing(prefix, "/")before appending/to prevent double trailing slashes.lib/bds/rendering/metadata.ex— Addedmenu_items_from_raw/1public function for testability.lib/bds/rendering/links_and_languages.ex—post_path/2fornillanguage now usesPath.join(["/", year, month, day, slug]) <> "/"instead of building withindex.htmlthen stripping it. Language-prefix clause usesString.trim_trailing/2to prevent double slashes.canonical_media_path_by_source_path/1usesPath.join("/", media.file_path)instead of"/" <> file_path.lib/bds/publishing.ex—ensure_trailing_slash/1made public for testability (implementation already correct).- Added 17 tests in
test/bds/csm016_path_concatenation_test.exs: FileSystem extension handling (bare name, double extension, nested paths),href_for_language(empty, with/without trailing slash), menu item href encoding (special chars, plain slugs, category slugs), post_path construction (leading/trailing slashes, no double slashes, language prefix),language_prefix(same/nil/different language),ensure_trailing_slash(without/with trailing slash, empty string).
CSM-017 — send(self(), ...) Component Chatter ✅ FIXED
send(self(), ...) Component Chatter- Fixed: 2026-05-09
- What was done:
- Created
BDS.Desktop.ShellLive.Notify— a single dispatch module that standardizes all parent communication from LiveComponent editors. Provides typed functions:output/3,output/4,tab_meta/4,tab_meta_merge/3,close_tab/2,reload/0,dirty/3,command/2,open_sidebar_item/2, andparent/1(escape hatch for chat-specific messages). - Replaced all 25+
send(self(), ...)calls across 11 editor components withNotify.*calls:post_editor.ex— 13 calls (dirty, tab_meta, close_tab, output)media_editor.ex— 7 calls (dirty, tab_meta, output)chat_editor.ex— 15 calls (output, tab_meta, open_sidebar_item, plus chat-specific viaNotify.parent)template_editor.ex— 3 calls (close_tab, output, reload)script_editor.ex— 3 calls (close_tab, output, reload)misc_editor.ex— 4 calls (command, output, tab_meta_merge, open_sidebar_item)settings_editor.ex— 2 calls (output, parent)tags_editor.ex— 2 calls (output, parent)menu_editor.ex— 1 call (output)import_editor.ex— 2 calls (tab_meta, output)overlay_manager.ex— 3 calls (parent for cross-component routing)
- Consolidated Bridges from 30+ editor-specific
handle_infoclauses to 4 generic handlers:{:editor_output, ...},{:editor_tab_meta, ...},{:editor_dirty, ...},{:editor_command, ...}. - Removed 18 editor-specific message atoms from Bridges (
:post_editor_output,:media_editor_output,:post_editor_dirty,:media_editor_dirty,:post_editor_tab_meta, etc.). - Kept chat-specific messages (
{:chat_editor_task_started, ...},{:chat_editor_toggle_sidebar}, etc.) and cross-component routing ({:post_editor_insert_content, ...}) in Bridges since they originate from AI streaming or overlay actions, not from editor self-notification. - Added 24 tests in
test/bds/csm017_component_chatter_test.exs: 11 source-level tests asserting nosend(self(), ...)in any editor file, 1 aggregate test verifying all shell_livesend(self(), ...)calls are innotify.ex, 2 Bridges tests verifying old patterns are gone and new generic handlers exist, 10 Notify API tests verifying each function sends the correct message.
- Created
Low Severity / Code Quality
CSM-018 — @moduledoc false Epidemic ✅ FIXED
@moduledoc false Epidemic- Fixed: 2026-05-10
- What was done:
- Replaced
@moduledoc falsewith descriptive@moduledocstrings in all 12 listed public modules:lib/bds/i18n.ex— language support, locale resolution, flag emoji mappinglib/bds/map_utils.ex— mixed-key map utilities and safe atom conversionlib/bds/bounded_atoms.ex— allow-list-based dynamic atom conversionlib/bds/document_fields.ex— frontmatter field access with key aliaseslib/bds/import_definitions.ex— CRUD for WXR import configurationslib/bds/publishing.ex— GenServer for site upload job coordinationlib/bds/settings.ex— global key-value settings persistencelib/bds/templates.ex— Liquid template lifecycle managementlib/bds/ai.ex— AI endpoint config, secrets, and inference dispatchlib/bds/mcp.ex— MCP server facade for external AI agentslib/bds/scripting/capabilities.ex— Lua scripting capability map builderlib/bds/scripting/api_docs.ex— machine-readable Lua API documentation
- Replaced
CSM-019 — Missing @spec on Public Functions ✅ FIXED
@spec on Public Functions- Fixed: 2026-05-10
- What was done:
- Added
@specannotations to every public function across 25 files in rendering, generation, publishing, UI, and scripting modules. - Added
@type t :: %__MODULE__{}toworkbench.exandfile_system.exto support struct-based specs. - Rendering:
post_rendering.ex,links_and_languages.ex,labels.ex,metadata.ex,file_system.ex,filters.ex,list_archive.ex,template_selection.ex - Generation:
generated_file_hash.ex - Publishing:
publishing.ex - UI:
registry.ex,session.ex,sidebar.ex,menu_bar.ex,commands.ex,dashboard.ex,workbench.ex - Scripting:
job_store.ex,job_runner.ex,job_supervisor.ex,capabilities.ex,capabilities/util.ex,api_docs.ex - Dialyzer passes with 0 errors; all 619 tests pass.
- Added
CSM-020 — Deeply Nested case Instead of with ✅ FIXED
case Instead of with- Fixed: 2026-05-10
- What was done:
lib/bds/import_definitions.ex—delete_definition/1: Replaced nestedcasepiped into anothercasewith a flatwithchain:Repo.get→Repo.delete→{:ok, :deleted}, withelseclauses forniland{:error, _}.lib/bds/publishing.ex—handle_call({:update_job, ...}): Replacedcase Repo.getwithwith %PublishJob{} = job <- Repo.get(...). Also replacedRepo.update!()withRepo.update()to avoid crashes on changeset errors.lib/bds/templates.ex—update_template/2: Replaced outercase Repo.getwithwith+ extracteddo_update_template/2private function. Collapsed three levels of nestedcase(Repo.get → transaction_result → sync_side_effects) into a single flatwithchain.- Added 7 tests in
test/bds/csm020_nested_case_test.exs: delete_definition success and not-found, update_template success and not-found, source-level assertions that all three files usewithinstead of nestedcase.
CSM-021 — cond Where Pattern Matching Suffices ✅ FIXED
cond Where Pattern Matching Suffices- Fixed: 2026-05-11
- What was done:
lib/bds/ai.ex—get_endpoint/2: Replacedcond do is_nil(x) and ...; true -> ... endwith a simpleif/elsesince there are only two branches.lib/bds/scripting/api_docs.ex—example_response_value/1: Extracted"nil"literal match into a separate function head. Replaced remainingcondwithcaseon a tuple of guard results.lib/bds/scripting/api_docs.ex—example_field_value/1: Replacedcondwithcaseon a tuple ofString.contains?/String.ends_with?results.- Added 2 source-level tests in
test/bds/csm021_cond_pattern_match_test.exsasserting nocond doblocks remain in either file.
CSM-022 — Silent Error Swallowing ✅ FIXED
- Fixed: 2026-05-11
- What was done:
execute_macro/4now returns{:error, reason}instead of{:ok, ""}when the underlying script execution fails.- Added
Logger.warning/1call that logs the project ID and error reason before returning the error tuple. - Updated test in
api_test.exsto assert{:error, _reason}instead of{:ok, ""}for failing macros.
CSM-023 — SRP Violations ✅ FIXED
- Fixed: 2026-05-11
- What was done:
lib/bds/templates.ex—do_update_template/2:- Extracted
resolve_next_slug/2— determines slug from attrs or keeps current. - Extracted
content_changed?/2— checks if content attr differs from effective content. - Extracted
resolve_next_status/2— pattern-matched function heads for status transition (published + content change → draft). - Extracted
build_update_attrs/5— assembles the changeset map from resolved values. - Extracted
commit_update_transaction/4— runs the Repo transaction with cascade logic. do_update_template/2is now a concise pipeline: resolve → build → commit → sync.
- Extracted
lib/bds/scripting/capabilities.ex—for_project/2:- Extracted 13 domain-specific builder functions:
app_capabilities/2,project_capabilities/1,meta_capabilities/1,post_capabilities/1,media_capabilities/1,script_capabilities/1,template_capabilities/1,tag_capabilities/1,task_capabilities/0,sync_capabilities/2,publish_capabilities/2,chat_capabilities/1,embedding_capabilities/1. for_project/2is now a 15-line dispatch map.
- Extracted 13 domain-specific builder functions:
- Added 5 tests in
test/bds/csm023_srp_violations_test.exs: source-level assertions for helper extraction in templates, delegation in do_update_template, builder function presence in capabilities, concise for_project body (≤20 lines), no inline capability definitions in for_project.
CSM-024 — Enum.reduce with acc.draft ++ [post] (O(n²)) ✅ FIXED
Enum.reduce with acc.draft ++ [post] (O(n²))- Fixed: 2026-05-08 (as part of CSM-005)
- What was done: Replaced
acc.draft ++ [post]withEnum.group_by/2ingroup_posts/1. See CSM-005 entry for details.
CSM-025 — Hardcoded Language Prefixes ✅ FIXED
- Fixed: 2026-05-11
- What was done:
- Replaced hardcoded
["de/", "fr/", "it/", "es/"]inlanguage_match?/2with dynamically derived prefixes fromplan.blog_languagesandplan.language. build_outputs/2now computesother_prefixesby rejecting the main language fromblog_languagesand appending"/"to each.pages_for_language/3andlanguage_match?/3now accept the computed prefixes as a parameter instead of using a hardcoded list.- Works correctly with arbitrary language codes (e.g.
pt-br,zh-cn,ja) that were not in the old hardcoded list. - Added 5 tests in
test/bds/csm025_hardcoded_languages_test.exs: source-level assertion for no hardcoded prefixes, main language exclusion, non-main language inclusion, arbitrary language codes, single-language blog.
- Replaced hardcoded
CSM-026 — TOCTOU Race Condition in Template File System ✅ FIXED
- Fixed: 2026-05-11
- What was done:
- Extracted
candidate_paths/2— validates the template path and returns all candidate file paths without checking existence. - Added
try_read/2— attemptsFile.readon each candidate path sequentially, returning{:ok, contents}on first success or{:error, :enoent}when all fail. No separate existence check. - Simplified
full_path/2to delegate tocandidate_paths/2(returns first candidate for backward compatibility with tests). - Rewrote
Liquex.FileSystemprotocol impl to usetry_read/2directly, eliminating the TOCTOU window betweenFile.regular?andFile.read. - Added 10 tests in
test/bds/csm026_toctou_file_system_test.exs: atomic read, missing template, multi-root fallthrough, first-root-wins priority, file-deleted-between-calls safety, protocol read, protocol raise on missing, and path validation (empty, absolute, traversal).
- Extracted
CSM-027 — if result == :ok Instead of Pattern Matching ✅ FIXED
if result == :ok Instead of Pattern Matching- Fixed: 2026-05-11
- What was done:
- Replaced
if result == :ok and ...inrewrite_template_file/2with acaseexpression using pattern matching and awhenguard. Persistence.atomic_writeresult is now matched directly::ok when file_path changedtriggers old file cleanup,other(including{:error, _}) is returned as-is.
- Replaced
CSM-028 — Broad rescue Swallowing Template Errors ✅ FIXED
rescue Swallowing Template Errors- Fixed: 2026-05-11
- What was done:
- Replaced
Liquex.FileSystem.read_template_file(which raises on missing templates) withBDS.Rendering.FileSystem.try_read(returns{:ok, source}/{:error, :enoent}). - Missing template now logs a warning and returns
""without raising. - Extracted
render_macro_source/4to separate file reading from template parsing/rendering. Liquex.render!rescue remains specific toLiquex.Error(no non-bang variant exists in Liquex).- No broad
rescue _error ->orrescue _ ->clauses remain infilters.ex. - Added 3 source-level tests in
test/bds/csm028_broad_rescue_test.exs: no broad rescue clauses, noread_template_fileusage,try_readis used instead.
- Replaced
CSM-029 — length/1 in Guards or Comparisons ✅ FIXED
length/1 in Guards or Comparisons- Fixed: 2026-05-11
- What was done:
lib/bds/generation/outputs.ex—category_route_paths,tag_route_paths,date_route_paths:- Bound
post_count = length(posts)at the start of eachEnum.flat_mapcallback, before passing topaginated_archive_paths. Eliminates inlinelength/1calls inside loop bodies.
- Bound
lib/bds/ui/sidebar.ex—build_post_section:- Bound
post_count = length(posts)before the map literal instead of computinglength(posts)inline in thecount:field.
- Bound
- Added 3 source-level tests in
test/bds/csm029_length_in_guards_test.exs: no inlinelength(posts)inpaginated_archive_pathscalls, no inlinelength()inbuild_post_sectionmap literal, no inlinelength(posts)in route path function callbacks.
CSM-030 — Unchecked File.mkdir_p / File.mkdir_p! ✅ FIXED
File.mkdir_p / File.mkdir_p!- Fixed: 2026-05-11
- What was done:
lib/bds/media/thumbnails.ex— Already fixed in CSM-009;File.mkdir_pis inside awithchain inwrite_all_thumbnails.lib/bds/media/sidecars.ex— Removed redundantFile.mkdir_pcalls fromwrite_sidecar/2andwrite_translation_sidecar/3(the underlyingPersistence.atomic_writealready handlesmkdir_p). Updated specs to return:ok | {:error, File.posix()}. Updated callers (sync_media_sidecar,sync_media_translation_sidecar) to propagate errors.lib/bds/media.ex— Replaced all:ok = write_sidecar(...)and:ok = write_translation_sidecar(...)match assertions withlog_sidecar_error/2(mirrors existinglog_thumbnail_error/2pattern). Sidecar write failures are logged as warnings but don't fail the DB operation.lib/bds/media/linking.ex— Samelog_sidecar_error/2pattern for post link/unlink sidecar writes.lib/bds/release_packaging.ex— ReplacedFile.mkdir_p!withFile.mkdir_pinreset_output/1(return value propagated throughwithchain inpackage/1). ReplacedFile.mkdir_p!withwith :ok <- File.mkdir_p(...)incopy_release/2. ReplacedFile.write!withFile.writeinwrite_manifest/1.- Added 6 tests in
test/bds/csm030_unchecked_mkdir_test.exs: source-level assertions for no uncheckedFile.mkdir_p, no bang variants, no:ok =match assertions on sidecar writes.
CSM-031 — try/rescue Instead of with and Error Tuples ✅ FIXED
try/rescue Instead of with and Error Tuples- Fixed: 2026-05-27
- What was done:
lib/bds/rendering/filters.ex— Extractedsafe_liquex_render/3private helper that isolates the unavoidableLiquex.render!rescue into a single function returning{:ok, binary} | {:error, String.t()}. Replaced inlinetry/rescueinrender_macro_source/4with awithchain using the helper.lib/bds/rendering/template_selection.ex— Same pattern: extractedsafe_liquex_render/3helper, replaced inlinetry/rescueinrender_template/3with awithchain.lib/bds/rendering/template_selection.ex—load_bundled_template_source/3: Replaced raisingLiquex.FileSystem.read_template_filewithFileSystem.try_read(returns{:ok, source} | {:error, :enoent}), eliminating the function-levelrescueblock entirely. Uses awithchain for control flow.lib/bds/desktop/shell_data.ex— Already fixed by CSM-010; notry/rescueblocks remain.- Added 7 tests in
test/bds/csm031_try_rescue_test.exs: source-level assertions that no inlinetry/rescuearoundLiquex.render!exists in either file, both files definesafe_liquex_renderhelpers,load_bundled_template_sourcehas no rescue block and usesFileSystem.try_read, andshell_data.exhas no try/rescue.
CSM-032 — Map.get with Default Instead of Pattern Matching ✅ FIXED
Map.get with Default Instead of Pattern Matching- Fixed: 2026-05-27
- What was done:
- Metadata struct access — Replaced
Map.get(metadata, :key)/Map.get(metadata, :key, default)with dot access (metadata.key) across 10 files that consume the well-defined metadata map fromMetadata.load_state():lib/bds/posts/translation_validation.ex—:main_language,:blog_languageslib/bds/posts/auto_translation.ex—:main_language,:blog_languageslib/bds/desktop/shell_commands.ex—:main_language,:blog_languageslib/bds/desktop/shell_live/settings_editor/project_settings.ex— all 10 metadata fieldslib/bds/desktop/shell_live/settings_editor/style_editor.ex—:pico_themelib/bds/desktop/shell_live/settings_editor/managed_categories.ex—:categories,:category_settingslib/bds/desktop/shell_live/settings_editor/publishing_settings.ex—:publishing_preferenceslib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex—:categorieslib/bds/desktop/shell_live/import_editor/analysis_state.ex—:default_authorlib/bds/import_execution.ex—:default_author
- Overlay context maps — Replaced
Map.get(delete_details, :key, default)andMap.get(merge, :key, default)with pattern matching inlib/bds/desktop/overlay.ex:open(:media, :confirm_delete, ...)— pattern matches%{title:, entity_name:, entity_type:, reference_list:}fromcontext.delete_detailsopen(:tags, :confirm_merge, ...)— pattern matches%{title:, message:}fromcontext.merge_detailsnormalize_ai_fields/1— pattern matches%{key:, label:, current_value:, suggested_value:, locked:}from each fieldlanguage_picker/2— extractsexisting_translations,language_names,language_flagsinto local bindings before the loop, eliminating nestedMap.get(Map.get(...))calls
- Overlay media/post maps — Replaced
Map.get(media, :field)with dot access into_insert_media_result,to_gallery_image,gallery_images, search filtering, andto_insert_link_result(media/post maps are built with known keys byoverlay_components.ex) - Generation pipeline — Replaced
Map.get(post, :field)withpost.fieldfor Post struct fields across:lib/bds/generation/data.ex—:author,:tags,:categories,:template_slug,:do_not_translate,:languageinbuild_published_translation_variantandresolve_posts_for_languagelib/bds/generation/outputs.ex—:language(all occurrences)lib/bds/generation/validation.ex—:file_pathlib/bds/generation/sitemap.ex—:do_not_translatelib/bds/preview.ex—:template_slug
- Kept
Map.getwhere appropriate: dynamic field access (Map.get(post, field)with variable keys), truly optional keys (:translation_source_slugon mixed struct/map lists), external data lookups (string-keyed JSON, form params), and hash-map lookups with defaults. - Updated
test/bds/desktop/overlay_test.exsto provide completedelete_detailsandmerge_detailsmaps matching the real contract fromoverlay_components.ex. - Added 18 tests in
test/bds/csm032_map_get_pattern_match_test.exs: source-level assertions that metadata consumers use dot access, overlay uses pattern matching for known structures, and generation pipeline uses dot access for Post struct fields.
- Metadata struct access — Replaced
CSM-033 — Enum.each with Side Effects That Should Be Batch Inserts ✅ FIXED
Enum.each with Side Effects That Should Be Batch Inserts- Fixed: 2026-05-27
- What was done:
lib/bds/search.ex— Already addressed by CSM-006;batch_insert_post_indexandbatch_insert_media_indexuse multi-row SQL INSERT with chunking.lib/bds/embeddings.ex— ReplacedEnum.each+ per-postsync_post_if_enabled(which did N individualRepo.get_byreads + N individualRepo.insert_or_updatewrites) in three bulk functions:rebuild_project/2— preloads all keys viapreload_keys_by_post_id/1, computes rows withcompute_key_data/3, batch-upserts withbatch_upsert_keys/1.repair_posts/2— same pattern withpreload_keys_by_post_id/2scoped to target post IDs.index_unindexed/1— same pattern, eliminating per-postRepo.get_bylookups.
- Added
preload_keys_by_post_id/1and/2— single-query key preload into a map by post_id. - Added
max_label_value/0— reads max label once instead of per-postnext_label()queries. - Added
compute_key_data/3— resolves body, hashes, embeds (if needed), returns:skipor{:upsert, row}. - Added
batch_upsert_keys/1— multi-rowINSERT INTO embedding_keys ... ON CONFLICT(label) DO UPDATEwith 199-row chunking (SQLite 999-param limit ÷ 5 columns). sync_post_if_enabled/2retained for single-postsync_post/1path (CRUD operations).- Added 11 tests in
test/bds/csm033_batch_inserts_test.exs: source-level assertions (no Enum.each+sync_post_if_enabled, batch_upsert_keys present, preload present, ON CONFLICT upsert, compute_key_data used), search.ex batch verification, and functional tests (index 5 posts, rebuild updates stale keys, repair targets subset, skip on matching hash).
CSM-034 — File.read! / File.write! Without Error Handling ✅ FIXED
File.read! / File.write! Without Error Handling- Fixed: 2026-05-27
- What was done:
lib/bds/preview_assets.ex—generated_outputs/0: ReplacedFile.read!withFile.readinsideEnum.flat_map, silently skipping files that become unreadable betweenPath.wildcardand read (TOCTOU race).lib/bds/templates.ex—upsert_template_from_file/3: ReplacedFile.read!and pattern-matchedFrontmatter.parse_documentwith awithchain returning{:ok, template} | {:error, reason}. ReplacedRepo.insert_or_update!withRepo.insert_or_updateto propagate changeset errors.lib/bds/templates.ex— Updated all three callers:rebuild_templates_from_fileslogs a warning and skips bad files,sync_template_from_fileandimport_orphan_template_filemap errors to{:error, :not_found}.lib/bds/release_packaging.ex— Already fixed by CSM-030 (File.write!→File.write).- Added 8 tests in
test/bds/csm034_file_read_bang_test.exs: source-level assertions for no bang file ops in all three files, functional tests for rebuild skipping bad templates, sync returning error on deleted file, import returning error on missing file, and preview_assets returning valid output tuples.
CSM-035 — Process Dictionary (Process.get/put) Usage
- File:
lib/bds/desktop/ui_locale.ex:32,49,65 - What:
UILocale.put/1sets process dictionary (Process.put(@key, locale)) for UI locale. Used inShellLive.render(Z. 550) andMenuBar. - Fix: This is isolated to the LiveView/MenuBar process so it's low-risk, but document the invariant explicitly: the process dict key
:bds_ui_localeis set before each render call.
CSM-036 — Missing @impl true on GenServer Callbacks
- File:
lib/bds/publishing.ex:46,61,71,75 - What: Only
init/1(Z. 36) and the firsthandle_call(Z. 41) have@impl true. The remaininghandle_callclauses at Z. 46, 61, 71, 75 lack it. - Fix: Add
@impl truebefore everyhandle_call,handle_cast,handle_info, andterminate.
Checklist for Agents Picking Up This File
- All critical items (CSM-001 to CSM-005) have been addressed or explicitly deferred with justification.
- CSM-001: Fixed. All
String.to_atomon dynamic data replaced withMapUtils.safe_atomize_key/keysorString.to_existing_atom. - CSM-002: Fixed. Search now pushes all filtering and pagination into SQL via Ecto queries and CTEs.
- CSM-004: Fixed.
attach_runnermoved tohandle_continue,terminate/2added for cleanup,restart: :temporaryset, JobStoredetach_runnerbug fixed.
- CSM-001: Fixed. All
- All high-severity items (CSM-006 to CSM-010) have been addressed.
- CSM-006: Fixed. Batch INSERT for reindexing, preloaded post records for rendering.
- CSM-007: Fixed. Decomposed into refresh_layout, refresh_sidebar, refresh_content, reload_shell.
- CSM-008: Fixed. Panel data pre-computed in event handlers, tab meta skips DB for complete entries.
- CSM-009: Fixed. All bang Image/File variants replaced with error-tuple handling,
ensure_thumbnailsreturns{:error, _}instead of crashing. - CSM-010: Fixed. Replaced rescue blocks with
Repo.ready?/0probe and{:ok, _}/{:error, :not_ready}tuples.
- CSM-001 fix covers ALL 6 affected files, not just
import_definitions.ex. - CSM-003 fix covers ALL
Repo.delete!call sites (posts, tags, scripts, media, projects, templates, translations). - CSM-007 decomposition is the prerequisite for fixing CSM-008 (render-path queries).
- Tests were written before implementation changes (Red → Green → Refactor).
- Full test suite passes:
mix test. - Dialyzer passes cleanly:
mix dialyzer(zero warnings). - Build succeeds:
mix compile. - No external JS/CSS referenced in preview/generated HTML (per AGENTS.md).
- All UI strings use gettext / i18n, no hardcoded text.
- API docs (
API.md) updated if any API changes were made. - Metadata diff tool and rebuild-from-database updated if metadata changed.
- Specs in
specs/folder updated and validated if behavior changed. - Unused code (including tests for removed features) has been deleted.
- This
CODESMELL.mdupdated: fixed items removed, new ones added.