Compare commits

...

18 Commits

Author SHA1 Message Date
f7a4a9512c fix: persist a2ui surfaces in the database for chats to re-hydrate on
opening an old chat, unless manually dismissed
2026-05-27 20:13:33 +02:00
141c2bfc89 removed fixed codesmell document 2026-05-27 19:20:43 +02:00
a5ac74db91 fix(style): add missing @impl true to all handle_call clauses in Publishing GenServer (CSM-036) 2026-05-27 19:19:50 +02:00
beca4d992f fix(docs): document UILocale process-dict invariant and add enforcement tests (CSM-035) 2026-05-27 19:16:42 +02:00
9e6d93a4b3 fix(safety): replace File.read! with File.read and error-tuple handling in preview_assets and templates (CSM-034) 2026-05-27 19:10:13 +02:00
e29dfb490a fix(perf): replace Enum.each + individual inserts with preloaded keys and batch upsert in embeddings (CSM-033) 2026-05-27 19:03:21 +02:00
f2b340ba86 fix(style): replace Map.get with dot access and pattern matching where keys are guaranteed (CSM-032) 2026-05-27 18:33:42 +02:00
d18e0ef7f2 fix(rendering): replace inline try/rescue with with-chains and safe_liquex_render helpers (CSM-031) 2026-05-27 18:12:23 +02:00
2d796cee83 updated specgaps for A3-1 2026-05-27 17:56:19 +02:00
b052d59376 fix(fs): handle File.mkdir_p errors and remove bang variants in sidecars and release packaging (CSM-030) 2026-05-11 20:25:06 +02:00
4a089b0856 fix(perf): bind length/1 to variables before loop bodies in route paths and sidebar (CSM-029) 2026-05-11 20:18:56 +02:00
2632649cdc fix(rendering): replace raising read_template_file with try_read in macro templates (CSM-028) 2026-05-11 20:09:49 +02:00
782511d523 fix(templates): replace equality check with pattern matching in rewrite_template_file (CSM-027) 2026-05-11 20:06:15 +02:00
1cb59d7a78 chore: update the spec gaps with decisionm points 2026-05-11 12:19:16 +02:00
9844f3555a chore: analyse specs against code 2026-05-11 11:56:34 +02:00
99dc1c2216 chore: remove redundant export-only tests, add test audit procedure
Deleted chat_editor_test.exs and import_editor_test.exs which only
checked function_exported?/Code.ensure_loaded? without exercising any
behavior — both components are already tested via LiveView rendering
in shell_live_test.exs and import_shell_live_test.exs respectively.

Added TESTAUDIT.md documenting the procedure for periodic test suite
audits to catch non-behavioral tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:35:24 +02:00
71fb99af16 chore: unit tests adaption to idea of gate test 2026-05-11 10:21:00 +02:00
0808b27057 chore: moved from delay-based tests to deterministic gated server tests
for chat
2026-05-11 10:20:46 +02:00
61 changed files with 1879 additions and 763 deletions

View File

@@ -1 +1,2 @@
- [Fix all test failures](feedback_fix_all_failures.md) — Never dismiss failures as pre-existing; fix everything - [Fix all test failures](feedback_fix_all_failures.md) — Never dismiss failures as pre-existing or flaky; investigate and fix
- [Debug targeted](feedback_targeted_debugging.md) — Analyze the code and fix; don't brute-force with repeated suite runs

View File

@@ -1,11 +1,13 @@
--- ---
name: Fix all test failures name: Fix all test failures including flaky ones
description: Never dismiss test failures as pre-existing — if tests fail after changes, fix them description: Never dismiss test failures as pre-existing or flaky — investigate root cause and stabilize
type: feedback type: feedback
--- ---
All test failures after changes must be fixed, even if they appear unrelated. The test suite was clean before, so any failure is the responsibility of the current change. All test failures must be fixed, even if they appear unrelated to current changes. The test suite was clean before, so any failure is my responsibility.
**Why:** The user confirmed the suite was green before. Dismissing failures as "pre-existing" is wrong and wastes time. Flaky tests are deeper problems waiting to surface. Running a test in isolation and seeing it pass is never enough — must find out why it was flaky in the full suite run and make it stable.
**How to apply:** After making changes, if any test fails, investigate and fix it before reporting the task as done. Never stash/skip/ignore failures. **Why:** Dismissing failures as "pre-existing" or "flaky" is wrong. Flaky tests indicate real issues (race conditions, test pollution, shared state) that will bite harder later.
**How to apply:** After making changes, if any test fails: investigate the root cause, fix it, and verify it passes reliably in the full suite. Never stash, never skip, never re-run and hope. Never dismiss ordering-dependent failures — find and fix the shared state or race condition.

View File

@@ -0,0 +1,11 @@
---
name: Debug targeted, don't brute-force
description: When investigating flaky tests, analyze the code and fix — don't brute-force with repeated full suite runs
type: feedback
---
If you already know which test is failing and why, fix it. Don't waste time running the full suite 20 times hoping to capture output.
**Why:** It's slow, wasteful, and avoids thinking. Analyze the code, understand the race, fix it.
**How to apply:** When a test fails, read the test, understand what it does, identify the root cause, and fix it. Only re-run to verify the fix, not to gather more data you already have.

View File

@@ -6,7 +6,9 @@
"Bash(mix dialyzer *)", "Bash(mix dialyzer *)",
"Bash(mix ecto.migrate)", "Bash(mix ecto.migrate)",
"Bash(git add *)", "Bash(git add *)",
"Bash(git push *)" "Bash(git push *)",
"Bash(git -C /Users/gb/Projects/bDS2 status)",
"Bash(git status *)"
] ]
} }
} }

View File

@@ -1,509 +0,0 @@
# 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
1. Pick a section.
2. Search the codebase for the file/line references.
3. Write a failing test that reproduces the issue.
4. Fix the code.
5. Run the full test suite and `mix dialyzer`.
6. 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/1` and `BDS.MapUtils.safe_atomize_keys/1` — uses `String.to_existing_atom/1` with rescue fallback to keep unknown keys as strings.
- Replaced all 6 affected `String.to_atom` call sites:
- `lib/bds/import_definitions.ex``atomize_keys/1``MapUtils.safe_atomize_keys/1`
- `lib/bds/import_execution.ex``normalize_report/1``MapUtils.safe_atomize_keys/1`
- `lib/bds/ai/catalog.ex``atomize_map_keys/1``MapUtils.safe_atomize_keys/1`, `parse_modality/1``MapUtils.safe_atomize_key/1`
- `lib/bds/ai/chat_tools.ex``metadata_attrs/2``MapUtils.safe_atomize_key/1`
- `lib/bds/desktop/automation.ex``atomize_map/1``MapUtils.safe_atomize_keys/1`
- Replaced lower-risk `String.to_atom` with `String.to_existing_atom/1`:
- `lib/bds/ui/menu_bar.ex` — sidebar view and singleton editor command IDs
- `lib/bds/ui/workbench.ex``normalize_type/1`
- `lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex``map_value/3`
- `lib/bds/release_packaging.ex``normalize_platform/1`
- Updated `test/bds/bounded_atoms_test.exs` to enforce no `String.to_atom` on dynamic data (replaced old `String.to_existing_atom` ban).
---
### ~~CSM-002 — Search Loads Entire Tables into Memory~~ ✅ FIXED
- **Fixed:** 2026-05-07
- **What was done:**
- Replaced `search_posts/3` and `search_media/3` with SQL-level filtering and pagination.
- Blank queries now use pure Ecto queries with `where` clauses for status, language, year/month, date range, tags, categories, and missing translations.
- Non-blank (FTS) queries use a CTE (`WITH fts_results AS (...)`) to preserve `bm25` ordering, joined with the posts/media table, with all filters applied in SQL.
- Tag and category overlap filtering uses `json_each` in `EXISTS` subqueries.
- Missing-translation filtering uses a `NOT EXISTS` correlated subquery.
- Count uses `select count` + `Repo.one` instead of `length(all_records)`.
- Pagination uses SQL `LIMIT`/`OFFSET` instead of `Enum.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.
---
### ~~CSM-003 — Non-Atomic Side Effects in Post CRUD~~ ✅ FIXED
- **Fixed:** 2026-05-07
- **What was done:**
- Replaced all 11 `Repo.delete!` call sites with `Repo.delete` + `{:error, _}` handling:
- `lib/bds/posts.ex``delete_post/1`
- `lib/bds/scripts.ex``delete_script/1`
- `lib/bds/media.ex``delete_media/1`, `delete_media_translation/3`
- `lib/bds/templates.ex``delete_template/2`, `remove_orphan_templates/2`
- `lib/bds/tags.ex``delete_tag/1`, `merge_tags/2`
- `lib/bds/projects.ex``delete_project/1`
- `lib/bds/posts/translations.ex``delete_post_translation/1`
- `lib/bds/posts/translation_validation.ex``fix_invalid_database_row/1`
- Reordered `delete_post/1` to perform `Repo.delete` first, 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`, and `delete_post_translation/1`.
- `delete_media/1` now wraps translation + media deletes in a `Repo.transaction` for atomicity.
- Tags and projects already used `Repo.transaction`; replaced inner `Repo.delete!` with `Repo.delete` + `Repo.rollback` on error.
- Added tests for delete atomicity and not-found handling.
---
### ~~CSM-004 — Blocking `init/1` + Missing `terminate/2` in Job Runner~~ ✅ FIXED
- **Fixed:** 2026-05-08
- **What was done:**
- Moved `JobStore.attach_runner/2` from `init/1` to a new `handle_continue(:attach_and_start)` callback, so supervisor startup is no longer blocked by the synchronous call.
- Added `terminate/2` callback that calls `JobStore.detach_runner/2` (with `try/catch` for 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_runner` calls from `handle_call(:cancel)`, task result handler, and `:DOWN` handler — `terminate/2` now handles all detach cleanup.
- Changed `restart: :temporary` since job runners are one-shot processes that should not auto-restart on failure.
- Added `@impl true` to all `handle_info` clauses.
- Fixed pre-existing bug in `JobStore.detach_runner` handler where `update_in/2` macro result was incorrectly double-wrapped, corrupting state.
- Added test: start a runner, kill it externally (not via cancel), assert `JobStore` no longer contains the dead PID.
---
### ~~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/1` and `list_media/1` that loaded all records into memory.
- Replaced `apply_post_filters/1` and `apply_media_filters/1` (Elixir-side filtering) with SQL `WHERE` clauses using Ecto dynamic queries and SQLite `json_each` fragments.
- 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`) use `Ecto.Adapters.SQL.query!` with `json_each` cross-joins, `GROUP BY`, and `DISTINCT`.
- Pagination uses SQL `LIMIT` instead of `Enum.take`.
- `tag_count/1` replaces `list_tags/1` + `length/1` with `Repo.one(select: count(tag.id))`.
- Fixed `group_posts/1` O(n²) `acc.draft ++ [post]` pattern — now uses `Enum.group_by/2` (also fixes CSM-024).
- **Tags** (`lib/bds/tags.ex`):
- `posts_with_tag/2` now uses `EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)` instead of loading all posts.
- `posts_with_any_tag/2` now uses `json_each` cross-join with a JSON parameter for the tag name list.
- `post_tag_names/1` now selects only the `tags` column instead of loading full post records.
- **Dashboard** (`lib/bds/ui/dashboard.ex`):
- `post_stats` uses `GROUP BY post.status, SELECT {status, count(id)}` — no longer loads all posts.
- `media_stats` uses `SELECT count(id), coalesce(sum(size), 0)` and a separate image count query with `LIKE 'image/%'`.
- `tag_cloud_items` and `category_counts` use raw SQL with `json_each` cross-joins and `GROUP BY`.
- `timeline_entries` uses SQL `strftime` + `GROUP BY` for year/month aggregation.
- `recent_posts` uses SQL `ORDER BY updated_at DESC LIMIT 5`.
- **Posts** (`lib/bds/posts.ex`):
- `dashboard_stats/1` uses `GROUP BY post.status, SELECT {status, count(id)}` instead of loading all statuses.
- **Capabilities** (`lib/bds/scripting/capabilities/`):
- `tag_post_ids/2` uses `json_each` fragment + `SELECT post.id` instead of loading all posts.
- `names_with_counts/2` uses raw SQL with `json_each` + `GROUP BY` instead of loading all posts.
- `posts_by_status/2` filters at SQL level instead of loading all posts and filtering in Elixir.
- Added 20 tests in `test/bds/csm005_sql_filtering_test.exs` covering dashboard stats, tag cloud, sidebar page/post separation, tag/search/year-month filters, available aggregates, and media filtering.
---
## 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 in `reindex_posts/2` and `reindex_media/2` with 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 in `preload_post_translations` and `preload_media_translations` by replacing with `Enum.group_by`.
- **Rendering — preloaded post records:** `PostRendering.post_assigns/2` now accepts an optional `:_post_record` key in assigns, skipping the `Repo.get(Post, id)` re-query when the record is already available.
- **Generation outputs pass records:** `build_page_outputs` and `build_post_outputs` in `outputs.ex` now 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.
---
### ~~CSM-007 — Monolithic State Rebuild ("God Function")~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- Decomposed `reload_shell/2` into 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 calls `refresh_layout`.
- `refresh_content/2` — Queries projects, dashboard, git badge, and sidebar data, then calls `refresh_layout`.
- `reload_shell/2` — Full refresh: tab_meta sync, task status, static data, then calls `refresh_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.
- Updated Bridges callbacks to use focused refreshers: `refresh_layout` for toggle events and close_tab, `refresh_sidebar` for view switches and tab meta updates, `refresh_content` for entity/tag changes.
- Split `@local_menu_actions` into `@layout_menu_actions` and `@sidebar_menu_actions` for correct dispatch.
- Fixed `false || true` bug in `refresh_layout` where `offline_mode = assigns[:offline_mode] || true` incorrectly defaulted `false` to `true`.
- Added 7 tests in `test/bds/csm007_reload_shell_test.exs` using 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).
---
### ~~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_links` and `render_git_log` no 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/1` and `git_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/1` that fetches panel data (post links or git log) based on the active panel tab and stores results in assigns.
- `refresh_layout/2` detects when `current_tab` or `panel.active_tab` changed and calls `refresh_panel_data/1` only when stale — no DB queries on re-renders.
- Initialized `panel_post_links` and `panel_git_entries` assigns in mount.
- **Tab meta** (`lib/bds/desktop/shell_live/tab_helpers.ex`):
- `sync_tab_meta` now skips `derived_tab_meta` DB queries when existing meta already has both title and subtitle populated (`meta_complete?/1` guard).
- 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).
---
### ~~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.autorotate` with `{:ok, {image, rotation_info}}` destructuring.
- `Image.thumbnail!``Image.thumbnail` returning `{:ok, image}` / `{:error, reason}`.
- `Image.embed!``Image.embed` with `with` chain.
- `Image.flatten!``Image.flatten` with `with` chain.
- `Image.write!``Image.write` with `{:ok, _}` / `{:error, reason}` handling.
- `File.mkdir_p` result is now checked — errors halt thumbnail generation with `{:error, reason}`.
- `write_all_thumbnails` uses `Enum.reduce_while` to stop on first error and return `{:error, reason}`.
- `ensure_thumbnails` spec updated to `:ok | {:error, term()}`.
- `regenerate_thumbnails` propagates `{:error, reason}` from `ensure_thumbnails`.
- `regenerate_missing_thumbnails` replaced `try/rescue` with `case` on the new error tuples.
- Call sites in `BDS.Media` (`import_media`, `replace_media_binary`) use `log_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.
---
### ~~CSM-010 — `rescue` for Control Flow in Data Layer~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- Added `BDS.Repo.ready?/0` — a lightweight probe that queries `sqlite_master` (parameterized) to check if core tables exist, without raising exceptions.
- Replaced all 4 `rescue` blocks in `ShellData` (`project_snapshot/0`, `dashboard/1`, `sidebar_view/3`, `git_badge_count/2`) with upfront `Repo.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/2` and `ShellLive.refresh_sidebar/2` to pattern-match the new tuples and fall back to empty defaults only on `{:error, :not_ready}`.
- Made `default_project_snapshot/0` public 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.
---
## Medium Severity
### ~~CSM-011 — No URL State / Deep Linking~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- `mount/3` now 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 a `url-state` event to the client with the serialized URL.
- Added JS handler in the `AppShell` hook that calls `history.replaceState` to update the browser URL without triggering navigation.
- URL encoding: `?view=<sidebar_view>` (omitted when `posts`, the default) and `?tab=<type>:<id>` (omitted when no tab is active). Invalid or unknown params are silently ignored.
- Used `push_event` + `history.replaceState` instead of `push_patch`/`handle_params` to maintain compatibility with existing `live_isolated` tests.
- 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_view` pushes url-state, `select_view` posts pushes clean URL, `select_tab` pushes url-state, `close_tab` removes tab from URL, `open_sidebar_item` pushes url-state.
---
### ~~CSM-012 — Desktop File Dialog Blocks Event Handler~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- Replaced synchronous `FilePicker.choose_file/1` call in `SidebarCreate.create/4` for the "media" kind with `Task.async`, storing the task ref in a new `file_picker_task` socket assign.
- Added `handle_file_picker_result/2` private function in `ShellLive` with clauses for `{:ok, _media}`, `:cancel`, `{:error, %{message: _}}`, and `{:error, reason}`.
- Extended the existing `handle_info({ref, result}, socket)` and `handle_info({:DOWN, ref, ...}, socket)` handlers to match on `file_picker_task` ref.
- Added `BDS_DESKTOP_AUTOMATION` guard to `FilePicker.choose_file/1` — returns `:cancel` immediately in automation/test mode, preventing native dialogs from opening during tests.
- Initialized `file_picker_task: nil` assign 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.
---
### ~~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!` with `Liquex.parse` (non-bang) and `case` match on `{:ok, ast}` / `{:error, reason, line}`.
- Wrapped `Liquex.render!` in `try/rescue` catching `Liquex.Error` specifically (no non-bang `render` exists in Liquex).
- Removed broad `rescue _error -> ""` — errors now log via `Logger.warning` with template path and reason before returning `""`.
- **`lib/bds/rendering/template_selection.ex`** — `render_template`:
- `Liquex.parse` was already non-bang; added `else` clause to normalize the 3-tuple `{:error, reason, line}` into `{:error, "reason at line N"}`.
- Wrapped `Liquex.render!` in `try/rescue` catching `Liquex.Error` specifically, returning `{:error, message}`.
- Removed broad `rescue error -> {:error, error}`.
- **`lib/bds/rendering/post_rendering.ex`** — `post_data_json_value`:
- Replaced `Jason.encode!` with `Jason.encode` and `case` match — returns `"{}"` on encode failure instead of crashing.
- Added 5 tests in `test/bds/csm013_bang_rendering_test.exs`: template syntax error returns `{:error, _}` from `render_template`, broken template in `render_post_page` returns `{: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
- **Fixed:** 2026-05-09
- **What was done:**
- **`lib/bds/generation/outputs.ex`** — `build_category_outputs`:
- Bound `total_pages = length(paginated_posts)` and `total_items = length(posts)` before the nested loop. Previously called `length/1` 4 times per page × language iteration.
- **`lib/bds/generation/outputs.ex`** — `build_root_outputs`:
- Bound `total_items = length(posts)` before the loop, reused by `pagination_for_page`. Previously called `length(posts)` on every page iteration.
- **`lib/bds/generation/outputs.ex`** — `build_paginated_archive_outputs`:
- Bound `total_items = length(posts)` before the loop. Previously called `length(posts)` inside the nested page × language loop.
- **`lib/bds/rendering/list_archive.ex`** — `build_day_blocks`:
- Bound `last_index = length(grouped_blocks) - 1` before the `Enum.map`. Previously called `length(grouped_blocks)` on every iteration.
- **`lib/bds/publishing.ex`** — `run_upload`:
- Bound `target_count = max(length(targets), 1)` before the `Enum.reduce_while`. Negligible impact (3 targets) but fixed for consistency.
- `lib/bds/ui/sidebar.ex` `acc.draft ++ [post]` was already fixed by CSM-005 (replaced with `Enum.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.exs` with 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)`.
- Added 12 tests in `test/bds/csm015_missing_indexes_test.exs` verifying via `EXPLAIN QUERY PLAN` that all indexed columns use index lookups.
---
### ~~CSM-016 — String Concatenation for Paths~~ ✅ FIXED
- **Fixed:** 2026-05-09
- **What was done:**
- **`lib/bds/rendering/file_system.ex`** — Extracted `ensure_liquid_ext/1` using `Path.extname/1` to check before appending `.liquid`, preventing double-extension bugs (e.g. `"header.liquid.liquid"`).
- **`lib/bds/rendering/metadata.ex`** — `menu_item_href` for `:page` kind now applies `URI.encode/1` to the slug (matching the existing `:category_archive` pattern). `href_for_language/1` now uses `String.trim_trailing(prefix, "/")` before appending `/` to prevent double trailing slashes.
- **`lib/bds/rendering/metadata.ex`** — Added `menu_items_from_raw/1` public function for testability.
- **`lib/bds/rendering/links_and_languages.ex`** — `post_path/2` for `nil` language now uses `Path.join(["/", year, month, day, slug]) <> "/"` instead of building with `index.html` then stripping it. Language-prefix clause uses `String.trim_trailing/2` to prevent double slashes. `canonical_media_path_by_source_path/1` uses `Path.join("/", media.file_path)` instead of `"/" <> file_path`.
- **`lib/bds/publishing.ex`** — `ensure_trailing_slash/1` made 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
- **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`, and `parent/1` (escape hatch for chat-specific messages).
- Replaced all 25+ `send(self(), ...)` calls across 11 editor components with `Notify.*` 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 via `Notify.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_info` clauses 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 no `send(self(), ...)` in any editor file, 1 aggregate test verifying all shell_live `send(self(), ...)` calls are in `notify.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.
---
## Low Severity / Code Quality
### ~~CSM-018 — `@moduledoc false` Epidemic~~ ✅ FIXED
- **Fixed:** 2026-05-10
- **What was done:**
- Replaced `@moduledoc false` with descriptive `@moduledoc` strings in all 12 listed public modules:
- `lib/bds/i18n.ex` — language support, locale resolution, flag emoji mapping
- `lib/bds/map_utils.ex` — mixed-key map utilities and safe atom conversion
- `lib/bds/bounded_atoms.ex` — allow-list-based dynamic atom conversion
- `lib/bds/document_fields.ex` — frontmatter field access with key aliases
- `lib/bds/import_definitions.ex` — CRUD for WXR import configurations
- `lib/bds/publishing.ex` — GenServer for site upload job coordination
- `lib/bds/settings.ex` — global key-value settings persistence
- `lib/bds/templates.ex` — Liquid template lifecycle management
- `lib/bds/ai.ex` — AI endpoint config, secrets, and inference dispatch
- `lib/bds/mcp.ex` — MCP server facade for external AI agents
- `lib/bds/scripting/capabilities.ex` — Lua scripting capability map builder
- `lib/bds/scripting/api_docs.ex` — machine-readable Lua API documentation
---
### ~~CSM-019 — Missing `@spec` on Public Functions~~ ✅ FIXED
- **Fixed:** 2026-05-10
- **What was done:**
- Added `@spec` annotations to every public function across 25 files in rendering, generation, publishing, UI, and scripting modules.
- Added `@type t :: %__MODULE__{}` to `workbench.ex` and `file_system.ex` to 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.
---
### ~~CSM-020 — Deeply Nested `case` Instead of `with`~~ ✅ FIXED
- **Fixed:** 2026-05-10
- **What was done:**
- **`lib/bds/import_definitions.ex`** — `delete_definition/1`: Replaced nested `case` piped into another `case` with a flat `with` chain: `Repo.get``Repo.delete``{:ok, :deleted}`, with `else` clauses for `nil` and `{:error, _}`.
- **`lib/bds/publishing.ex`** — `handle_call({:update_job, ...})`: Replaced `case Repo.get` with `with %PublishJob{} = job <- Repo.get(...)`. Also replaced `Repo.update!()` with `Repo.update()` to avoid crashes on changeset errors.
- **`lib/bds/templates.ex`** — `update_template/2`: Replaced outer `case Repo.get` with `with` + extracted `do_update_template/2` private function. Collapsed three levels of nested `case` (Repo.get → transaction_result → sync_side_effects) into a single flat `with` chain.
- 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 use `with` instead of nested `case`.
---
### ~~CSM-021 — `cond` Where Pattern Matching Suffices~~ ✅ FIXED
- **Fixed:** 2026-05-11
- **What was done:**
- **`lib/bds/ai.ex`** — `get_endpoint/2`: Replaced `cond do is_nil(x) and ...; true -> ... end` with a simple `if/else` since 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 remaining `cond` with `case` on a tuple of guard results.
- **`lib/bds/scripting/api_docs.ex`** — `example_field_value/1`: Replaced `cond` with `case` on a tuple of `String.contains?`/`String.ends_with?` results.
- Added 2 source-level tests in `test/bds/csm021_cond_pattern_match_test.exs` asserting no `cond do` blocks remain in either file.
---
### ~~CSM-022 — Silent Error Swallowing~~ ✅ FIXED
- **Fixed:** 2026-05-11
- **What was done:**
- `execute_macro/4` now returns `{:error, reason}` instead of `{:ok, ""}` when the underlying script execution fails.
- Added `Logger.warning/1` call that logs the project ID and error reason before returning the error tuple.
- Updated test in `api_test.exs` to 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/2` is now a concise pipeline: resolve → build → commit → sync.
- **`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/2` is now a 15-line dispatch map.
- 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
- **Fixed:** 2026-05-08 (as part of CSM-005)
- **What was done:** Replaced `acc.draft ++ [post]` with `Enum.group_by/2` in `group_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/"]` in `language_match?/2` with dynamically derived prefixes from `plan.blog_languages` and `plan.language`.
- `build_outputs/2` now computes `other_prefixes` by rejecting the main language from `blog_languages` and appending `"/"` to each.
- `pages_for_language/3` and `language_match?/3` now 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.
---
### ~~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` — attempts `File.read` on each candidate path sequentially, returning `{:ok, contents}` on first success or `{:error, :enoent}` when all fail. No separate existence check.
- Simplified `full_path/2` to delegate to `candidate_paths/2` (returns first candidate for backward compatibility with tests).
- Rewrote `Liquex.FileSystem` protocol impl to use `try_read/2` directly, eliminating the TOCTOU window between `File.regular?` and `File.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).
---
### CSM-027 — `if result == :ok` Instead of Pattern Matching
- **File:** `lib/bds/templates.ex:445`
- **Fix:** Use `case result do :ok -> ...; _ -> ... end`.
---
### CSM-028 — Broad `rescue` Swallowing Template Errors
- **File:** `lib/bds/rendering/filters.ex:130-132`
- **What:** `rescue _error -> ""` swallows all macro template failures silently.
- **Fix:** Rescue only specific exceptions, or return `{:error, exception}` and let the caller decide.
---
### CSM-029 — `length/1` in Guards or Comparisons
- **Files:** `lib/bds/generation/outputs.ex`, `lib/bds/ui/sidebar.ex`
- **What:** `length(list)` is O(n). Using it inside a loop makes the whole loop O(n²).
- **Fix:** Bind the length before the loop.
---
### CSM-030 — Unchecked `File.mkdir_p` / `File.mkdir_p!`
- **Files:** `lib/bds/media/thumbnails.ex:133`, `lib/bds/media/sidecars.ex:24,56`, `lib/bds/release_packaging.ex:80,85`
- **What:** Result of `File.mkdir_p/1` is discarded. `File.mkdir_p!/1` in `release_packaging` can crash on permission errors.
- **Fix:** Pattern-match `File.mkdir_p/1` or use `with`; replace bang variants with non-bang and handle errors.
---
### CSM-031 — `try/rescue` Instead of `with` and Error Tuples
- **Files:** `lib/bds/rendering/filters.ex`, `lib/bds/rendering/template_selection.ex`, `lib/bds/desktop/shell_data.ex`
- **Fix:** Replace `try/rescue` around expected failures with non-bang functions and `with` chains.
---
### CSM-032 — `Map.get` with Default Instead of Pattern Matching
- **Files:** Widespread
- **What:** `Map.get(map, key, default)` when the key is expected to exist.
- **Fix:** Use pattern matching (`%{key: value} = map`) or `Map.fetch!/2` if the key is required.
---
### CSM-033 — `Enum.each` with Side Effects That Should Be Batch Inserts
- **Files:** `lib/bds/search.ex:174-177`, `lib/bds/embeddings.ex`
- **What:** `Enum.each` used for inserting records. The side-effect pattern is fine, but `Enum.map` + `Repo.insert_all` would be much faster for bulk inserts.
- **Fix:** Use `Repo.insert_all` for batch inserts instead of `Enum.each` + `Repo.insert`.
---
### CSM-034 — `File.read!` / `File.write!` Without Error Handling
- **Files:** `lib/bds/preview_assets.ex:32`, `lib/bds/release_packaging.ex:105`, `lib/bds/templates.ex:488-489`
- **Fix:** Use `File.read/1`, `File.write/2`, and handle `{:error, reason}`.
---
### CSM-035 — Process Dictionary (`Process.get/put`) Usage
- **File:** `lib/bds/desktop/ui_locale.ex:32,49,65`
- **What:** `UILocale.put/1` sets process dictionary (`Process.put(@key, locale)`) for UI locale. Used in `ShellLive.render` (Z. 550) and `MenuBar`.
- **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_locale` is 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 first `handle_call` (Z. 41) have `@impl true`. The remaining `handle_call` clauses at Z. 46, 61, 71, 75 lack it.
- **Fix:** Add `@impl true` before every `handle_call`, `handle_cast`, `handle_info`, and `terminate`.
---
## Checklist for Agents Picking Up This File
- [x] All critical items (CSM-001 to CSM-005) have been addressed or explicitly deferred with justification.
- CSM-001: Fixed. All `String.to_atom` on dynamic data replaced with `MapUtils.safe_atomize_key/keys` or `String.to_existing_atom`.
- CSM-002: Fixed. Search now pushes all filtering and pagination into SQL via Ecto queries and CTEs.
- CSM-004: Fixed. `attach_runner` moved to `handle_continue`, `terminate/2` added for cleanup, `restart: :temporary` set, JobStore `detach_runner` bug fixed.
- [x] 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_thumbnails` returns `{:error, _}` instead of crashing.
- CSM-010: Fixed. Replaced rescue blocks with `Repo.ready?/0` probe and `{:ok, _}`/`{:error, :not_ready}` tuples.
- [x] CSM-001 fix covers ALL 6 affected files, not just `import_definitions.ex`.
- [x] CSM-003 fix covers ALL `Repo.delete!` call sites (posts, tags, scripts, media, projects, templates, translations).
- [x] CSM-007 decomposition is the prerequisite for fixing CSM-008 (render-path queries).
- [x] Tests were written **before** implementation changes (Red → Green → Refactor).
- [x] Full test suite passes: `mix test`.
- [x] Dialyzer passes cleanly: `mix dialyzer` (zero warnings).
- [x] Build succeeds: `mix compile`.
- [x] No external JS/CSS referenced in preview/generated HTML (per AGENTS.md).
- [x] All UI strings use gettext / i18n, no hardcoded text.
- [x] API docs (`API.md`) updated if any API changes were made.
- [x] Metadata diff tool and rebuild-from-database updated if metadata changed.
- [x] Specs in `specs/` folder updated and validated if behavior changed.
- [x] Unused code (including tests for removed features) has been deleted.
- [x] This `CODESMELL.md` updated: fixed items removed, new ones added.

191
SPECAUDIT.md Normal file
View File

@@ -0,0 +1,191 @@
# Spec Audit Process
This document describes the repeatable process for auditing the Allium specifications against the bDS2 codebase and test suite. Run it whenever specs or code change materially.
## Overview
The audit produces three categories of findings:
1. **Spec-claims-not-in-code** — spec describes behavior the code does not implement
2. **Code-not-in-spec** — code implements behavior the spec does not describe
3. **Spec-claims-not-in-tests** — spec invariants/rules/behaviors lack test coverage
## Step 1: Map the Territory
```bash
# List all spec files
ls specs/*.allium
# List all source modules
ls lib/bds/ lib/bds/**/
# List all test files
ls test/bds/ test/bds/**/
```
Record the mapping between specs and code/test files. Use `specs/bds.allium` as the index — it lists every `use` directive with its domain label.
## Step 2: Extract Spec Claims
For each `.allium` file, extract:
| Claim Type | Pattern | Example |
|---|---|---|
| **invariant** | `invariant Name:` or lines describing always-true properties | `UniqueSlugPerProject: slugs unique within project` |
| **rule** | `rule Name { requires: ... ensures: ... }` | `CreatePost: creates with slug, status=draft` |
| **guarantee** | `guarantee Name:` | `SandboxedExecution: no filesystem/process loading` |
| **config** | `config { key = value }` | `macro_timeout = 10.seconds` |
| **behavior** | Explicit claims in comments or entity descriptions | `"HomeAlwaysPresent: menu always has Home entry"` |
Record the spec file name, claim name, claim type, and line number for each.
## Step 3: Compare Spec Claims Against Code
For each claim, find the corresponding code and verify:
### 3a. Entity/field existence
- Does the Ecto schema have the fields the spec declares?
- Are relationships (has_many, belongs_to) present?
- Are enum/status values complete?
```bash
# Check schema fields
grep -n "field :" lib/bds/posts/post.ex
grep -n "has_many\|belongs_to" lib/bds/posts/post.ex
```
### 3b. Rule implementation
- Does the code enforce the `requires` preconditions?
- Does the code produce the `ensures` postconditions?
- Are side-effects (FTS, embeddings, file writes) triggered?
```bash
# Check function implementation
grep -n "def create_post" lib/bds/posts.ex
grep -n "def publish_post" lib/bds/posts.ex
```
### 3c. Invariant enforcement
- Are constraints enforced at the schema level (unique_index, check_constraint)?
- Are constraints enforced in changeset validations?
- Are constraints enforced in business logic?
```bash
# Check database constraints
grep -n "unique_index\|check_constraint" priv/repo/migrations/*.ex
grep -n "unique_constraint\|validate_" lib/bds/posts/post.ex
```
### 3d. File format compliance
- Does the serialization format match the spec's frontmatter values?
- Are conditional fields omitted when falsy?
- Are required fields always present?
```bash
# Check serialization
grep -n "serialize\|write_file\|Frontmatter" lib/bds/frontmatter.ex lib/bds/posts/file_sync.ex
```
## Step 4: Compare Code Against Spec Claims
Search for code that implements behavior NOT described in any spec:
### 4a. Public API functions not in any spec rule
```bash
# List public functions in a module
grep -n "def " lib/bds/posts.ex | grep -v "defp"
```
### 4b. Schema fields not in any spec entity
```bash
# List all fields
grep -n "field :" lib/bds/posts/post.ex
```
### 4c. Side effects not in engine_side_effects.allium
```bash
# Check what happens after CRUD operations
grep -n "sync_post\|sync_media\|Search\.\|Embeddings\.\|AutoTranslation" lib/bds/posts.ex lib/bds/media.ex
```
### 4d. UI features not in any editor spec
```bash
# Check HEEx templates for UI elements
grep -n "phx-click\|data-phx-" lib/bds/desktop/post_editor_html/post_editor.html.heex
```
## Step 5: Compare Spec Claims Against Tests
For each invariant, rule, and guarantee, search for a test that verifies it:
### 5a. Direct test search
```bash
# Search test names and bodies
grep -rn "test \"" test/bds/posts_test.exs | head -30
grep -rn "test \"" test/bds/media_test.exs | head -30
```
### 5b. Invariant coverage check
For each invariant, determine:
- **YES**: Test explicitly verifies the invariant (creates violation, expects rejection)
- **PARTIAL**: Test verifies the happy path but not violation scenarios
- **NO**: No test exists
### 5c. Rule coverage check
For each rule, determine:
- **YES**: Test exercises `requires` precondition and `ensures` postcondition
- **PARTIAL**: Test exercises the happy path but not preconditions or all postconditions
- **NO**: No test exists
### 5d. Side-effect chain coverage
For each side-effect rule in `engine_side_effects.allium`, check whether a test verifies ALL `ensures` clauses fire together (not just individually).
## Step 6: Classify Findings
Each gap falls into one of these categories with a recommended action:
| Category | Direction | Action |
|---|---|---|
| **Spec correct, code wrong** | Spec → Code | Fix the code |
| **Code correct, spec drifted** | Code → Spec | Update the spec |
| **Code behavior, no spec** | Code → Spec | Distill into spec |
| **Spec claim, no test** | Spec → Test | Write test |
| **Internal spec inconsistency** | Spec → Spec | Align specs |
| **Decision needed** | Both | Resolve with stakeholder |
## Step 7: Produce SPECGAPS.md
Consolidate all findings into `SPECGAPS.md` with:
- Gap ID for tracking
- Clear description of the gap
- Which spec file and line
- Which code file and line
- Recommended path (fix code / update spec / write test / decide)
- Priority (HIGH/MEDIUM/LOW)
## Step 8: Validate
After making changes:
```bash
# Run full test suite
mix test
# Run dialyzer
mix dialyzer
# Validate allium specs (if tool available)
# Use the allium CLI to validate spec files
```
## Re-running the Audit
1. Start from Step 2 — re-extract claims from updated specs
2. Run Steps 3-5 against current code and tests
3. Compare against previous SPECGAPS.md to identify resolved and new gaps
4. Update SPECGAPS.md
The audit should be re-run after:
- Adding new spec files or significant spec changes
- Adding new features or refactoring code
- Adding new test files
- Before any release milestone

192
SPECGAPS.md Normal file
View File

@@ -0,0 +1,192 @@
# Spec Gaps — Allium Specs vs Code vs Tests
Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update spec | **ST** = write test | **SD** = decide | **SI** = fix internal spec inconsistency
---
## A. Spec Claims Not Fulfilled by Code
### A1. Code Must Change (spec is normative)
| ID | Gap | Spec | Code | Path |
|---|---|---|---|---|
| A1-1 | No `archived→draft` or `archived→published` transition | post.allium:121-122 | No code path to unarchive | Fix code: implement unarchive transitions |
| A1-2 | `DeletePost` must delete translations + translation files | post.allium:209-212 | `delete_post/1` skips translation cleanup | Fix code: delete PostTranslation rows + files |
| A1-3 | Publish must delete old file when path changes | engine_side_effects.allium:73-74 | `publish_post` does not delete old file | Fix code: add old file deletion on path change |
| A1-4 | `doNotTranslate: false` written to frontmatter despite "only when true" | frontmatter.allium:398 | `lib/bds/frontmatter.ex:38-39` writes false | Fix code: omit `doNotTranslate` when false |
| A1-5 | Auto-save after 3000ms idle | editor_post.allium:183-188 | No auto-save timer | Fix code: implement auto-save on idle + unmount + tab switch |
| A1-6 | On-demand rendering in preview server | preview.allium:53-93 | Server serves static pre-generated files | Fix code: implement on-demand template rendering for post/archive/language routes |
| A1-7 | Template lookup must use all 4 levels (post→tag→category→default) | template_context.allium:267-277 | Only levels 1 and 4 implemented; tag/category fallback unused | Fix code: implement levels 2-3 in template_selection.ex |
| A1-8 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Fix code: add validation step before publish |
| A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Fix code: implement preset color palette popover |
| A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create |
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown |
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
### A2. Spec Should Update (code is normative)
| ID | Gap | Spec | Code | Path |
|---|---|---|---|---|
| A2-1 | WYSIWYG/visual editor mode (3 modes) | editor_post.allium:159-164 | Only markdown+preview; visual normalizes to markdown | Drop from spec or mark future |
| A2-2 | Template/Script are global entities | template.allium, script.allium | Both have `project_id`, per-project uniqueness | Update spec to per-project scoping |
| A2-3 | TagsFile uses `{tags: [...]}` wrapper | frontmatter.allium:255-273 | Code writes bare array `[...]` | Update spec |
| A2-4 | Sidecar is "YAML-like, not gray-matter" | frontmatter.allium:174 | Code wraps with `---` delimiters | Update spec to gray-matter style |
| A2-5 | Translation frontmatter omits status/timestamps | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | Update spec to match written fields |
| A2-6 | Search index has single `stemmed_content` | search.allium:40-54 | FTS5 per-field stemmed columns | Update spec to per-field model |
| A2-7 | Tag archives are single-page | generation.allium:142-147 | Code paginates | Update spec |
| A2-8 | Date archives year+month only | generation.allium:151-159 | Code also generates day-level | Update spec |
| A2-9 | Menu is DB entity | menu.allium:20-26 | Purely file-based OPML, no DB table | Update spec to file-only model |
| A2-10 | Panel tabs: problems, terminal | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | Update spec |
| A2-11 | Git sidebar: commit input, history, push/pull | sidebar_views.allium | Only "Working tree" item | Mark as partial/TODO in spec |
| A2-12 | Slug timestamp fallback after 999 | post.allium:21 | Unbounded numeric suffix | Update spec or fix code |
| A2-13 | Thumbnail generation is async | engine_side_effects.allium:117 | Synchronous | Update spec or fix code |
| A2-14 | AiModelModality: :video vs :file/:tool | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | Update spec to :file/:tool |
| A2-15 | JSON key convention: snake_case vs camelCase | frontmatter.allium values | Code uses camelCase for all metadata JSON | Update spec to camelCase |
| A2-16 | Snowball stemmer language list | search.allium:26-31 | Library determines which have algorithms vs passthrough | Update spec: don't enumerate; just say "Snowball stemmers via library" |
| A2-17 | `provider_package_ref` on AiModel | schema.allium:282 | Not in code; legacy field not needed | Drop from spec |
---
## B. Code Behavior Not in Spec
### B1. Must Add to Spec (domain-level, affects behavior)
| ID | Behavior | Code Location | Path |
|---|---|---|---|
| B1-1 | Chat inline surfaces (9 types: card, chart, form, list, metric, mindmap, table, tabs, text/json) | `lib/bds/ui/chat/tool_surfaces.ex:6-15` | Distill into spec |
| B1-2 | Auto-translation system (AutoTranslation.maybe_schedule, media cascade, batch fill) | `lib/bds/posts/auto_translation.ex` | Distill into spec |
| B1-3 | 3 extra settings sections (Technology, MCP, Data Maintenance) | `lib/bds/ui/settings_editor/` | Distill into spec |
| B1-4 | Style/Theme as separate tab (`:style`), not settings section | `lib/bds/ui/style_editor.ex` | Distill into spec |
| B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity |
| B1-6 | Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering) | `lib/bds/rendering/` | Distill into spec |
| B1-7 | 404.html generation | `lib/bds/generation/outputs.ex:344-345` | Add to generation.allium |
| B1-8 | `linkedPostIds` in media sidecar | `lib/bds/media/sidecars.ex:42` | Add to frontmatter.allium MediaSidecar |
| B1-9 | `projectId` in template/script frontmatter | `templates.ex:337`, `scripts.ex:268` | Add to frontmatter.allium |
| B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium |
| B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium |
| B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium |
| B1-13 | `:confirm_dialog` generic confirmation | `shell_overlay.html.heex:171-187` | Add to modals.allium |
| B1-14 | Publish actions for scripts and templates | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | Add to editor_script.allium, editor_template.allium |
| B1-15 | `:import` as full editor tab | `lib/bds/ui/import_editor.ex` | Add to tabs.allium |
| B1-16 | `:documentation`/`:api_documentation` tab types | `lib/bds/desktop/misc_editor/` | Add to tabs.allium |
| B1-17 | Metadata diff covers embedding, media_translation, post_translation as entity types | `lib/bds/maintenance/repair.ex` | Add to metadata_diff.allium |
| B1-18 | Finished task TTL eviction (1h, keep last 10) | `lib/bds/tasks.ex:365-386` | Add to task.allium |
| B1-19 | `discard_post_changes/1` | `lib/bds/posts.ex:201-227` | Add to post.allium |
| B1-20 | `replace_media_file/2` with checksum/backup | `lib/bds/media.ex:288-337` | Add to media.allium |
### B2. Lower Priority (implementation detail or minor)
| ID | Behavior | Code Location |
|---|---|---|
| B2-1 | `editor_body/1` content resolver | `lib/bds/posts.ex:229-252` |
| B2-2 | `sync_post_from_file/1` single-post reimport | `lib/bds/posts.ex:254-279` |
| B2-3 | `import_orphan_post_file/1` | `lib/bds/posts.ex:289-291` |
| B2-4 | `dashboard_stats/1`, `post_counts_by_year_month/1` | `lib/bds/posts.ex:378-413` |
| B2-5 | `regenerate_missing_thumbnails/2` | `lib/bds/media.ex:47-48` |
| B2-6 | Cache dir computation | `lib/bds/projects.ex:101-106` |
| B2-7 | `remove_stale_published_templates` | `lib/bds/templates.ex:524-552` |
| B2-8 | Rendering Labels module (30+ i18n strings) | `lib/bds/rendering/labels.ex` |
| B2-9 | Progress reporting during reindex | `lib/bds/generation/progress.ex` |
---
## C. Internal Spec Inconsistencies
All reconciled to follow code. Specs must be self-consistent and match code.
| ID | Conflict | Resolution | Path |
|---|---|---|---|
| C-1 | schema.allium ChatMessage has no cache tokens; ai.allium ChatMessage has `cache_read_tokens`/`cache_write_tokens` | Code has cache tokens → align schema.allium with ai.allium | Update schema.allium |
| C-2 | media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it | Code writes `linkedPostIds` → add to frontmatter.allium | Update frontmatter.allium |
| C-3 | translation.allium says status/timestamps omitted; frontmatter.allium TranslationFrontmatter defines only 5 fields; code writes 8+ fields | Code writes status/timestamps → update both specs to match code | Update translation.allium + frontmatter.allium |
---
## D. Spec Claims Not Covered by Tests
### D1. No Test Coverage (HIGH priority — invariants/guarantees)
| ID | Claim | Spec | Path |
|---|---|---|---|
| D1-1 | UniqueMediaTranslation invariant | media.allium:108 | Write test: create duplicate media translation, expect rejection |
| D1-2 | UniqueTranslationPerLanguage invariant | translation.allium:94 | Write test: create duplicate post translation, expect rejection |
| D1-3 | BundledDefaultTemplatesExistOutsideProjectData | template.allium:65 | Write test: render with no Template rows, bundled template found |
| D1-4 | UserTemplateDirectoryOverridesBundledDefaults | template.allium:75 | Write test: project template overrides bundled same-slug |
| D1-5 | LiquidTagSubset (5 tags only) | template.allium:179 | Write test: unsupported tag raises error |
| D1-6 | LiquidFilterSubset (4 standard + 2 custom) | template.allium:191 | Write test: unsupported filter raises error |
| D1-7 | LiquidOperatorSubset | template.allium:210 | Write test: unsupported operator raises error |
| D1-8 | MacroTimeout guarantee | script.allium:94-95 | Write test: macro times out within budget |
| D1-9 | ExecuteTransform rule (pipeline, ordering, toast budget) | script.allium:229-263 | Write test: transform pipeline executes in order, toast budget enforced |
| D1-10 | TransformPipelineContinuation | script.allium:247-249 | Write test: error in transform doesn't halt pipeline |
| D1-11 | ChatContextTruncation invariant | ai.allium:375-379 | Write test: long chat history trimmed to context window |
| D1-12 | BoundedToolLoop enforcement | ai.allium:381-385 | Write test: tool rounds bounded by chat_max_tool_rounds |
| D1-13 | DiscardPostChangesSideEffects | engine_side_effects.allium:99-104 | Write test: FTS updated after discard |
| D1-14 | ReplaceMediaFileSideEffects | engine_side_effects.allium:128-134 | Write test: file replaced, thumbnails regenerated |
| D1-15 | Drag-and-drop image chain | action_patterns.allium:84-103 | Write integration test |
| D1-16 | DebouncedPersistence (5s) | embedding.allium:204-208 | Write test: index persistence debounced |
| D1-17 | Protected categories cannot be deleted | editor_settings.allium:81-84 | Write test: article/aside/page/picture deletion rejected |
| D1-18 | HomeItemProtection (menu) | editor_misc.allium:206-209 | Write test: cannot move/reorder/delete Home |
### D2. No Test Coverage (MEDIUM priority — rules/behaviors)
| ID | Claim | Spec | Path |
|---|---|---|---|
| D2-1 | RemoveCategory rule | metadata.allium:100 | Write test: remove category, verify list+settings+JSON updated |
| D2-2 | CreateAndPublishTemplate rule | template.allium:105 | Write test: create+publish in one step |
| D2-3 | CreateAndPublishScript rule | script.allium:160 | Write test: create+publish in one step |
| D2-4 | UniqueScriptSlug dedup | script.allium:115 | Write test: two scripts same title → dedup slug |
| D2-5 | FrontmatterRoundtrip invariant | post.allium:223 | Write test: write file, read back, assert all DB fields match |
| D2-6 | SidecarRoundtrip invariant | media.allium:198 | Write test: write sidecar, read back, assert all fields match |
| D2-7 | ConditionalPostFields: nil fields absent from frontmatter | frontmatter.allium:398 | Write test: post with nil excerpt/author/language → fields not in file |
| D2-8 | ConditionalMediaFields: nil fields absent from sidecar | frontmatter.allium:417 | Write test: media with nil title/alt → fields not in sidecar |
| D2-9 | max_posts_per_page 1..500 constraint | metadata.allium:75-77 | Write test: values outside range rejected |
| D2-10 | SandboxedExecution: restricted capabilities blocked | script.allium:84-88 | Write test: filesystem/process/package loading blocked |
| D2-11 | TransformToastBudget enforcement | script.allium:251-258 | Write test: per-script and total toast limits enforced |
| D2-12 | ProgressThrottled: 250ms throttle | task.allium:110-113 | Write test: rapid progress reports throttled |
| D2-13 | archived→draft transition | post.allium:121 | Write test: unarchive post → draft |
| D2-14 | archived→published transition | post.allium:122 | Write test: unarchive post → published |
| D2-15 | AppNoopNotifier: app writes don't produce notification rows | cli_sync.allium:64-68 | Write test: app mutation produces no notification row |
| D2-16 | ValidateMedia rule | media_processing.allium:318-343 | Write test: missing/corrupted/orphan media detected |
| D2-17 | ContentHashSkipsUnchanged during reindex | embedding.allium:199-202 | Write test: unchanged content_hash skips re-embedding |
### D3. Partial Test Coverage (needs expansion)
| ID | Claim | Spec | Gap | Path |
|---|---|---|---|---|
| D3-1 | PublishPost: content=null after publish | post.allium:186 | Not explicitly tested | Add assertion |
| D3-2 | PublishPost: old file deleted on path change | engine_side_effects.allium:73-74 | Not tested | Add test |
| D3-3 | UpsertPostTranslation: do_not_translate guard | translation.allium:113 | Indirectly covered only | Add direct test |
| D3-4 | PublishTemplate: Liquid validation prerequisite | template.allium:139 | Not tested as publish gate | Add test |
| D3-5 | PublishScript: validation prerequisite | script.allium:181 | Not tested as publish gate | Add test |
| D3-6 | ExecuteMacro failure degrades to empty | script.allium:199 | Returns error tuple, not empty | Fix code or update spec |
| D3-7 | TemplateFrontmatter roundtrip | template.allium:53 | Slug verified, no full parse-back | Add roundtrip test |
| D3-8 | DefaultCategories for fresh project | metadata.allium:60 | Defaults present after add, not verified fresh | Add fresh-project test |
| D3-9 | FtsIncludesTranslations | translation.allium:178 | Tested for one language; expand | Test all stemmer languages |
| D3-10 | PostCanonicalUrl format | post.allium:33-40 | Constructed in links test, not asserted as invariant | Add format assertion |
| D3-11 | Slug generation: German transliteration | post.allium:14-22 | "Föö Bär" → "foo-bar-blog" tested; expand ä/ö/ü/ß/ÄÖÜ | Expand test |
### D4. UI Test Coverage Gaps (whole-editor specs)
| ID | Spec | Covered | Not Covered |
|---|---|---|---|
| D4-1 | editor_media.allium | AI analysis, delete | Translate, replace file, link-to-post, translation CRUD, detect language |
| D4-2 | editor_settings.allium | AI endpoints, airplane toggle, rebuild | Protected categories, MCP agents, style/theme, search filter, categories CRUD |
| D4-3 | editor_chat.allium | Chat creation, pinned tab | API key screen, message rendering, input area, model selector, inline surfaces |
| D4-4 | editor_script.allium | Editor layout, create defaults | Save, syntax check, run, delete |
| D4-5 | editor_template.allium | Editor layout, create defaults | Save with validation, validate, delete with references |
| D4-6 | editor_tags.allium | Sync/discover, merge | Cloud sizing, color picker, delete confirmation, create form |
| D4-7 | editor_misc.allium | Menu add/save, metadata diff, validation | Menu protection, import analysis, translation fix, duplicate dismiss, git diff |
---
## Priority Order for Resolution
1. **A1-1 through A1-12** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown)
2. **D1-1 through D1-18** — untested invariants/guarantees
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
4. **B1-1 through B1-6** — major code behaviors missing from spec
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
6. **D2-1 through D2-17** — untested rules
7. **D3-1 through D3-11** — partial test coverage
8. **B1-7 through B1-20** — minor code behaviors missing from spec
9. **D4-1 through D4-7** — UI test coverage

87
TESTAUDIT.md Normal file
View File

@@ -0,0 +1,87 @@
# Test Audit Procedure
Periodic review of the unit test suite to ensure every test exercises production
code against real assumptions and behavior.
## Scope
All `*_test.exs` files under `test/`.
## What counts as a valid unit test
A valid unit test **calls at least one production function** from `lib/bds/` and
**asserts on its return value, side effects, or observable behavior**.
Acceptable patterns:
- Calling a production function and asserting its return value.
- Calling a production function with injected test doubles (fake HTTP clients,
fake runtimes) and asserting the production code's orchestration logic.
- Mounting a LiveView or rendering a LiveComponent and asserting HTML output
or database state after interactions.
- Sending events to a GenServer and asserting state transitions.
### Source-property tests (acceptable, not flagged)
Tests that verify structural properties of source code are acceptable and should
not be flagged during this audit. Examples:
- Checking that all public functions have `@spec` annotations (AST parsing).
- Asserting absence of `String.to_atom` or `cond do` in specific files.
- Verifying CSS/JS/template assets contain expected class names or imports.
- Checking that `API.md` matches the output of a documentation generator.
- Verifying database indexes exist via `EXPLAIN QUERY PLAN`.
- Asserting `.allium` spec files have consistent parameter signatures.
- Checking config files for expected values.
- Verifying function decomposition patterns in source.
These are linting/contract/consistency checks. They serve a purpose but are
distinct from behavioral tests.
## What gets flagged
1. **Export-existence-only tests** — tests that call `function_exported?/3` or
`Code.ensure_loaded?/1` without ever invoking the function. These verify
compilation, not behavior. They are redundant when the same module is already
tested via rendering or direct calls in another test file.
2. **Mock-only tests** — tests that define a fake/stub module and only assert
on that fake's behavior without routing through any production code path.
3. **Trivially-passing tests** — tests whose assertions succeed regardless of
whether the production code is correct (e.g., asserting on a hardcoded value
that never touches production logic).
## How to run the audit
Ask Claude Code to:
> Analyse the unit tests of the project and check if all of them actually call
> proper production code or if there are tests that essentially only test
> scaffolds, mocks and helper functions. Every unit test must test proper
> production code against assumptions and behaviour. Source-property tests
> (structure, @spec, asset presence, schema verification, doc staleness) are
> acceptable and should not be flagged.
The audit should:
1. Read every `*_test.exs` file under `test/` in full.
2. For each test block, identify which production function (if any) is called.
3. Flag any test that falls into the categories above.
4. Report flagged tests with file path, line number, and explanation.
## Audit log
### 2026-05-11
Reviewed all 71 test files (69 after cleanup). Found 2 redundant files:
- `test/bds/desktop/shell_live/chat_editor_test.exs` — single test only called
`function_exported?` for `ChatEditor`. The component was already fully tested
via `render_component` in `shell_live_test.exs`. **Deleted.**
- `test/bds/desktop/shell_live/import_editor_test.exs` — single test only called
`Code.ensure_loaded?` + `function_exported?` for `ImportEditor`. The component
was already exercised in `import_shell_live_test.exs`. **Deleted.**
Result after cleanup: 646 tests, 0 failures, 4 skipped.

View File

@@ -186,4 +186,12 @@ defmodule BDS.AI do
@spec cancel_chat(String.t()) :: :ok @spec cancel_chat(String.t()) :: :ok
defdelegate cancel_chat(conversation_id), to: Chat defdelegate cancel_chat(conversation_id), to: Chat
@spec get_surface_state(String.t()) :: map()
defdelegate get_surface_state(conversation_id), to: Chat
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
{:ok, map()} | {:error, term()}
defdelegate put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces),
to: Chat
end end

View File

@@ -62,6 +62,42 @@ defmodule BDS.AI.Chat do
Repo.get(ChatConversation, conversation_id) Repo.get(ChatConversation, conversation_id)
end end
@spec get_surface_state(String.t()) :: map()
def get_surface_state(conversation_id) when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do
%ChatConversation{surface_state: state} when is_map(state) -> state
_other -> %{}
end
end
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
{:ok, map()} | {:error, term()}
def put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces)
when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do
nil ->
{:error, :not_found}
%ChatConversation{} = conversation ->
state = %{
"surface_data" => surface_data,
"surface_tabs" => surface_tabs,
"dismissed_surfaces" => MapSet.to_list(dismissed_surfaces)
}
conversation
|> ChatConversation.changeset(%{
surface_state: state,
updated_at: Persistence.now_ms()
})
|> Repo.update()
|> case do
{:ok, _updated} -> {:ok, state}
error -> error
end
end
end
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()} @spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do case Repo.get(ChatConversation, conversation_id) do

View File

@@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do
title: String.t() | nil, title: String.t() | nil,
model: String.t() | nil, model: String.t() | nil,
copilot_session_id: String.t() | nil, copilot_session_id: String.t() | nil,
surface_state: map() | nil,
created_at: integer() | nil, created_at: integer() | nil,
updated_at: integer() | nil updated_at: integer() | nil
} }
@@ -19,13 +20,14 @@ defmodule BDS.AI.ChatConversation do
field :title, :string field :title, :string
field :model, :string field :model, :string
field :copilot_session_id, :string field :copilot_session_id, :string
field :surface_state, :map
field :created_at, :integer field :created_at, :integer
field :updated_at, :integer field :updated_at, :integer
end end
def changeset(conversation, attrs) do def changeset(conversation, attrs) do
conversation conversation
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at], |> cast(attrs, [:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
empty_values: [nil] empty_values: [nil]
) )
|> validate_required([:id, :title, :created_at, :updated_at]) |> validate_required([:id, :title, :created_at, :updated_at])

View File

@@ -48,29 +48,32 @@ defmodule BDS.Desktop.Overlay do
end end
def open(:media, :confirm_delete, context) do def open(:media, :confirm_delete, context) do
delete_details = Map.get(context, :delete_details, %{}) %{
title: title,
entity_name: entity_name,
entity_type: entity_type,
reference_list: reference_list
} = context.delete_details
%{ %{
kind: :confirm_delete, kind: :confirm_delete,
title: Map.get(delete_details, :title, "Delete"), title: title,
entity_name: Map.get(delete_details, :entity_name, ""), entity_name: entity_name,
entity_type: Map.get(delete_details, :entity_type, "media"), entity_type: entity_type,
reference_count: length(Map.get(delete_details, :reference_list, [])), reference_count: length(reference_list),
reference_list: Map.get(delete_details, :reference_list, []) reference_list: reference_list
} }
end end
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context) def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
def open(:tags, :confirm_merge, context) do def open(:tags, :confirm_merge, context) do
merge = Map.get(context, :merge_details, %{}) %{title: title, message: message} = context.merge_details
target = Map.get(merge, :target, "")
count = Map.get(merge, :count, 0)
%{ %{
kind: :confirm_dialog, kind: :confirm_dialog,
title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"), title: title,
message: Map.get(merge, :message, "Cannot be undone.") message: message
} }
end end
@@ -115,8 +118,8 @@ defmodule BDS.Desktop.Overlay do
|> Map.get(:all_media, []) |> Map.get(:all_media, [])
|> Enum.filter(fn media -> |> Enum.filter(fn media ->
normalized == "" or normalized == "" or
search_matches?(Map.get(media, :title, ""), normalized) or search_matches?(media.title, normalized) or
search_matches?(Map.get(media, :original_name, ""), normalized) search_matches?(media.original_name, normalized)
end) end)
|> Enum.map(&to_insert_media_result/1) |> Enum.map(&to_insert_media_result/1)
@@ -203,18 +206,22 @@ defmodule BDS.Desktop.Overlay do
def insert_media_result(_overlay, _media_id), do: nil def insert_media_result(_overlay, _media_id), do: nil
defp language_picker(context, source_language) do defp language_picker(context, source_language) do
existing_translations = Map.get(context, :existing_translations, %{})
language_names = Map.get(context, :language_names, %{})
language_flags = Map.get(context, :language_flags, %{})
targets = targets =
context context
|> Map.get(:blog_languages, []) |> Map.get(:blog_languages, [])
|> Enum.uniq() |> Enum.uniq()
|> Enum.reject(&(&1 == source_language)) |> Enum.reject(&(&1 == source_language))
|> Enum.map(fn code -> |> Enum.map(fn code ->
existing_status = Map.get(Map.get(context, :existing_translations, %{}), code) existing_status = Map.get(existing_translations, code)
%{ %{
code: code, code: code,
name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)), name: Map.get(language_names, code, String.upcase(code)),
flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code), flag_emoji: Map.get(language_flags, code, code),
has_existing_translation: not is_nil(existing_status), has_existing_translation: not is_nil(existing_status),
existing_status: existing_status existing_status: existing_status
} }
@@ -255,14 +262,15 @@ defmodule BDS.Desktop.Overlay do
def set_ai_suggestions_error(overlay, _error_message), do: overlay def set_ai_suggestions_error(overlay, _error_message), do: overlay
defp normalize_ai_fields(fields) do defp normalize_ai_fields(fields) do
Enum.map(fields, fn field -> Enum.map(fields, fn %{key: key, label: label, current_value: current,
suggested_value: suggested, locked: locked} = field ->
%{ %{
key: to_string(Map.get(field, :key, "")), key: to_string(key),
label: Map.get(field, :label, ""), label: label,
current_value: Map.get(field, :current_value, ""), current_value: current,
suggested_value: Map.get(field, :suggested_value, ""), suggested_value: suggested,
accepted: not Map.get(field, :locked, false), accepted: not locked,
locked: Map.get(field, :locked, false), locked: locked,
loading: Map.get(field, :loading, false) loading: Map.get(field, :loading, false)
} }
end) end)
@@ -276,7 +284,7 @@ defmodule BDS.Desktop.Overlay do
end end
defp gallery_images(context) do defp gallery_images(context) do
images = Enum.filter(Map.get(context, :media, []), &Map.get(&1, :is_image, false)) images = Enum.filter(Map.get(context, :media, []), & &1.is_image)
post_media_ids = Map.get(context, :post_media_ids, []) post_media_ids = Map.get(context, :post_media_ids, [])
case Enum.filter(images, &(&1.id in post_media_ids)) do case Enum.filter(images, &(&1.id in post_media_ids)) do
@@ -289,29 +297,29 @@ defmodule BDS.Desktop.Overlay do
%{ %{
post_id: post.id, post_id: post.id,
title: post.title, title: post.title,
status: to_string(Map.get(post, :status, "draft")), status: post.status,
canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"), canonical_url: post.canonical_url,
similarity_score: Map.get(post, :similarity_score) similarity_score: nil
} }
end end
defp to_insert_media_result(media) do defp to_insert_media_result(media) do
%{ %{
media_id: media.id, media_id: media.id,
title: Map.get(media, :title, ""), title: media.title,
original_name: Map.get(media, :original_name, media.id), original_name: media.original_name,
is_image: Map.get(media, :is_image, false), is_image: media.is_image,
thumbnail_url: Map.get(media, :thumbnail_url) thumbnail_url: media.thumbnail_url
} }
end end
defp to_gallery_image(media) do defp to_gallery_image(media) do
%{ %{
media_id: media.id, media_id: media.id,
thumbnail_url: Map.get(media, :thumbnail_url), thumbnail_url: media.thumbnail_url,
image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)), image_url: media.image_url,
alt_text: Map.get(media, :alt_text), alt_text: media.alt_text,
title: Map.get(media, :title, Map.get(media, :original_name, media.id)) title: media.title
} }
end end

View File

@@ -449,7 +449,7 @@ defmodule BDS.Desktop.ShellCommands do
end end
defp translation_fill_enabled?(metadata) do defp translation_fill_enabled?(metadata) do
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, [])) ([metadata.main_language] ++ metadata.blog_languages)
|> Enum.map(fn language -> |> Enum.map(fn language ->
language language
|> to_string() |> to_string()

View File

@@ -116,6 +116,15 @@ defmodule BDS.Desktop.ShellLive.Bridges do
{:noreply, assign(socket, :chat_editor_request_refs, refs)} {:noreply, assign(socket, :chat_editor_request_refs, refs)}
end end
def handle_info({:persist_surface_state, conversation_id}, socket, _callbacks) do
send_update(ChatEditor,
id: "chat-editor-#{conversation_id}",
action: :persist_surface_state
)
{:noreply, socket}
end
def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
{:noreply, {:noreply,
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))} callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}

View File

@@ -1,6 +1,8 @@
defmodule BDS.Desktop.ShellLive.ChatEditor do defmodule BDS.Desktop.ShellLive.ChatEditor do
@moduledoc false @moduledoc false
require Logger
use Phoenix.LiveComponent use Phoenix.LiveComponent
import Phoenix.HTML, only: [raw: 1] import Phoenix.HTML, only: [raw: 1]
@@ -37,6 +39,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
{:ok, do_note_streaming_content(socket, content)} {:ok, do_note_streaming_content(socket, content)}
end end
def update(%{action: :persist_surface_state}, socket) do
{:ok, persist_surface_state(socket)}
end
def update(assigns, socket) do def update(assigns, socket) do
socket = socket =
socket socket
@@ -97,7 +103,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
socket socket
) do ) do
next_data = Map.put(socket.assigns.surface_data, surface_id, fields) next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
{:noreply, assign(socket, :surface_data, next_data) |> build_data()} {:noreply, assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()}
end end
def handle_event( def handle_event(
@@ -111,6 +117,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
:surface_tabs, :surface_tabs,
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index)) Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
) )
|> persist_surface_state()
|> build_data() |> build_data()
{:noreply, socket} {:noreply, socket}
@@ -120,6 +127,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
socket = socket =
socket socket
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id)) |> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|> persist_surface_state()
|> build_data() |> build_data()
{:noreply, socket} {:noreply, socket}
@@ -148,14 +156,29 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp ensure_state(socket) do defp ensure_state(socket) do
conversation_id = socket.assigns.current_tab.id conversation_id = socket.assigns.current_tab.id
persisted = AI.get_surface_state(conversation_id)
{surface_data, surface_tabs, dismissed_surfaces} =
case persisted do
state when is_map(state) and map_size(state) > 0 ->
{
state["surface_data"] || %{},
state["surface_tabs"] || %{},
MapSet.new(state["dismissed_surfaces"] || [])
}
_other ->
{%{}, %{}, MapSet.new()}
end
defaults = %{ defaults = %{
conversation_id: conversation_id, conversation_id: conversation_id,
input: "", input: "",
model_selector_open?: false, model_selector_open?: false,
request: nil, request: nil,
surface_data: %{}, surface_data: surface_data,
surface_tabs: %{}, surface_tabs: surface_tabs,
dismissed_surfaces: MapSet.new(), dismissed_surfaces: dismissed_surfaces,
action_error: nil action_error: nil
} }
@@ -819,6 +842,41 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Private helpers ─────────────────────────────────────────────────────── # ── Private helpers ───────────────────────────────────────────────────────
@surface_state_debounce_ms 500
defp persist_surface_state(socket) do
conversation_id = socket.assigns.conversation_id
surface_data = socket.assigns.surface_data
surface_tabs = socket.assigns.surface_tabs
dismissed_surfaces = socket.assigns.dismissed_surfaces
case AI.put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces) do
{:ok, _state} ->
:ok
{:error, reason} ->
Logger.warning("Failed to persist surface state for conversation #{conversation_id}",
reason: inspect(reason)
)
end
socket
end
defp schedule_surface_state_persist(socket) do
if socket.assigns[:surface_state_timer] do
Process.cancel_timer(socket.assigns[:surface_state_timer])
end
timer =
Process.send_after(
self(),
{:persist_surface_state, socket.assigns.conversation_id},
@surface_state_debounce_ms
)
assign(socket, :surface_state_timer, timer)
end
defp active_project_id(socket) do defp active_project_id(socket) do
socket.assigns[:project_id] socket.assigns[:project_id]

View File

@@ -266,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
@spec default_author(term()) :: term() @spec default_author(term()) :: term()
def default_author(project_id) do def default_author(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
Map.get(metadata, :default_author) metadata.default_author
end end
@spec suggested_definition_name(term()) :: term() @spec suggested_definition_name(term()) :: term()

View File

@@ -222,7 +222,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
%{ %{
categories: Enum.uniq(Map.get(metadata, :categories, []) || []), categories: Enum.uniq(metadata.categories || []),
tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq() tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq()
} }
end end

View File

@@ -22,8 +22,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
@spec category_rows(term()) :: term() @spec category_rows(term()) :: term()
def category_rows(metadata) do def category_rows(metadata) do
categories = Map.get(metadata, :categories, []) categories = metadata.categories
settings = Map.get(metadata, :category_settings, %{}) settings = metadata.category_settings
Enum.map(categories, fn category -> Enum.map(categories, fn category ->
category_settings = Map.get(settings, category, %{}) category_settings = Map.get(settings, category, %{})
@@ -167,7 +167,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
end end
end end
defp category_names(metadata), do: Map.get(metadata, :categories, []) defp category_names(metadata), do: metadata.categories
defp ensure_default_categories(project_id) do defp ensure_default_categories(project_id) do
Enum.reduce_while(Map.keys(@default_category_settings), :ok, fn category, _acc -> Enum.reduce_while(Map.keys(@default_category_settings), :ok, fn category, _acc ->

View File

@@ -16,17 +16,17 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
@spec project_form(term()) :: term() @spec project_form(term()) :: term()
def project_form(metadata) do def project_form(metadata) do
%{ %{
"name" => Map.get(metadata, :name, ""), "name" => metadata.name || "",
"description" => Map.get(metadata, :description, ""), "description" => metadata.description || "",
"public_url" => Map.get(metadata, :public_url, ""), "public_url" => metadata.public_url || "",
"main_language" => Map.get(metadata, :main_language) || "en", "main_language" => metadata.main_language || "en",
"default_author" => Map.get(metadata, :default_author, ""), "default_author" => metadata.default_author || "",
"max_posts_per_page" => Integer.to_string(Map.get(metadata, :max_posts_per_page, 50)), "max_posts_per_page" => Integer.to_string(metadata.max_posts_per_page),
"blogmark_category" => "blogmark_category" =>
Map.get(metadata, :blogmark_category) || metadata.blogmark_category ||
List.first(Map.get(metadata, :categories, [])) || "article", List.first(metadata.categories) || "article",
"blog_languages" => Map.get(metadata, :blog_languages, []), "blog_languages" => metadata.blog_languages,
"semantic_similarity_enabled" => Map.get(metadata, :semantic_similarity_enabled, false) "semantic_similarity_enabled" => metadata.semantic_similarity_enabled
} }
end end

View File

@@ -8,7 +8,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
@spec publishing_form(term()) :: term() @spec publishing_form(term()) :: term()
def publishing_form(metadata) do def publishing_form(metadata) do
prefs = Map.get(metadata, :publishing_preferences, %{}) prefs = metadata.publishing_preferences
%{ %{
"ssh_host" => Map.get(prefs, "ssh_host", ""), "ssh_host" => Map.get(prefs, "ssh_host", ""),

View File

@@ -88,7 +88,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
def current_theme(assigns) do def current_theme(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} -> {:ok, metadata} ->
case Map.get(metadata, :pico_theme) do case metadata.pico_theme do
nil -> "default" nil -> "default"
"" -> "default" "" -> "default"
theme -> theme theme -> theme

View File

@@ -11,6 +11,21 @@ defmodule BDS.Desktop.UILocale do
process dictionary directly. Use `with_locale/2` around any render or process dictionary directly. Use `with_locale/2` around any render or
component that needs a locale binding; use `current/0` to read it. component that needs a locale binding; use `current/0` to read it.
## Invariant
Every code path that evaluates HEEx templates containing `translated/1,2`
calls **must** call `UILocale.put/1` before template evaluation:
* `ShellLive.render/1` — sets locale at the top of every LiveView render.
* `SidebarComponents.sidebar_content/1` — sets locale before the function
component's HEEx (runs in the same process, may be called outside
the parent render cycle via `send_update`).
* `MenuBar.mount/1` and `MenuBar.handle_info({:set_ui_locale, _})` — set
locale in the separate menu-bar process which has its own render cycle.
Violating this invariant causes `current/0` to return a stale or `nil`
locale, producing untranslated UI text.
Direct use of `Process.put(:bds_ui_locale, _)` or Direct use of `Process.put(:bds_ui_locale, _)` or
`Process.get(:bds_ui_locale)` is forbidden outside this module. `Process.get(:bds_ui_locale)` is forbidden outside this module.
""" """

View File

@@ -15,6 +15,7 @@ defmodule BDS.Embeddings do
@duplicate_threshold 0.92 @duplicate_threshold 0.92
@exact_match_score 0.999999 @exact_match_score 0.999999
@key_batch_size 199
def model_id, do: configured_backend().model_info().model_id def model_id, do: configured_backend().model_info().model_id
def dimensions, do: configured_backend().model_info().dimensions def dimensions, do: configured_backend().model_info().dimensions
@@ -73,7 +74,24 @@ defmodule BDS.Embeddings do
order_by: [asc: post.created_at, asc: post.slug] order_by: [asc: post.created_at, asc: post.slug]
) )
Enum.each(posts, &sync_post_if_enabled(&1, refresh_index: false)) existing_keys = preload_keys_by_post_id(project_id, Enum.map(posts, & &1.id))
base_label = max_label_value()
{rows, _next_label} =
Enum.reduce(posts, {[], base_label + 1}, fn post, {acc, next_label} ->
existing_key = Map.get(existing_keys, post.id)
case compute_key_data(post, existing_key, next_label) do
:skip ->
{acc, next_label}
{:upsert, row} ->
bump = if existing_key, do: 0, else: 1
{[row | acc], next_label + bump}
end
end)
batch_upsert_keys(rows)
:ok = rebuild_snapshot(project_id) :ok = rebuild_snapshot(project_id)
{:ok, Enum.map(posts, & &1.id)} {:ok, Enum.map(posts, & &1.id)}
else else
@@ -104,12 +122,27 @@ defmodule BDS.Embeddings do
where: key.project_id == ^project_id and key.post_id not in ^post_ids where: key.project_id == ^project_id and key.post_id not in ^post_ids
) )
posts existing_keys = preload_keys_by_post_id(project_id)
|> Enum.with_index(1) base_label = max_label_value()
|> Enum.each(fn {post, index} ->
sync_post_if_enabled(post, refresh_index: false) {rows, _next_label} =
:ok = report_rebuild_progress(on_progress, index, total_posts, "embedding entries") posts
end) |> Enum.with_index(1)
|> Enum.reduce({[], base_label + 1}, fn {post, index}, {acc, next_label} ->
:ok = report_rebuild_progress(on_progress, index, total_posts, "embedding entries")
existing_key = Map.get(existing_keys, post.id)
case compute_key_data(post, existing_key, next_label) do
:skip ->
{acc, next_label}
{:upsert, row} ->
bump = if existing_key, do: 0, else: 1
{[row | acc], next_label + bump}
end
end)
batch_upsert_keys(rows)
:ok = report_rebuild_phase(on_progress, 0.99, "Persisting embedding snapshot") :ok = report_rebuild_phase(on_progress, 0.99, "Persisting embedding snapshot")
:ok = rebuild_snapshot(project_id) :ok = rebuild_snapshot(project_id)
@@ -196,6 +229,53 @@ defmodule BDS.Embeddings do
end end
end end
defp preload_keys_by_post_id(project_id) do
Repo.all(from key in Key, where: key.project_id == ^project_id)
|> Map.new(&{&1.post_id, &1})
end
defp preload_keys_by_post_id(project_id, post_ids) do
Repo.all(
from key in Key,
where: key.project_id == ^project_id and key.post_id in ^post_ids
)
|> Map.new(&{&1.post_id, &1})
end
defp max_label_value do
Repo.one(from key in Key, select: max(key.label)) || 0
end
defp compute_key_data(%Post{} = post, existing_key, next_label) do
body = resolve_post_body(post)
raw_text = compose_embedding_source(post.title, body)
content_hash = hash_text(raw_text)
if existing_key && existing_key.content_hash == content_hash do
:skip
else
{:ok, vector} = embed_text(raw_text, post.language)
label = if existing_key, do: existing_key.label, else: next_label
{:upsert, [label, post.id, post.project_id, content_hash, Jason.encode!(vector)]}
end
end
defp batch_upsert_keys([]), do: :ok
defp batch_upsert_keys(rows) do
rows
|> Enum.chunk_every(@key_batch_size)
|> Enum.each(fn chunk ->
placeholders = Enum.map_join(chunk, ", ", fn _ -> "(?, ?, ?, ?, ?)" end)
params = List.flatten(chunk)
Repo.query!(
"INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector) VALUES #{placeholders} ON CONFLICT(label) DO UPDATE SET content_hash = excluded.content_hash, vector = excluded.vector",
params
)
end)
end
def remove_post(post_id) when is_binary(post_id) do def remove_post(post_id) when is_binary(post_id) do
project_id = project_id =
case Repo.get_by(Key, post_id: post_id) do case Repo.get_by(Key, post_id: post_id) do
@@ -227,23 +307,24 @@ defmodule BDS.Embeddings do
order_by: [asc: post.created_at, asc: post.slug] order_by: [asc: post.created_at, asc: post.slug]
) )
Enum.each(posts, fn post -> existing_keys = preload_keys_by_post_id(project_id)
body = resolve_post_body(post) base_label = max_label_value()
content_hash = hash_text(compose_embedding_source(post.title, body))
case Repo.get_by(Key, post_id: post.id, project_id: project_id) do {rows, _next_label} =
%Key{content_hash: ^content_hash} -> Enum.reduce(posts, {[], base_label + 1}, fn post, {acc, next_label} ->
:ok existing_key = Map.get(existing_keys, post.id)
_other -> case compute_key_data(post, existing_key, next_label) do
:ok = :skip ->
sync_post_if_enabled( {acc, next_label}
%{post | content: if(post.content in [nil, ""], do: body, else: post.content)},
refresh_index: false
)
end
end)
{:upsert, row} ->
bump = if existing_key, do: 0, else: 1
{[row | acc], next_label + bump}
end
end)
batch_upsert_keys(rows)
:ok = rebuild_snapshot(project_id) :ok = rebuild_snapshot(project_id)
indexed = indexed =

View File

@@ -118,7 +118,7 @@ defmodule BDS.Generation.Data do
main = String.downcase(to_string(main_language || "")) main = String.downcase(to_string(main_language || ""))
Enum.map(posts, fn post -> Enum.map(posts, fn post ->
post_language = String.downcase(to_string(Map.get(post, :language) || "")) post_language = String.downcase(to_string(post.language || ""))
effective_language = if post_language == "", do: main, else: post_language effective_language = if post_language == "", do: main, else: post_language
cond do cond do
@@ -373,18 +373,18 @@ defmodule BDS.Generation.Data do
excerpt: translation.excerpt, excerpt: translation.excerpt,
content: nil, content: nil,
status: :published, status: :published,
author: Map.get(post, :author), author: post.author,
created_at: post.created_at, created_at: post.created_at,
updated_at: translation.updated_at, updated_at: translation.updated_at,
published_at: translation.published_at || post.published_at, published_at: translation.published_at || post.published_at,
file_path: translation.file_path, file_path: translation.file_path,
tags: Map.get(post, :tags, []), tags: post.tags,
categories: Map.get(post, :categories, []), categories: post.categories,
template_slug: Map.get(post, :template_slug), template_slug: post.template_slug,
language: translation.language, language: translation.language,
do_not_translate: Map.get(post, :do_not_translate, false), do_not_translate: post.do_not_translate,
translation_source_slug: post.slug, translation_source_slug: post.slug,
translation_canonical_language: Map.get(post, :language), translation_canonical_language: post.language,
translation_file_path: translation.file_path translation_file_path: translation.file_path
} }
end end

View File

@@ -21,7 +21,7 @@ defmodule BDS.Generation.Outputs do
Enum.reject(route_posts, fn post -> Enum.reject(route_posts, fn post ->
is_binary(Map.get(post, :translation_source_slug)) and is_binary(Map.get(post, :translation_source_slug)) and
MapSet.member?(subtree_languages, to_string(Map.get(post, :language))) MapSet.member?(subtree_languages, to_string(post.language))
end) end)
end end
@@ -80,10 +80,12 @@ defmodule BDS.Generation.Outputs do
def category_route_paths(plan, posts_by_category, route_language) do def category_route_paths(plan, posts_by_category, route_language) do
if :category in plan.sections do if :category in plan.sections do
Enum.flat_map(posts_by_category, fn {category, posts} -> Enum.flat_map(posts_by_category, fn {category, posts} ->
post_count = length(posts)
paginated_archive_paths( paginated_archive_paths(
route_language, route_language,
["category", archive_route_segment(category)], ["category", archive_route_segment(category)],
length(posts), post_count,
plan.max_posts_per_page plan.max_posts_per_page
) )
end) end)
@@ -96,10 +98,12 @@ defmodule BDS.Generation.Outputs do
def tag_route_paths(plan, posts_by_tag, route_language) do def tag_route_paths(plan, posts_by_tag, route_language) do
if :tag in plan.sections do if :tag in plan.sections do
Enum.flat_map(posts_by_tag, fn {tag, posts} -> Enum.flat_map(posts_by_tag, fn {tag, posts} ->
post_count = length(posts)
paginated_archive_paths( paginated_archive_paths(
route_language, route_language,
["tag", archive_route_segment(tag)], ["tag", archive_route_segment(tag)],
length(posts), post_count,
plan.max_posts_per_page plan.max_posts_per_page
) )
end) end)
@@ -113,10 +117,12 @@ defmodule BDS.Generation.Outputs do
if :date in plan.sections do if :date in plan.sections do
year_paths = year_paths =
Enum.flat_map(post_index.posts_by_year, fn {year, posts} -> Enum.flat_map(post_index.posts_by_year, fn {year, posts} ->
post_count = length(posts)
paginated_archive_paths( paginated_archive_paths(
route_language, route_language,
[Integer.to_string(year)], [Integer.to_string(year)],
length(posts), post_count,
plan.max_posts_per_page plan.max_posts_per_page
) )
end) end)
@@ -124,11 +130,12 @@ defmodule BDS.Generation.Outputs do
month_paths = month_paths =
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} -> Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
[year, month] = String.split(year_month, "/", parts: 2) [year, month] = String.split(year_month, "/", parts: 2)
post_count = length(posts)
paginated_archive_paths( paginated_archive_paths(
route_language, route_language,
[year, month], [year, month],
length(posts), post_count,
plan.max_posts_per_page plan.max_posts_per_page
) )
end) end)
@@ -136,11 +143,12 @@ defmodule BDS.Generation.Outputs do
day_paths = day_paths =
Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} -> Enum.flat_map(post_index.posts_by_year_month_day, fn {year_month_day, posts} ->
[year, month, day] = String.split(year_month_day, "/", parts: 3) [year, month, day] = String.split(year_month_day, "/", parts: 3)
post_count = length(posts)
paginated_archive_paths( paginated_archive_paths(
route_language, route_language,
[year, month, day], [year, month, day],
length(posts), post_count,
plan.max_posts_per_page plan.max_posts_per_page
) )
end) end)
@@ -424,11 +432,11 @@ defmodule BDS.Generation.Outputs do
title: post.title, title: post.title,
content: body, content: body,
slug: post.slug, slug: post.slug,
language: Map.get(post, :language), language: post.language,
excerpt: post.excerpt, excerpt: post.excerpt,
_post_record: post _post_record: post
}, },
fn -> render_post_page(post.title, body, post.slug, Map.get(post, :language)) end fn -> render_post_page(post.title, body, post.slug, post.language) end
)} )}
end) end)
end) end)
@@ -552,11 +560,11 @@ defmodule BDS.Generation.Outputs do
title: post.title, title: post.title,
content: body, content: body,
slug: post.slug, slug: post.slug,
language: Map.get(post, :language), language: post.language,
excerpt: post.excerpt, excerpt: post.excerpt,
_post_record: post _post_record: post
}, },
fn -> render_post_page(post.title, body, post.slug, Map.get(post, :language)) end fn -> render_post_page(post.title, body, post.slug, post.language) end
)} )}
end) end)
end) end)

View File

@@ -75,7 +75,7 @@ defmodule BDS.Generation.Sitemap do
page_path = Paths.relative_path_to_url_path(Paths.page_output_path(post.slug, nil)) page_path = Paths.relative_path_to_url_path(Paths.page_output_path(post.slug, nil))
languages = languages =
if Paths.truthy_flag?(Map.get(post, :do_not_translate)), if Paths.truthy_flag?(post.do_not_translate),
do: [plan.language], do: [plan.language],
else: all_languages else: all_languages

View File

@@ -34,7 +34,7 @@ defmodule BDS.Generation.Validation do
post_file_path: post_file_path:
source_full_path( source_full_path(
project_data_dir, project_data_dir,
Map.get(post, :translation_file_path) || Map.get(post, :file_path) Map.get(post, :translation_file_path) || post.file_path
), ),
generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0) generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0)
} }
@@ -53,7 +53,7 @@ defmodule BDS.Generation.Validation do
%{ %{
post_url_path: relative_path_to_url_path(relative_path), post_url_path: relative_path_to_url_path(relative_path),
post_file_path: source_full_path(project_data_dir, Map.get(post, :file_path)), post_file_path: source_full_path(project_data_dir, post.file_path),
generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0) generated_updated_at_ms: Map.get(generated_file_updated_at, relative_path, 0)
} }
end) end)

View File

@@ -605,6 +605,6 @@ defmodule BDS.ImportExecution do
defp project_default_author(project_id) do defp project_default_author(project_id) do
{:ok, metadata} = Metadata.get_project_metadata(project_id) {:ok, metadata} = Metadata.get_project_metadata(project_id)
Map.get(metadata, :default_author) metadata.default_author
end end
end end

View File

@@ -106,7 +106,7 @@ defmodule BDS.Media do
|> Repo.insert!() |> Repo.insert!()
end) do end) do
{:ok, media} -> {:ok, media} ->
:ok = write_sidecar(project, media) log_sidecar_error(write_sidecar(project, media), media.id)
log_thumbnail_error(ensure_thumbnails(project, media), media.id) log_thumbnail_error(ensure_thumbnails(project, media), media.id)
:ok = Search.sync_media(media) :ok = Search.sync_media(media)
{:ok, media} {:ok, media}
@@ -148,7 +148,7 @@ defmodule BDS.Media do
|> Repo.update!() |> Repo.update!()
end) do end) do
{:ok, updated_media} -> {:ok, updated_media} ->
:ok = write_sidecar(project, updated_media) log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
:ok = Search.sync_media(updated_media) :ok = Search.sync_media(updated_media)
{:ok, updated_media} {:ok, updated_media}
@@ -240,7 +240,7 @@ defmodule BDS.Media do
|> Repo.insert_or_update!() |> Repo.insert_or_update!()
end) do end) do
{:ok, updated_translation} -> {:ok, updated_translation} ->
:ok = write_translation_sidecar(project, media, updated_translation) log_sidecar_error(write_translation_sidecar(project, media, updated_translation), media.id)
:ok = Search.sync_media(media.id) :ok = Search.sync_media(media.id)
{:ok, updated_translation} {:ok, updated_translation}
@@ -275,7 +275,7 @@ defmodule BDS.Media do
) )
:ok = Search.sync_media(media) :ok = Search.sync_media(media)
:ok = write_sidecar(project, media) log_sidecar_error(write_sidecar(project, media), media.id)
{:ok, true} {:ok, true}
{:error, changeset} -> {:error, changeset} ->
@@ -322,7 +322,7 @@ defmodule BDS.Media do
end) do end) do
{:ok, updated_media} -> {:ok, updated_media} ->
_ = File.rm(previous_destination_backup) _ = File.rm(previous_destination_backup)
:ok = write_sidecar(project, updated_media) log_sidecar_error(write_sidecar(project, updated_media), updated_media.id)
log_thumbnail_error(ensure_thumbnails(project, updated_media), updated_media.id) log_thumbnail_error(ensure_thumbnails(project, updated_media), updated_media.id)
:ok = Search.sync_media(updated_media) :ok = Search.sync_media(updated_media)
{:ok, updated_media} {:ok, updated_media}
@@ -350,4 +350,10 @@ defmodule BDS.Media do
defp log_thumbnail_error({:error, reason}, media_id) do defp log_thumbnail_error({:error, reason}, media_id) do
Logger.warning("Thumbnail generation failed for media #{media_id}: #{inspect(reason)}") Logger.warning("Thumbnail generation failed for media #{media_id}: #{inspect(reason)}")
end end
defp log_sidecar_error(:ok, _media_id), do: :ok
defp log_sidecar_error({:error, reason}, media_id) do
Logger.warning("Sidecar write failed for media #{media_id}: #{inspect(reason)}")
end
end end

View File

@@ -1,6 +1,8 @@
defmodule BDS.Media.Linking do defmodule BDS.Media.Linking do
@moduledoc false @moduledoc false
require Logger
import Ecto.Query import Ecto.Query
alias BDS.Media.Media alias BDS.Media.Media
@@ -64,7 +66,7 @@ defmodule BDS.Media.Linking do
end end
end) do end) do
{:ok, _result} -> {:ok, _result} ->
:ok = Sidecars.write_sidecar(project, media) log_sidecar_error(Sidecars.write_sidecar(project, media), media.id)
{:ok, :linked} {:ok, :linked}
{:error, reason} -> {:error, reason} ->
@@ -93,7 +95,7 @@ defmodule BDS.Media.Linking do
:ok :ok
end) do end) do
{:ok, :ok} -> {:ok, :ok} ->
:ok = Sidecars.write_sidecar(project, media) log_sidecar_error(Sidecars.write_sidecar(project, media), media.id)
{:ok, :unlinked} {:ok, :unlinked}
{:error, reason} -> {:error, reason} ->
@@ -112,6 +114,12 @@ defmodule BDS.Media.Linking do
) )
end end
defp log_sidecar_error(:ok, _media_id), do: :ok
defp log_sidecar_error({:error, reason}, media_id) do
Logger.warning("Sidecar write failed for media #{media_id}: #{inspect(reason)}")
end
defp next_sort_order(media_id) do defp next_sort_order(media_id) do
case Repo.one( case Repo.one(
from pm in PostMedia, from pm in PostMedia,

View File

@@ -18,10 +18,9 @@ defmodule BDS.Media.Sidecars do
alias BDS.Search alias BDS.Search
alias BDS.Sidecar alias BDS.Sidecar
@spec write_sidecar(BDS.Projects.Project.t(), Media.t()) :: :ok @spec write_sidecar(BDS.Projects.Project.t(), Media.t()) :: :ok | {:error, File.posix()}
def write_sidecar(project, media) do def write_sidecar(project, media) do
path = Path.join(Projects.project_data_dir(project), media.sidecar_path) path = Path.join(Projects.project_data_dir(project), media.sidecar_path)
:ok = File.mkdir_p(Path.dirname(path))
atomic_write( atomic_write(
path, path,
@@ -45,7 +44,8 @@ defmodule BDS.Media.Sidecars do
) )
end end
@spec write_translation_sidecar(BDS.Projects.Project.t(), Media.t(), Translation.t()) :: :ok @spec write_translation_sidecar(BDS.Projects.Project.t(), Media.t(), Translation.t()) ::
:ok | {:error, File.posix()}
def write_translation_sidecar(project, media, translation) do def write_translation_sidecar(project, media, translation) do
path = path =
Path.join( Path.join(
@@ -53,8 +53,6 @@ defmodule BDS.Media.Sidecars do
translation_sidecar_path(media, translation.language) translation_sidecar_path(media, translation.language)
) )
:ok = File.mkdir_p(Path.dirname(path))
atomic_write( atomic_write(
path, path,
Sidecar.serialize_document([ Sidecar.serialize_document([
@@ -189,8 +187,7 @@ defmodule BDS.Media.Sidecars do
media -> media ->
project = Projects.get_project!(media.project_id) project = Projects.get_project!(media.project_id)
:ok = write_sidecar(project, media) write_sidecar(project, media)
:ok
end end
end end
@@ -224,8 +221,11 @@ defmodule BDS.Media.Sidecars do
%Translation{} = translation -> %Translation{} = translation ->
media = Repo.get!(Media, translation.translation_for) media = Repo.get!(Media, translation.translation_for)
project = Projects.get_project!(media.project_id) project = Projects.get_project!(media.project_id)
:ok = write_translation_sidecar(project, media, translation)
{:ok, translation} case write_translation_sidecar(project, media, translation) do
:ok -> {:ok, translation}
{:error, reason} -> {:error, reason}
end
end end
end end

View File

@@ -252,7 +252,7 @@ defmodule BDS.Posts.AutoTranslation do
end end
defp configured_languages(metadata) do defp configured_languages(metadata) do
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, [])) ([metadata.main_language] ++ metadata.blog_languages)
|> Enum.map(&normalize_language/1) |> Enum.map(&normalize_language/1)
|> Enum.reject(&(&1 in [nil, ""])) |> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq() |> Enum.uniq()

View File

@@ -312,7 +312,7 @@ defmodule BDS.Posts.TranslationValidation do
defp legacy_missing_entries(source_posts, translation_rows, metadata) do defp legacy_missing_entries(source_posts, translation_rows, metadata) do
configured_languages = configured_languages =
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, [])) ([metadata.main_language] ++ metadata.blog_languages)
|> Enum.map(&do_normalize_language/1) |> Enum.map(&do_normalize_language/1)
|> Enum.reject(&(&1 in [nil, ""])) |> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq() |> Enum.uniq()
@@ -444,7 +444,7 @@ defmodule BDS.Posts.TranslationValidation do
language = do_normalize_language(source_post.language) language = do_normalize_language(source_post.language)
if language == "" do if language == "" do
do_normalize_language(Map.get(metadata, :main_language)) do_normalize_language(metadata.main_language)
else else
language language
end end

View File

@@ -101,7 +101,7 @@ defmodule BDS.Preview do
with :ok <- ensure_running(state.current, project_id), with :ok <- ensure_running(state.current, project_id),
{:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do {:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
body = body =
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do case Rendering.render_post_page(project_id, payload.template_slug, payload) do
{:ok, rendered} -> rendered {:ok, rendered} -> rendered
{:error, _reason} -> render_draft(payload) {:error, _reason} -> render_draft(payload)
end end
@@ -178,7 +178,7 @@ defmodule BDS.Preview do
defp resolve_draft_request(project_id, post_id, query_params) do defp resolve_draft_request(project_id, post_id, query_params) do
with {:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do with {:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
body = body =
case Rendering.render_post_page(project_id, Map.get(payload, :template_slug), payload) do case Rendering.render_post_page(project_id, payload.template_slug, payload) do
{:ok, rendered} -> rendered {:ok, rendered} -> rendered
{:error, _reason} -> render_draft(payload) {:error, _reason} -> render_draft(payload)
end end

View File

@@ -28,8 +28,11 @@ defmodule BDS.PreviewAssets do
end) end)
|> Enum.filter(&File.regular?/1) |> Enum.filter(&File.regular?/1)
|> Enum.sort() |> Enum.sort()
|> Enum.map(fn path -> |> Enum.flat_map(fn path ->
{Path.relative_to(path, @preview_root), File.read!(path)} case File.read(path) do
{:ok, contents} -> [{Path.relative_to(path, @preview_root), contents}]
{:error, _reason} -> []
end
end) end)
end end

View File

@@ -46,6 +46,7 @@ defmodule BDS.Publishing do
{:reply, Repo.get(PublishJob, job_id), state} {:reply, Repo.get(PublishJob, job_id), state}
end end
@impl true
def handle_call({:update_job, job_id, attrs}, _from, state) do def handle_call({:update_job, job_id, attrs}, _from, state) do
with %PublishJob{} = job <- Repo.get(PublishJob, job_id) do with %PublishJob{} = job <- Repo.get(PublishJob, job_id) do
attrs = Map.put(attrs, :updated_at, Persistence.now_ms()) attrs = Map.put(attrs, :updated_at, Persistence.now_ms())
@@ -55,6 +56,7 @@ defmodule BDS.Publishing do
{:reply, :ok, state} {:reply, :ok, state}
end end
@impl true
def handle_call({:should_upload_scp_file, upload_key, local_mtime}, _from, state) do def handle_call({:should_upload_scp_file, upload_key, local_mtime}, _from, state) do
should_upload? = should_upload? =
case state.scp_uploads[upload_key] do case state.scp_uploads[upload_key] do
@@ -65,10 +67,12 @@ defmodule BDS.Publishing do
{:reply, should_upload?, state} {:reply, should_upload?, state}
end end
@impl true
def handle_call({:mark_uploaded_scp_file, upload_key, local_mtime}, _from, state) do def handle_call({:mark_uploaded_scp_file, upload_key, local_mtime}, _from, state) do
{:reply, :ok, put_in(state, [:scp_uploads, upload_key], local_mtime)} {:reply, :ok, put_in(state, [:scp_uploads, upload_key], local_mtime)}
end end
@impl true
def handle_call({:upload_site, project_id, credentials, targets, opts}, _from, state) do def handle_call({:upload_site, project_id, credentials, targets, opts}, _from, state) do
job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic])) job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic]))
uploader = build_uploader(Keyword.put_new(opts, :project_id, project_id)) uploader = build_uploader(Keyword.put_new(opts, :project_id, project_id))

View File

@@ -77,16 +77,15 @@ defmodule BDS.ReleasePackaging do
defp reset_output(metadata) do defp reset_output(metadata) do
File.rm_rf!(metadata.payload_root) File.rm_rf!(metadata.payload_root)
File.rm_rf!(metadata.archive_path) File.rm_rf!(metadata.archive_path)
File.mkdir_p!(metadata.output_dir) File.mkdir_p(metadata.output_dir)
:ok
end end
defp copy_release(source, destination) do defp copy_release(source, destination) do
File.mkdir_p!(Path.dirname(destination)) with :ok <- File.mkdir_p(Path.dirname(destination)) do
case File.cp_r(source, destination) do
case File.cp_r(source, destination) do {:ok, _files} -> :ok
{:ok, _files} -> :ok {:error, reason, _file} -> {:error, reason}
{:error, reason, _file} -> {:error, reason} end
end end
end end
@@ -102,8 +101,7 @@ defmodule BDS.ReleasePackaging do
} }
manifest_path = Path.join(metadata.payload_root, "manifest.json") manifest_path = Path.join(metadata.payload_root, "manifest.json")
File.write!(manifest_path, Jason.encode!(manifest, pretty: true)) File.write(manifest_path, Jason.encode!(manifest, pretty: true))
:ok
end end
defp create_archive(%Metadata{platform: :windows} = metadata) do defp create_archive(%Metadata{platform: :windows} = metadata) do

View File

@@ -135,30 +135,44 @@ defmodule BDS.Rendering.Filters do
"" ""
_id -> _id ->
template_source = Liquex.FileSystem.read_template_file(context.file_system, template_path) case BDS.Rendering.FileSystem.try_read(context.file_system, template_path) do
{:ok, template_source} ->
render_macro_source(template_path, template_source, assigns, context)
case Liquex.parse(template_source) do {:error, :enoent} ->
{:ok, template_ast} ->
isolated_context = Liquex.Context.new_isolated_subscope(context, assigns)
try do
{result, _context} = Liquex.render!(template_ast, isolated_context)
IO.iodata_to_binary(result)
rescue
e in Liquex.Error ->
require Logger
Logger.warning("Macro template render failed (#{template_path}): #{e.message}")
""
end
{:error, reason, line} ->
require Logger require Logger
Logger.warning("Macro template parse failed (#{template_path}): #{reason} at line #{line}") Logger.warning("Macro template not found: #{template_path}")
"" ""
end end
end end
end end
defp render_macro_source(template_path, template_source, assigns, context) do
with {:ok, template_ast} <- Liquex.parse(template_source),
{:ok, rendered} <- safe_liquex_render(template_ast, context, assigns) do
rendered
else
{:error, reason, line} ->
require Logger
Logger.warning("Macro template parse failed (#{template_path}): #{reason} at line #{line}")
""
{:error, message} ->
require Logger
Logger.warning("Macro template render failed (#{template_path}): #{message}")
""
end
end
defp safe_liquex_render(template_ast, context, assigns) do
isolated_context = Liquex.Context.new_isolated_subscope(context, assigns)
{result, _context} = Liquex.render!(template_ast, isolated_context)
{:ok, IO.iodata_to_binary(result)}
rescue
e in Liquex.Error -> {:error, e.message}
end
defp render_markdown_html(markdown) do defp render_markdown_html(markdown) do
case Earmark.as_html(markdown) do case Earmark.as_html(markdown) do
{:ok, html, _messages} -> html {:ok, html, _messages} -> html

View File

@@ -93,45 +93,45 @@ defmodule BDS.Rendering.TemplateSelection do
@spec render_template(String.t(), String.t(), map()) :: @spec render_template(String.t(), String.t(), map()) ::
{:ok, String.t()} | {:error, String.t()} {:ok, String.t()} | {:error, String.t()}
def render_template(project_id, source, assigns) do def render_template(project_id, source, assigns) do
with {:ok, template_ast} <- Liquex.parse(source) do with {:ok, template_ast} <- Liquex.parse(source),
project = Projects.get_project!(project_id) {:ok, _rendered} = ok <- safe_liquex_render(template_ast, project_id, assigns) do
ok
context =
Liquex.Context.new(assigns,
static_environment: assigns,
filter_module: Filters,
file_system: FileSystem.new(StarterTemplates.template_roots(project))
)
try do
{result, _context} = Liquex.render!(template_ast, context)
{:ok, IO.iodata_to_binary(result)}
rescue
e in Liquex.Error -> {:error, e.message}
end
else else
{:error, reason, line} -> {:error, "#{reason} at line #{line}"} {:error, reason, line} when is_integer(line) -> {:error, "#{reason} at line #{line}"}
{:error, _message} = error -> error
end end
end end
defp safe_liquex_render(template_ast, project_id, assigns) do
project = Projects.get_project!(project_id)
context =
Liquex.Context.new(assigns,
static_environment: assigns,
filter_module: Filters,
file_system: FileSystem.new(StarterTemplates.template_roots(project))
)
{result, _context} = Liquex.render!(template_ast, context)
{:ok, IO.iodata_to_binary(result)}
rescue
e in Liquex.Error -> {:error, e.message}
end
defp load_bundled_template_source(project, kind, slug) do defp load_bundled_template_source(project, kind, slug) do
desired_slug = bundled_template_slug(kind, slug) desired_slug = bundled_template_slug(kind, slug)
if is_binary(desired_slug) do with true <- is_binary(desired_slug),
file_system = project |> StarterTemplates.template_roots() |> FileSystem.new() file_system = project |> StarterTemplates.template_roots() |> FileSystem.new(),
source = Liquex.FileSystem.read_template_file(file_system, desired_slug) {:ok, source} <- FileSystem.try_read(file_system, desired_slug) do
case Frontmatter.parse_document(source) do case Frontmatter.parse_document(source) do
{:ok, %{body: body}} -> {:ok, body} {:ok, %{body: body}} -> {:ok, body}
{:error, :invalid_frontmatter} -> {:ok, source} {:error, :invalid_frontmatter} -> {:ok, source}
end end
else else
{:error, :template_not_found} false -> {:error, :template_not_found}
{:error, :enoent} -> {:error, :template_not_found}
end end
rescue
error in [Liquex.Error] ->
_ = error
{:error, :template_not_found}
end end
defp maybe_load_bundled_template_source(project, kind, slug, template, reason, error) defp maybe_load_bundled_template_source(project, kind, slug, template, reason, error)

View File

@@ -4,6 +4,8 @@ defmodule BDS.Templates do
including slug derivation, status transitions, and filesystem synchronization. including slug derivation, status transitions, and filesystem synchronization.
""" """
require Logger
import Ecto.Query import Ecto.Query
import BDS.MapUtils, only: [attr: 2, maybe_put: 3] import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
@@ -184,10 +186,18 @@ defmodule BDS.Templates do
templates = templates =
template_paths template_paths
|> Enum.with_index(1) |> Enum.with_index(1)
|> Enum.map(fn {path, index} -> |> Enum.flat_map(fn {path, index} ->
template = upsert_template_from_file(project_id, project, path) result = upsert_template_from_file(project_id, project, path)
:ok = report_rebuild_progress(on_progress, index, total_files, "template files") :ok = report_rebuild_progress(on_progress, index, total_files, "template files")
template
case result do
{:ok, template} ->
[template]
{:error, reason} ->
Logger.warning("Skipping template #{path}: #{inspect(reason)}")
[]
end
end) end)
remove_stale_published_templates(project_id, project, template_paths) remove_stale_published_templates(project_id, project, template_paths)
@@ -241,10 +251,9 @@ defmodule BDS.Templates do
project = Projects.get_project!(template.project_id) project = Projects.get_project!(template.project_id)
full_path = Path.join(Projects.project_data_dir(project), template.file_path) full_path = Path.join(Projects.project_data_dir(project), template.file_path)
if File.exists?(full_path) do case upsert_template_from_file(template.project_id, project, full_path) do
{:ok, upsert_template_from_file(template.project_id, project, full_path)} {:ok, _template} = ok -> ok
else {:error, _reason} -> {:error, :not_found}
{:error, :not_found}
end end
end end
end end
@@ -278,10 +287,9 @@ defmodule BDS.Templates do
project = Projects.get_project!(project_id) project = Projects.get_project!(project_id)
full_path = Path.join(Projects.project_data_dir(project), relative_path) full_path = Path.join(Projects.project_data_dir(project), relative_path)
if File.exists?(full_path) do case upsert_template_from_file(project_id, project, full_path) do
{:ok, upsert_template_from_file(project_id, project, full_path)} {:ok, _template} = ok -> ok
else {:error, _reason} -> {:error, :not_found}
{:error, :not_found}
end end
end end
@@ -448,13 +456,12 @@ defmodule BDS.Templates do
body = published_template_body(original_template) body = published_template_body(original_template)
new_full_path = full_file_path(updated_template.project_id, updated_template.file_path) new_full_path = full_file_path(updated_template.project_id, updated_template.file_path)
result = case Persistence.atomic_write(new_full_path, serialize_template_file(updated_template, body)) do
Persistence.atomic_write(new_full_path, serialize_template_file(updated_template, body)) :ok when original_template.file_path != updated_template.file_path ->
delete_file_if_present(original_template.project_id, original_template.file_path)
if result == :ok and original_template.file_path != updated_template.file_path do other ->
delete_file_if_present(original_template.project_id, original_template.file_path) other
else
result
end end
end end
@@ -494,31 +501,33 @@ defmodule BDS.Templates do
end end
defp upsert_template_from_file(project_id, project, path) do defp upsert_template_from_file(project_id, project, path) do
contents = File.read!(path)
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
relative_path = Path.relative_to(path, Projects.project_data_dir(project)) relative_path = Path.relative_to(path, Projects.project_data_dir(project))
now = Persistence.now_ms()
attrs = %{ with {:ok, contents} <- File.read(path),
id: DocumentFields.get(fields, "id") || Ecto.UUID.generate(), {:ok, %{fields: fields}} <- Frontmatter.parse_document(contents) do
project_id: project_id, now = Persistence.now_ms()
slug: DocumentFields.fetch!(fields, "slug"),
title: DocumentFields.get(fields, "title") || "",
kind: parse_template_kind(DocumentFields.fetch!(fields, "kind")),
enabled: Map.get(fields, "enabled", true),
version: Map.get(fields, "version", 1),
file_path: relative_path,
status: :published,
content: nil,
created_at: DocumentFields.get(fields, "createdAt", now),
updated_at: DocumentFields.get(fields, "updatedAt", now)
}
template = Repo.get_by(Template, project_id: project_id, slug: attrs.slug) || %Template{} attrs = %{
id: DocumentFields.get(fields, "id") || Ecto.UUID.generate(),
project_id: project_id,
slug: DocumentFields.fetch!(fields, "slug"),
title: DocumentFields.get(fields, "title") || "",
kind: parse_template_kind(DocumentFields.fetch!(fields, "kind")),
enabled: Map.get(fields, "enabled", true),
version: Map.get(fields, "version", 1),
file_path: relative_path,
status: :published,
content: nil,
created_at: DocumentFields.get(fields, "createdAt", now),
updated_at: DocumentFields.get(fields, "updatedAt", now)
}
template template = Repo.get_by(Template, project_id: project_id, slug: attrs.slug) || %Template{}
|> Template.changeset(attrs)
|> Repo.insert_or_update!() template
|> Template.changeset(attrs)
|> Repo.insert_or_update()
end
end end
defp remove_stale_published_templates(project_id, project, template_paths) do defp remove_stale_published_templates(project_id, project, template_paths) do

View File

@@ -821,11 +821,13 @@ defmodule BDS.UI.Sidebar do
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
defp build_post_section(title, status, posts, translation_counts, published_meta?) do defp build_post_section(title, status, posts, translation_counts, published_meta?) do
post_count = length(posts)
%{ %{
id: Atom.to_string(status), id: Atom.to_string(status),
title: title, title: title,
status: Atom.to_string(status), status: Atom.to_string(status),
count: length(posts), count: post_count,
items: items:
Enum.map(posts, fn post -> Enum.map(posts, fn post ->
%{ %{

View File

@@ -0,0 +1,9 @@
defmodule BDS.Repo.Migrations.AddChatConversationSurfaceState do
use Ecto.Migration
def change do
alter table(:chat_conversations) do
add :surface_state, :text
end
end
end

View File

@@ -1477,6 +1477,39 @@ defmodule BDS.AITest do
assert Enum.map(messages, & &1.role) == [:user] assert Enum.map(messages, & &1.role) == [:user]
end end
test "get_surface_state and put_surface_state persist and restore surface UI state" do
assert {:ok, conversation} = BDS.AI.start_chat(%{title: "Surface State", model: "gpt-4.1"})
surface_data = %{"msg-1-surface-0" => %{"query" => "hello"}}
surface_tabs = %{"msg-1-surface-1" => 2}
dismissed = MapSet.new(["msg-1-surface-0"])
assert {:ok, _state} =
BDS.AI.put_surface_state(
conversation.id,
surface_data,
surface_tabs,
dismissed
)
loaded = BDS.AI.get_surface_state(conversation.id)
assert loaded["surface_data"] == surface_data
assert loaded["surface_tabs"] == surface_tabs
assert MapSet.new(loaded["dismissed_surfaces"]) == dismissed
end
test "get_surface_state returns empty map for conversation without surface state" do
assert {:ok, conversation} = BDS.AI.start_chat(%{title: "No Surface State", model: "gpt-4.1"})
loaded = BDS.AI.get_surface_state(conversation.id)
assert loaded == %{}
end
test "get_surface_state returns empty map for unknown conversation" do
loaded = BDS.AI.get_surface_state("nonexistent-id")
assert loaded == %{}
end
defp create_project_fixture(name) do defp create_project_fixture(name) do
temp_dir = Path.join(System.tmp_dir!(), "bds-ai-#{System.unique_integer([:positive])}") temp_dir = Path.join(System.tmp_dir!(), "bds-ai-#{System.unique_integer([:positive])}")
on_exit(fn -> File.rm_rf(temp_dir) end) on_exit(fn -> File.rm_rf(temp_dir) end)

View File

@@ -0,0 +1,22 @@
defmodule BDS.CSM028BroadRescueTest do
use ExUnit.Case, async: true
describe "source-level: no broad rescue in render_macro_template" do
test "filters.ex has no rescue _error or rescue _ clauses" do
source = File.read!("lib/bds/rendering/filters.ex")
refute source =~ ~r/rescue\s+_\w*\s*->/, "Found broad rescue clause in filters.ex"
end
test "filters.ex does not call read_template_file (which raises)" do
source = File.read!("lib/bds/rendering/filters.ex")
refute source =~ "read_template_file",
"render_macro_template should use try_read, not read_template_file"
end
test "filters.ex uses FileSystem.try_read for macro templates" do
source = File.read!("lib/bds/rendering/filters.ex")
assert source =~ "FileSystem.try_read"
end
end
end

View File

@@ -0,0 +1,55 @@
defmodule BDS.CSM029LengthInGuardsTest do
use ExUnit.Case, async: true
@outputs_path "lib/bds/generation/outputs.ex"
@sidebar_path "lib/bds/ui/sidebar.ex"
describe "source-level: length/1 bound before use in loops" do
test "outputs.ex route path functions bind length before passing to paginated_archive_paths" do
source = File.read!(@outputs_path)
refute source =~ ~r/paginated_archive_paths\([^)]*length\(posts\)/,
"length(posts) should be bound to a variable before passing to paginated_archive_paths"
end
test "sidebar.ex build_post_section binds length before map literal" do
source = File.read!(@sidebar_path)
lines =
source
|> String.split("\n")
|> Enum.with_index(1)
build_section_lines =
Enum.filter(lines, fn {line, _} -> line =~ "defp build_post_section" end)
for {_line, line_no} <- build_section_lines do
context =
lines
|> Enum.drop(line_no - 1)
|> Enum.take(30)
|> Enum.map(fn {l, _} -> l end)
|> Enum.join("\n")
refute context =~ ~r/count: length\(/,
"length() should be pre-bound, not inline in map literal"
end
end
test "no length/1 calls remain inline inside Enum.flat_map callbacks in route path functions" do
source = File.read!(@outputs_path)
route_fns = ["category_route_paths", "tag_route_paths", "date_route_paths"]
for fn_name <- route_fns do
fn_match = Regex.run(~r/def #{fn_name}\(.*?\n end/s, source)
assert fn_match, "Expected to find #{fn_name} in outputs.ex"
[fn_body] = fn_match
refute fn_body =~ ~r/\blength\(posts\)(?!\s)/,
"#{fn_name} should use pre-bound post_count instead of inline length(posts)"
end
end
end
end

View File

@@ -0,0 +1,42 @@
defmodule BDS.CSM030UncheckedMkdirTest do
use ExUnit.Case, async: true
describe "source-level: no unchecked File.mkdir_p" do
test "sidecars.ex has no bare File.mkdir_p calls" do
source = File.read!("lib/bds/media/sidecars.ex")
refute source =~ ~r/:ok\s*=\s*File\.mkdir_p/
refute source =~ ~r/File\.mkdir_p!/
end
test "release_packaging.ex has no File.mkdir_p! calls" do
source = File.read!("lib/bds/release_packaging.ex")
refute source =~ ~r/File\.mkdir_p!/
end
test "thumbnails.ex mkdir_p is inside a with chain" do
source = File.read!("lib/bds/media/thumbnails.ex")
refute source =~ ~r/:ok\s*=\s*File\.mkdir_p/
refute source =~ ~r/File\.mkdir_p!/
end
end
describe "source-level: sidecar write errors are handled" do
test "media.ex does not assert :ok on write_sidecar" do
source = File.read!("lib/bds/media.ex")
refute source =~ ~r/:ok\s*=\s*write_sidecar/
refute source =~ ~r/:ok\s*=\s*write_translation_sidecar/
end
test "linking.ex does not assert :ok on write_sidecar" do
source = File.read!("lib/bds/media/linking.ex")
refute source =~ ~r/:ok\s*=\s*Sidecars\.write_sidecar/
end
end
describe "source-level: release_packaging.ex write_manifest" do
test "uses File.write not File.write!" do
source = File.read!("lib/bds/release_packaging.ex")
refute source =~ ~r/File\.write!/
end
end
end

View File

@@ -0,0 +1,68 @@
defmodule BDS.CSM031TryRescueTest do
use ExUnit.Case, async: true
describe "source-level: no inline try/rescue around Liquex.render!" do
test "filters.ex has no try/rescue block in render_macro_source" do
source = File.read!("lib/bds/rendering/filters.ex")
refute source =~ ~r/try do\s+.*Liquex\.render!/s,
"render_macro_source should use safe_liquex_render helper, not inline try/rescue"
end
test "filters.ex isolates Liquex.render! rescue in safe_liquex_render" do
source = File.read!("lib/bds/rendering/filters.ex")
assert source =~ "defp safe_liquex_render"
end
test "template_selection.ex has no try/rescue block in render_template" do
source = File.read!("lib/bds/rendering/template_selection.ex")
lines = String.split(source, "\n")
in_render_template =
lines
|> Enum.drop_while(&(not String.contains?(&1, "def render_template(")))
|> Enum.take_while(&(not String.match?(&1, ~r/^\s+def[p]?\s/)))
body = Enum.join(in_render_template, "\n")
refute body =~ "try do", "render_template should not contain inline try/rescue"
end
test "template_selection.ex isolates Liquex.render! rescue in safe_liquex_render" do
source = File.read!("lib/bds/rendering/template_selection.ex")
assert source =~ "defp safe_liquex_render"
end
end
describe "source-level: no function-level rescue in load_bundled_template_source" do
test "template_selection.ex load_bundled_template_source has no rescue block" do
source = File.read!("lib/bds/rendering/template_selection.ex")
lines = String.split(source, "\n")
in_load_bundled =
lines
|> Enum.drop_while(&(not String.contains?(&1, "defp load_bundled_template_source(")))
|> Enum.take_while(fn line ->
not (String.match?(line, ~r/^\s+def[p]?\s/) and
not String.contains?(line, "load_bundled_template_source"))
end)
body = Enum.join(in_load_bundled, "\n")
refute body =~ ~r/^\s+rescue\b/m, "load_bundled_template_source should use with, not rescue"
end
test "template_selection.ex uses FileSystem.try_read instead of read_template_file" do
source = File.read!("lib/bds/rendering/template_selection.ex")
refute source =~ "read_template_file",
"should use FileSystem.try_read, not the raising read_template_file"
assert source =~ "FileSystem.try_read"
end
end
describe "source-level: shell_data.ex has no try/rescue" do
test "shell_data.ex contains no try/rescue blocks" do
source = File.read!("lib/bds/desktop/shell_data.ex")
refute source =~ ~r/\btry\s+do\b/, "shell_data.ex should not contain try/rescue blocks"
refute source =~ ~r/\brescue\b/, "shell_data.ex should not contain rescue clauses"
end
end
end

View File

@@ -0,0 +1,98 @@
defmodule BDS.CSM032MapGetPatternMatchTest do
use ExUnit.Case, async: true
@metadata_consumers [
"lib/bds/posts/translation_validation.ex",
"lib/bds/posts/auto_translation.ex",
"lib/bds/desktop/shell_commands.ex",
"lib/bds/desktop/shell_live/settings_editor/project_settings.ex",
"lib/bds/desktop/shell_live/settings_editor/style_editor.ex",
"lib/bds/desktop/shell_live/settings_editor/managed_categories.ex",
"lib/bds/desktop/shell_live/settings_editor/publishing_settings.ex",
"lib/bds/desktop/shell_live/import_editor/taxonomy_editing.ex",
"lib/bds/desktop/shell_live/import_editor/analysis_state.ex",
"lib/bds/import_execution.ex"
]
@metadata_atom_keys ~w(main_language blog_languages categories category_settings
publishing_preferences name description public_url default_author
max_posts_per_page blogmark_category pico_theme
semantic_similarity_enabled)
describe "source-level: no Map.get(metadata, :atom_key) on metadata struct" do
for file <- @metadata_consumers do
test "#{Path.basename(file)} uses dot access instead of Map.get for metadata atom keys" do
source = File.read!(unquote(file))
for key <- @metadata_atom_keys do
refute source =~ "Map.get(metadata, :#{key}",
"#{unquote(file)} should use metadata.#{key} instead of Map.get(metadata, :#{key})"
end
end
end
end
describe "source-level: overlay.ex uses pattern matching for known structures" do
test "delete_details uses pattern matching instead of Map.get" do
source = File.read!("lib/bds/desktop/overlay.ex")
refute source =~ "Map.get(delete_details,",
"overlay.ex should pattern match delete_details instead of using Map.get"
end
test "merge_details uses pattern matching instead of Map.get" do
source = File.read!("lib/bds/desktop/overlay.ex")
refute source =~ "Map.get(merge,",
"overlay.ex should pattern match merge_details instead of using Map.get"
end
test "media struct fields use dot access in to_insert_media_result" do
source = File.read!("lib/bds/desktop/overlay.ex")
refute source =~ ~r/to_insert_media_result.*Map\.get\(media/s &&
source =~ "defp to_insert_media_result",
"to_insert_media_result should use dot access for media fields"
end
test "media struct fields use dot access in to_gallery_image" do
source = File.read!("lib/bds/desktop/overlay.ex")
refute source =~ ~r/to_gallery_image.*Map\.get\(media/s &&
source =~ "defp to_gallery_image",
"to_gallery_image should use dot access for media fields"
end
end
describe "source-level: generation pipeline uses dot access for Post struct fields" do
test "outputs.ex uses post.language instead of Map.get(post, :language)" do
source = File.read!("lib/bds/generation/outputs.ex")
refute source =~ "Map.get(post, :language)",
"outputs.ex should use post.language"
end
test "data.ex uses dot access for Post struct fields in build_published_translation_variant" do
source = File.read!("lib/bds/generation/data.ex")
refute source =~ "Map.get(post, :author)",
"data.ex should use post.author"
refute source =~ "Map.get(post, :tags",
"data.ex should use post.tags"
refute source =~ "Map.get(post, :categories",
"data.ex should use post.categories"
refute source =~ "Map.get(post, :template_slug)",
"data.ex should use post.template_slug"
refute source =~ "Map.get(post, :do_not_translate",
"data.ex should use post.do_not_translate"
end
test "validation.ex uses post.file_path instead of Map.get(post, :file_path)" do
source = File.read!("lib/bds/generation/validation.ex")
refute source =~ "Map.get(post, :file_path)",
"validation.ex should use post.file_path"
end
test "sitemap.ex uses post.do_not_translate instead of Map.get" do
source = File.read!("lib/bds/generation/sitemap.ex")
refute source =~ "Map.get(post, :do_not_translate)",
"sitemap.ex should use post.do_not_translate"
end
end
end

View File

@@ -0,0 +1,206 @@
defmodule BDS.CSM033BatchInsertsTest do
use ExUnit.Case, async: false
import Ecto.Query
describe "source-level: embeddings.ex uses batch inserts instead of Enum.each + individual writes" do
setup do
source = File.read!("lib/bds/embeddings.ex")
%{source: source}
end
test "no Enum.each calling sync_post_if_enabled in bulk paths", %{source: source} do
refute source =~ "Enum.each(posts, &sync_post_if_enabled",
"bulk paths should not use Enum.each with sync_post_if_enabled"
refute source =~ ~r/Enum\.each\(fn \{post, index\} ->\n\s+sync_post_if_enabled/,
"bulk paths should not use Enum.each with sync_post_if_enabled"
end
test "bulk functions use batch_upsert_keys", %{source: source} do
assert source =~ "batch_upsert_keys(rows)",
"expected batch_upsert_keys to be called with collected rows"
end
test "bulk functions preload keys before the loop", %{source: source} do
assert source =~ "preload_keys_by_post_id(project_id)",
"expected keys to be preloaded in a single query"
end
test "batch_upsert_keys uses multi-row INSERT with ON CONFLICT upsert", %{source: source} do
assert source =~ "INSERT INTO embedding_keys",
"expected raw SQL batch INSERT for embedding keys"
assert source =~ "ON CONFLICT(label) DO UPDATE",
"expected ON CONFLICT upsert clause"
end
test "compute_key_data is used instead of individual Repo.insert_or_update", %{source: source} do
assert source =~ "compute_key_data(post, existing_key, next_label)",
"expected compute_key_data helper for row computation"
end
end
describe "source-level: search.ex already uses batch inserts" do
test "batch_insert_post_index uses multi-row VALUES" do
source = File.read!("lib/bds/search.ex")
assert source =~ "batch_insert_post_index"
assert source =~ ~r/INSERT INTO posts_fts.*VALUES.*\#\{placeholders\}/s
end
test "batch_insert_media_index uses multi-row VALUES" do
source = File.read!("lib/bds/search.ex")
assert source =~ "batch_insert_media_index"
assert source =~ ~r/INSERT INTO media_fts.*VALUES.*\#\{placeholders\}/s
end
end
describe "functional: batch operations produce correct results" do
defmodule FakeBackend do
@behaviour BDS.Embeddings.Backend
@impl true
def model_info, do: %{model_id: "fake/test-model", dimensions: 384}
@impl true
def embed(text, opts), do: BDS.Embeddings.Backends.InApp.embed(text, opts)
end
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir =
Path.join(System.tmp_dir!(), "bds-csm033-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "CSM033", data_path: temp_dir})
previous_config = Application.get_env(:bds, :embeddings)
Application.put_env(:bds, :embeddings, backend: FakeBackend)
on_exit(fn ->
if previous_config == nil do
Application.delete_env(:bds, :embeddings)
else
Application.put_env(:bds, :embeddings, previous_config)
end
end)
assert {:ok, _metadata} =
BDS.Metadata.update_project_metadata(project.id, %{
semantic_similarity_enabled: true
})
%{project: project}
end
test "index_unindexed batch-inserts keys for multiple posts", %{project: project} do
posts =
for i <- 1..5 do
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Post #{i}",
content: "content for post number #{i} with unique words #{:rand.uniform(10000)}",
language: "en"
})
post
end
{:ok, indexed} = BDS.Embeddings.index_unindexed(project.id)
assert length(indexed) == 5
assert Enum.all?(posts, fn post -> post.id in indexed end)
keys =
BDS.Repo.all(
from(k in BDS.Embeddings.Key, where: k.project_id == ^project.id)
)
assert length(keys) == 5
labels = Enum.map(keys, & &1.label) |> Enum.sort()
assert labels == Enum.to_list(1..5)
end
test "rebuild_project updates stale keys via batch upsert", %{project: project} do
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Rebuild Target",
content: "original content for rebuild test",
language: "en"
})
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
original_key =
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
{:ok, _post} = BDS.Posts.update_post(post.id, %{content: "completely different content now"})
{:ok, rebuilt_ids} = BDS.Embeddings.rebuild_project(project.id)
assert post.id in rebuilt_ids
updated_key =
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
assert updated_key.label == original_key.label
assert updated_key.content_hash != original_key.content_hash
end
test "repair_posts batch-upserts for specified posts only", %{project: project} do
{:ok, post_a} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Repair A",
content: "content A",
language: "en"
})
{:ok, _post_b} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Repair B",
content: "content B",
language: "en"
})
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
{:ok, repaired} = BDS.Embeddings.repair_posts(project.id, [post_a.id])
assert repaired == [post_a.id]
keys =
BDS.Repo.all(
from(k in BDS.Embeddings.Key, where: k.project_id == ^project.id)
)
assert length(keys) == 2
end
test "index_unindexed skips posts with matching content hash", %{project: project} do
{:ok, post} =
BDS.Posts.create_post(%{
project_id: project.id,
title: "Skip Test",
content: "unchanged content for skip test",
language: "en"
})
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
key_before =
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
{:ok, _indexed} = BDS.Embeddings.index_unindexed(project.id)
key_after =
BDS.Repo.get_by!(BDS.Embeddings.Key, project_id: project.id, post_id: post.id)
assert key_before.label == key_after.label
assert key_before.content_hash == key_after.content_hash
assert key_before.vector == key_after.vector
end
end
end

View File

@@ -0,0 +1,107 @@
defmodule BDS.CSM034FileReadBangTest do
use ExUnit.Case, async: false
import Ecto.Query
describe "source-level: no File.read! or File.write! in affected files" do
test "preview_assets.ex has no File.read!" do
source = File.read!("lib/bds/preview_assets.ex")
refute source =~ "File.read!", "preview_assets.ex should use File.read, not File.read!"
end
test "templates.ex has no File.read!" do
source = File.read!("lib/bds/templates.ex")
refute source =~ "File.read!", "templates.ex should use File.read, not File.read!"
end
test "templates.ex has no File.write!" do
source = File.read!("lib/bds/templates.ex")
refute source =~ "File.write!", "templates.ex should use File.write, not File.write!"
end
test "release_packaging.ex has no File.read! or File.write!" do
source = File.read!("lib/bds/release_packaging.ex")
refute source =~ "File.read!", "release_packaging.ex should use File.read, not File.read!"
refute source =~ "File.write!", "release_packaging.ex should use File.write, not File.write!"
end
end
describe "preview_assets.generated_outputs/0 handles read errors" do
test "returns results for readable files, skips unreadable ones" do
outputs = BDS.PreviewAssets.generated_outputs()
assert is_list(outputs)
assert Enum.all?(outputs, fn {path, body} -> is_binary(path) and is_binary(body) end)
end
end
describe "templates file error handling" do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
temp_dir = Path.join(System.tmp_dir!(), "bds-csm034-#{System.unique_integer([:positive])}")
File.mkdir_p!(temp_dir)
on_exit(fn -> File.rm_rf(temp_dir) end)
{:ok, project} = BDS.Projects.create_project(%{name: "CSM034", data_path: temp_dir})
%{project: project, temp_dir: temp_dir}
end
test "rebuild skips unreadable template files without crashing", %{
project: project,
temp_dir: temp_dir
} do
templates_dir = Path.join(temp_dir, "templates")
File.mkdir_p!(templates_dir)
File.write!(Path.join(templates_dir, "good.liquid"), """
---
slug: good
kind: post
title: Good Template
---
<p>{{ content }}</p>
""")
File.write!(Path.join(templates_dir, "bad.liquid"), "no frontmatter here")
assert {:ok, templates} = BDS.Templates.rebuild_templates_from_files(project.id)
assert length(templates) == 1
assert hd(templates).slug == "good"
end
test "sync_template_from_file returns error when file is deleted", %{
project: project,
temp_dir: temp_dir
} do
templates_dir = Path.join(temp_dir, "templates")
File.mkdir_p!(templates_dir)
path = Path.join(templates_dir, "ephemeral.liquid")
File.write!(path, """
---
slug: ephemeral
kind: post
title: Ephemeral
---
<p>{{ content }}</p>
""")
{:ok, _templates} = BDS.Templates.rebuild_templates_from_files(project.id)
template =
BDS.Repo.one(
from(t in BDS.Templates.Template,
where: t.project_id == ^project.id and t.slug == "ephemeral"
)
)
File.rm!(path)
assert {:error, :not_found} = BDS.Templates.sync_template_from_file(template.id)
end
test "import_orphan_template_file returns error for missing file", %{project: project} do
assert {:error, :not_found} =
BDS.Templates.import_orphan_template_file(project.id, "templates/ghost.liquid")
end
end
end

View File

@@ -0,0 +1,86 @@
defmodule BDS.CSM035ProcessDictTest do
use ExUnit.Case, async: true
alias BDS.Desktop.UILocale
describe "source-level: no raw Process.put/get for :bds_ui_locale outside ui_locale.ex" do
test "no direct Process.put(:bds_ui_locale, ...) outside ui_locale.ex" do
elixir_files =
Path.wildcard("lib/**/*.ex")
|> Enum.reject(&(&1 == "lib/bds/desktop/ui_locale.ex"))
violations =
Enum.filter(elixir_files, fn path ->
source = File.read!(path)
source =~ "Process.put(:bds_ui_locale" or source =~ "Process.put(@key"
end)
assert violations == [],
"Raw Process.put(:bds_ui_locale) found outside ui_locale.ex: #{inspect(violations)}"
end
test "no direct Process.get(:bds_ui_locale, ...) outside ui_locale.ex" do
elixir_files =
Path.wildcard("lib/**/*.ex")
|> Enum.reject(&(&1 == "lib/bds/desktop/ui_locale.ex"))
violations =
Enum.filter(elixir_files, fn path ->
source = File.read!(path)
source =~ "Process.get(:bds_ui_locale" or source =~ "Process.delete(:bds_ui_locale"
end)
assert violations == [],
"Raw Process.get/delete(:bds_ui_locale) found outside ui_locale.ex: #{inspect(violations)}"
end
end
describe "source-level: render boundaries call UILocale.put before template evaluation" do
test "ShellLive.render/1 calls UILocale.put before index(assigns)" do
source = File.read!("lib/bds/desktop/shell_live.ex")
render_match = Regex.run(~r/def render\(assigns\).*?\n(.*?)\n(.*?)\n/s, source)
assert render_match, "could not find render/1 in shell_live.ex"
[_, first_line | _] = render_match
assert first_line =~ "UILocale.put", "render/1 must call UILocale.put on its first line"
end
test "sidebar_content/1 calls UILocale.put before HEEx" do
source = File.read!("lib/bds/desktop/shell_live/sidebar_components.ex")
match = Regex.run(~r/def sidebar_content\(assigns\).*?\n(.*?)\n/s, source)
assert match, "could not find sidebar_content/1"
[_, first_line | _] = match
assert first_line =~ "UILocale.put", "sidebar_content/1 must call UILocale.put on its first line"
end
test "MenuBar.mount/1 calls UILocale.put" do
source = File.read!("lib/bds/desktop/menu_bar.ex")
match = Regex.run(~r/def mount\(menu\).*?\n(.*?)\n/s, source)
assert match, "could not find mount/1 in menu_bar.ex"
[_, first_line | _] = match
assert first_line =~ "UILocale.put", "MenuBar.mount/1 must call UILocale.put on its first line"
end
end
describe "UILocale functional behavior" do
test "put/1 sets locale readable by current/0" do
UILocale.put("de")
assert UILocale.current() == "de"
end
test "with_locale/2 restores previous locale after block" do
UILocale.put("en")
UILocale.with_locale("fr", fn -> assert UILocale.current() == "fr" end)
assert UILocale.current() == "en"
end
test "with_locale/2 restores nil when no prior locale was set" do
Process.delete(:bds_ui_locale)
UILocale.with_locale("it", fn -> assert UILocale.current() == "it" end)
assert UILocale.current() == nil
end
test "put/1 returns :ok" do
assert UILocale.put("en") == :ok
end
end
end

View File

@@ -0,0 +1,45 @@
defmodule BDS.CSM036ImplTrueTest do
use ExUnit.Case, async: true
@publishing_source File.read!("lib/bds/publishing.ex")
describe "CSM-036: @impl true on GenServer callbacks" do
test "every handle_call clause has @impl true" do
lines = String.split(@publishing_source, "\n")
handle_call_lines =
lines
|> Enum.with_index(1)
|> Enum.filter(fn {line, _idx} ->
String.contains?(line, "def handle_call(")
end)
assert length(handle_call_lines) >= 5, "expected at least 5 handle_call clauses"
for {_line, idx} <- handle_call_lines do
preceding = Enum.at(lines, idx - 2)
assert String.contains?(preceding, "@impl true"),
"handle_call at line #{idx} missing @impl true (preceding line: #{inspect(preceding)})"
end
end
test "no handle_cast, handle_info, or terminate without @impl true" do
lines = String.split(@publishing_source, "\n")
callback_lines =
lines
|> Enum.with_index(1)
|> Enum.filter(fn {line, _idx} ->
Regex.match?(~r/^\s+def (handle_cast|handle_info|terminate)\(/, line)
end)
for {_line, idx} <- callback_lines do
preceding = Enum.at(lines, idx - 2)
assert String.contains?(preceding, "@impl true"),
"callback at line #{idx} missing @impl true"
end
end
end
end

View File

@@ -206,11 +206,17 @@ defmodule BDS.Desktop.OverlayTest do
} }
], ],
delete_details: %{ delete_details: %{
title: "Delete Media",
entity_name: "Street Scene", entity_name: "Street Scene",
entity_type: "media", entity_type: "media",
reference_list: ["Photo Walk", "Trip Notes"] reference_list: ["Photo Walk", "Trip Notes"]
}, },
merge_details: %{target: "travel", count: 3} merge_details: %{
target: "travel",
count: 3,
title: "Merge 3 tags into travel?",
message: "Cannot be undone."
}
} }
end end
end end

View File

@@ -1,9 +0,0 @@
defmodule BDS.Desktop.ShellLive.ChatEditorTest do
use ExUnit.Case, async: false
test "ChatEditor exports LiveComponent callbacks" do
assert function_exported?(BDS.Desktop.ShellLive.ChatEditor, :update, 2)
assert function_exported?(BDS.Desktop.ShellLive.ChatEditor, :handle_event, 3)
assert function_exported?(BDS.Desktop.ShellLive.ChatEditor, :render, 1)
end
end

View File

@@ -1,11 +0,0 @@
defmodule BDS.Desktop.ShellLive.ImportEditorTest do
use ExUnit.Case, async: false
test "ImportEditor exports LiveComponent callbacks" do
module = BDS.Desktop.ShellLive.ImportEditor
assert Code.ensure_loaded?(module)
assert function_exported?(module, :update, 2)
assert function_exported?(module, :handle_event, 3)
assert function_exported?(module, :render, 1)
end
end

View File

@@ -443,15 +443,27 @@ defmodule BDS.Desktop.ShellLiveTest do
def get(_url, _headers), do: {:error, :not_found} def get(_url, _headers), do: {:error, :not_found}
end end
defmodule DelayedChatServer do defmodule GatedChatServer do
@moduledoc false
use Plug.Router use Plug.Router
import Phoenix.ConnTest, except: [post: 2] import Phoenix.ConnTest, except: [post: 2]
plug(:match) plug(:match)
plug(:dispatch) plug(:dispatch)
def hold_gate do
case GenServer.whereis(__MODULE__) do
nil -> :ok
pid -> Agent.stop(pid)
end
Agent.start_link(fn -> :hold end, name: __MODULE__)
end
def release_gate, do: Agent.update(__MODULE__, fn _ -> :release end)
post "/v1/chat/completions" do post "/v1/chat/completions" do
Process.sleep(300) wait_for_release()
body = body =
Jason.encode!(%{ Jason.encode!(%{
@@ -467,6 +479,19 @@ defmodule BDS.Desktop.ShellLiveTest do
match _ do match _ do
send_resp(conn, 404, "not found") send_resp(conn, 404, "not found")
end end
defp wait_for_release do
case GenServer.whereis(__MODULE__) do
nil ->
Process.sleep(:infinity)
_pid ->
case Agent.get(__MODULE__, & &1) do
:release -> :ok
:hold -> Process.sleep(50) && wait_for_release()
end
end
end
end end
defmodule TitleChatServer do defmodule TitleChatServer do
@@ -3915,9 +3940,10 @@ defmodule BDS.Desktop.ShellLiveTest do
test "chat editor keeps previous surfaces visible while a new update surface streams" do test "chat editor keeps previous surfaces visible while a new update surface streams" do
assert :ok = AI.set_airplane_mode(false) assert :ok = AI.set_airplane_mode(false)
{:ok, _} = GatedChatServer.hold_gate()
server = server =
start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false}) start_supervised!({Bandit, plug: GatedChatServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server) {:ok, {_address, port}} = ThousandIsland.listener_info(server)
@@ -4000,8 +4026,6 @@ defmodule BDS.Desktop.ShellLiveTest do
view view
|> element("[data-testid='chat-abort-button']") |> element("[data-testid='chat-abort-button']")
|> render_click() |> render_click()
Process.sleep(350)
end end
test "chat editor hook reopens server-expanded A2UI surfaces after patches" do test "chat editor hook reopens server-expanded A2UI surfaces after patches" do
@@ -4019,6 +4043,87 @@ defmodule BDS.Desktop.ShellLiveTest do
assert live_js =~ "this.syncExpandedSurfaces();" assert live_js =~ "this.syncExpandedSurfaces();"
end end
test "chat editor restores dismissed surfaces from persisted surface state when reopening a chat" do
assert {:ok, conversation} = AI.start_chat(%{title: "Reopen Chat", model: "gpt-4.1"})
now = Persistence.now_ms()
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :user,
content: "Show me two cards",
created_at: now
})
)
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :assistant,
content: "Here are two cards.",
tool_calls:
Jason.encode!([
%{
"id" => "call-card-a",
"name" => "render_card",
"arguments" => %{
"title" => "UniqueTitleAlpha",
"body" => "First card alpha"
}
},
%{
"id" => "call-card-b",
"name" => "render_card",
"arguments" => %{
"title" => "UniqueTitleBeta",
"body" => "Second card beta"
}
}
]),
created_at: now + 1
})
)
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "chat",
"id" => conversation.id,
"title" => conversation.title,
"subtitle" => conversation.model || "chat"
})
assert length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 2
surface_id_a = Regex.run(~r/id="([^"]+-surface-0)"/, html) |> Enum.at(1)
dismissed_html =
view
|> element("button[phx-value-surface-id='#{surface_id_a}']")
|> render_click()
assert length(:binary.matches(dismissed_html, ~s(data-testid="chat-inline-surface"))) == 1
persisted = AI.get_surface_state(conversation.id)
assert MapSet.new(persisted["dismissed_surfaces"]) == MapSet.new([surface_id_a])
{:ok, view2, _html2} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html2 =
render_click(view2, "pin_sidebar_item", %{
"route" => "chat",
"id" => conversation.id,
"title" => conversation.title,
"subtitle" => conversation.model || "chat"
})
assert length(:binary.matches(html2, ~s(data-testid="chat-inline-surface"))) == 1
assert html2 =~ "UniqueTitleBeta"
refute html2 =~ ~r/id="#{Regex.escape(surface_id_a)}"/
end
test "chat editor folds tool-only assistant steps into the final assistant answer" do test "chat editor folds tool-only assistant steps into the final assistant answer" do
assert {:ok, conversation} = AI.start_chat(%{title: "Tool Chat", model: "gpt-4.1"}) assert {:ok, conversation} = AI.start_chat(%{title: "Tool Chat", model: "gpt-4.1"})
@@ -4356,9 +4461,10 @@ defmodule BDS.Desktop.ShellLiveTest do
test "chat editor shows in-flight stop state and can abort a running turn" do test "chat editor shows in-flight stop state and can abort a running turn" do
assert :ok = AI.set_airplane_mode(false) assert :ok = AI.set_airplane_mode(false)
{:ok, _} = GatedChatServer.hold_gate()
server = server =
start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false}) start_supervised!({Bandit, plug: GatedChatServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server) {:ok, {_address, port}} = ThousandIsland.listener_info(server)
@@ -4401,15 +4507,17 @@ defmodule BDS.Desktop.ShellLiveTest do
refute html =~ ~s(data-testid="chat-abort-button") refute html =~ ~s(data-testid="chat-abort-button")
Process.sleep(350) GatedChatServer.release_gate()
Process.sleep(100)
refute render(view) =~ "Delayed response" refute render(view) =~ "Delayed response"
end end
test "chat editor keeps the in-flight user turn visible and disables input while streaming" do test "chat editor keeps the in-flight user turn visible and disables input while streaming" do
assert :ok = AI.set_airplane_mode(false) assert :ok = AI.set_airplane_mode(false)
{:ok, _} = GatedChatServer.hold_gate()
server = server =
start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false}) start_supervised!({Bandit, plug: GatedChatServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server) {:ok, {_address, port}} = ThousandIsland.listener_info(server)
@@ -4483,15 +4591,17 @@ defmodule BDS.Desktop.ShellLiveTest do
refute html =~ ~s(data-testid="chat-abort-button") refute html =~ ~s(data-testid="chat-abort-button")
Process.sleep(350) GatedChatServer.release_gate()
Process.sleep(100)
refute render(view) =~ "Delayed response" refute render(view) =~ "Delayed response"
end end
test "chat editor does not duplicate persisted turn artifacts while the request is still active" do test "chat editor does not duplicate persisted turn artifacts while the request is still active" do
assert :ok = AI.set_airplane_mode(false) assert :ok = AI.set_airplane_mode(false)
{:ok, _} = GatedChatServer.hold_gate()
server = server =
start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false}) start_supervised!({Bandit, plug: GatedChatServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server) {:ok, {_address, port}} = ThousandIsland.listener_info(server)
@@ -4575,8 +4685,6 @@ defmodule BDS.Desktop.ShellLiveTest do
view view
|> element("[data-testid='chat-abort-button']") |> element("[data-testid='chat-abort-button']")
|> render_click() |> render_click()
Process.sleep(350)
end end
test "translation validation route renders dedicated cards and fix controls", %{ test "translation validation route renders dedicated cards and fix controls", %{