Compare commits
16 Commits
71fb99af16
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7a4a9512c | |||
| 141c2bfc89 | |||
| a5ac74db91 | |||
| beca4d992f | |||
| 9e6d93a4b3 | |||
| e29dfb490a | |||
| f2b340ba86 | |||
| d18e0ef7f2 | |||
| 2d796cee83 | |||
| b052d59376 | |||
| 4a089b0856 | |||
| 2632649cdc | |||
| 782511d523 | |||
| 1cb59d7a78 | |||
| 9844f3555a | |||
| 99dc1c2216 |
@@ -7,7 +7,8 @@
|
||||
"Bash(mix ecto.migrate)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(git -C /Users/gb/Projects/bDS2 status)"
|
||||
"Bash(git -C /Users/gb/Projects/bDS2 status)",
|
||||
"Bash(git status *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
509
CODESMELL.md
509
CODESMELL.md
@@ -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
191
SPECAUDIT.md
Normal 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
192
SPECGAPS.md
Normal 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
87
TESTAUDIT.md
Normal 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.
|
||||
@@ -186,4 +186,12 @@ defmodule BDS.AI do
|
||||
|
||||
@spec cancel_chat(String.t()) :: :ok
|
||||
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
|
||||
|
||||
@@ -62,6 +62,42 @@ defmodule BDS.AI.Chat do
|
||||
Repo.get(ChatConversation, conversation_id)
|
||||
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()}
|
||||
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
||||
case Repo.get(ChatConversation, conversation_id) do
|
||||
|
||||
@@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do
|
||||
title: String.t() | nil,
|
||||
model: String.t() | nil,
|
||||
copilot_session_id: String.t() | nil,
|
||||
surface_state: map() | nil,
|
||||
created_at: integer() | nil,
|
||||
updated_at: integer() | nil
|
||||
}
|
||||
@@ -19,13 +20,14 @@ defmodule BDS.AI.ChatConversation do
|
||||
field :title, :string
|
||||
field :model, :string
|
||||
field :copilot_session_id, :string
|
||||
field :surface_state, :map
|
||||
field :created_at, :integer
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
def changeset(conversation, attrs) do
|
||||
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]
|
||||
)
|
||||
|> validate_required([:id, :title, :created_at, :updated_at])
|
||||
|
||||
@@ -48,29 +48,32 @@ defmodule BDS.Desktop.Overlay do
|
||||
end
|
||||
|
||||
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,
|
||||
title: Map.get(delete_details, :title, "Delete"),
|
||||
entity_name: Map.get(delete_details, :entity_name, ""),
|
||||
entity_type: Map.get(delete_details, :entity_type, "media"),
|
||||
reference_count: length(Map.get(delete_details, :reference_list, [])),
|
||||
reference_list: Map.get(delete_details, :reference_list, [])
|
||||
title: title,
|
||||
entity_name: entity_name,
|
||||
entity_type: entity_type,
|
||||
reference_count: length(reference_list),
|
||||
reference_list: reference_list
|
||||
}
|
||||
end
|
||||
|
||||
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
|
||||
|
||||
def open(:tags, :confirm_merge, context) do
|
||||
merge = Map.get(context, :merge_details, %{})
|
||||
target = Map.get(merge, :target, "")
|
||||
count = Map.get(merge, :count, 0)
|
||||
%{title: title, message: message} = context.merge_details
|
||||
|
||||
%{
|
||||
kind: :confirm_dialog,
|
||||
title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"),
|
||||
message: Map.get(merge, :message, "Cannot be undone.")
|
||||
title: title,
|
||||
message: message
|
||||
}
|
||||
end
|
||||
|
||||
@@ -115,8 +118,8 @@ defmodule BDS.Desktop.Overlay do
|
||||
|> Map.get(:all_media, [])
|
||||
|> Enum.filter(fn media ->
|
||||
normalized == "" or
|
||||
search_matches?(Map.get(media, :title, ""), normalized) or
|
||||
search_matches?(Map.get(media, :original_name, ""), normalized)
|
||||
search_matches?(media.title, normalized) or
|
||||
search_matches?(media.original_name, normalized)
|
||||
end)
|
||||
|> 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
|
||||
|
||||
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 =
|
||||
context
|
||||
|> Map.get(:blog_languages, [])
|
||||
|> Enum.uniq()
|
||||
|> Enum.reject(&(&1 == source_language))
|
||||
|> Enum.map(fn code ->
|
||||
existing_status = Map.get(Map.get(context, :existing_translations, %{}), code)
|
||||
existing_status = Map.get(existing_translations, code)
|
||||
|
||||
%{
|
||||
code: code,
|
||||
name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)),
|
||||
flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code),
|
||||
name: Map.get(language_names, code, String.upcase(code)),
|
||||
flag_emoji: Map.get(language_flags, code, code),
|
||||
has_existing_translation: not is_nil(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
|
||||
|
||||
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, "")),
|
||||
label: Map.get(field, :label, ""),
|
||||
current_value: Map.get(field, :current_value, ""),
|
||||
suggested_value: Map.get(field, :suggested_value, ""),
|
||||
accepted: not Map.get(field, :locked, false),
|
||||
locked: Map.get(field, :locked, false),
|
||||
key: to_string(key),
|
||||
label: label,
|
||||
current_value: current,
|
||||
suggested_value: suggested,
|
||||
accepted: not locked,
|
||||
locked: locked,
|
||||
loading: Map.get(field, :loading, false)
|
||||
}
|
||||
end)
|
||||
@@ -276,7 +284,7 @@ defmodule BDS.Desktop.Overlay do
|
||||
end
|
||||
|
||||
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, [])
|
||||
|
||||
case Enum.filter(images, &(&1.id in post_media_ids)) do
|
||||
@@ -289,29 +297,29 @@ defmodule BDS.Desktop.Overlay do
|
||||
%{
|
||||
post_id: post.id,
|
||||
title: post.title,
|
||||
status: to_string(Map.get(post, :status, "draft")),
|
||||
canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"),
|
||||
similarity_score: Map.get(post, :similarity_score)
|
||||
status: post.status,
|
||||
canonical_url: post.canonical_url,
|
||||
similarity_score: nil
|
||||
}
|
||||
end
|
||||
|
||||
defp to_insert_media_result(media) do
|
||||
%{
|
||||
media_id: media.id,
|
||||
title: Map.get(media, :title, ""),
|
||||
original_name: Map.get(media, :original_name, media.id),
|
||||
is_image: Map.get(media, :is_image, false),
|
||||
thumbnail_url: Map.get(media, :thumbnail_url)
|
||||
title: media.title,
|
||||
original_name: media.original_name,
|
||||
is_image: media.is_image,
|
||||
thumbnail_url: media.thumbnail_url
|
||||
}
|
||||
end
|
||||
|
||||
defp to_gallery_image(media) do
|
||||
%{
|
||||
media_id: media.id,
|
||||
thumbnail_url: Map.get(media, :thumbnail_url),
|
||||
image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)),
|
||||
alt_text: Map.get(media, :alt_text),
|
||||
title: Map.get(media, :title, Map.get(media, :original_name, media.id))
|
||||
thumbnail_url: media.thumbnail_url,
|
||||
image_url: media.image_url,
|
||||
alt_text: media.alt_text,
|
||||
title: media.title
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -449,7 +449,7 @@ defmodule BDS.Desktop.ShellCommands do
|
||||
end
|
||||
|
||||
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 ->
|
||||
language
|
||||
|> to_string()
|
||||
|
||||
@@ -116,6 +116,15 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
||||
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
|
||||
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
|
||||
{:noreply,
|
||||
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
import Phoenix.HTML, only: [raw: 1]
|
||||
@@ -37,6 +39,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
{:ok, do_note_streaming_content(socket, content)}
|
||||
end
|
||||
|
||||
def update(%{action: :persist_surface_state}, socket) do
|
||||
{:ok, persist_surface_state(socket)}
|
||||
end
|
||||
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
@@ -97,7 +103,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
socket
|
||||
) do
|
||||
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
|
||||
|
||||
def handle_event(
|
||||
@@ -111,6 +117,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
:surface_tabs,
|
||||
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
|
||||
)
|
||||
|> persist_surface_state()
|
||||
|> build_data()
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -120,6 +127,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|
||||
|> persist_surface_state()
|
||||
|> build_data()
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -148,14 +156,29 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
defp ensure_state(socket) do
|
||||
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 = %{
|
||||
conversation_id: conversation_id,
|
||||
input: "",
|
||||
model_selector_open?: false,
|
||||
request: nil,
|
||||
surface_data: %{},
|
||||
surface_tabs: %{},
|
||||
dismissed_surfaces: MapSet.new(),
|
||||
surface_data: surface_data,
|
||||
surface_tabs: surface_tabs,
|
||||
dismissed_surfaces: dismissed_surfaces,
|
||||
action_error: nil
|
||||
}
|
||||
|
||||
@@ -819,6 +842,41 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
# ── 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
|
||||
socket.assigns[:project_id]
|
||||
|
||||
@@ -266,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
||||
@spec default_author(term()) :: term()
|
||||
def default_author(project_id) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
Map.get(metadata, :default_author)
|
||||
metadata.default_author
|
||||
end
|
||||
|
||||
@spec suggested_definition_name(term()) :: term()
|
||||
|
||||
@@ -222,7 +222,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
||||
{: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()
|
||||
}
|
||||
end
|
||||
|
||||
@@ -22,8 +22,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
|
||||
|
||||
@spec category_rows(term()) :: term()
|
||||
def category_rows(metadata) do
|
||||
categories = Map.get(metadata, :categories, [])
|
||||
settings = Map.get(metadata, :category_settings, %{})
|
||||
categories = metadata.categories
|
||||
settings = metadata.category_settings
|
||||
|
||||
Enum.map(categories, fn category ->
|
||||
category_settings = Map.get(settings, category, %{})
|
||||
@@ -167,7 +167,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
|
||||
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
|
||||
Enum.reduce_while(Map.keys(@default_category_settings), :ok, fn category, _acc ->
|
||||
|
||||
@@ -16,17 +16,17 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
||||
@spec project_form(term()) :: term()
|
||||
def project_form(metadata) do
|
||||
%{
|
||||
"name" => Map.get(metadata, :name, ""),
|
||||
"description" => Map.get(metadata, :description, ""),
|
||||
"public_url" => Map.get(metadata, :public_url, ""),
|
||||
"main_language" => Map.get(metadata, :main_language) || "en",
|
||||
"default_author" => Map.get(metadata, :default_author, ""),
|
||||
"max_posts_per_page" => Integer.to_string(Map.get(metadata, :max_posts_per_page, 50)),
|
||||
"name" => metadata.name || "",
|
||||
"description" => metadata.description || "",
|
||||
"public_url" => metadata.public_url || "",
|
||||
"main_language" => metadata.main_language || "en",
|
||||
"default_author" => metadata.default_author || "",
|
||||
"max_posts_per_page" => Integer.to_string(metadata.max_posts_per_page),
|
||||
"blogmark_category" =>
|
||||
Map.get(metadata, :blogmark_category) ||
|
||||
List.first(Map.get(metadata, :categories, [])) || "article",
|
||||
"blog_languages" => Map.get(metadata, :blog_languages, []),
|
||||
"semantic_similarity_enabled" => Map.get(metadata, :semantic_similarity_enabled, false)
|
||||
metadata.blogmark_category ||
|
||||
List.first(metadata.categories) || "article",
|
||||
"blog_languages" => metadata.blog_languages,
|
||||
"semantic_similarity_enabled" => metadata.semantic_similarity_enabled
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
|
||||
|
||||
@spec publishing_form(term()) :: term()
|
||||
def publishing_form(metadata) do
|
||||
prefs = Map.get(metadata, :publishing_preferences, %{})
|
||||
prefs = metadata.publishing_preferences
|
||||
|
||||
%{
|
||||
"ssh_host" => Map.get(prefs, "ssh_host", ""),
|
||||
|
||||
@@ -88,7 +88,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
|
||||
def current_theme(assigns) do
|
||||
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
|
||||
{:ok, metadata} ->
|
||||
case Map.get(metadata, :pico_theme) do
|
||||
case metadata.pico_theme do
|
||||
nil -> "default"
|
||||
"" -> "default"
|
||||
theme -> theme
|
||||
|
||||
@@ -11,6 +11,21 @@ defmodule BDS.Desktop.UILocale do
|
||||
process dictionary directly. Use `with_locale/2` around any render or
|
||||
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
|
||||
`Process.get(:bds_ui_locale)` is forbidden outside this module.
|
||||
"""
|
||||
|
||||
@@ -15,6 +15,7 @@ defmodule BDS.Embeddings do
|
||||
|
||||
@duplicate_threshold 0.92
|
||||
@exact_match_score 0.999999
|
||||
@key_batch_size 199
|
||||
|
||||
def model_id, do: configured_backend().model_info().model_id
|
||||
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]
|
||||
)
|
||||
|
||||
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, Enum.map(posts, & &1.id)}
|
||||
else
|
||||
@@ -104,12 +122,27 @@ defmodule BDS.Embeddings do
|
||||
where: key.project_id == ^project_id and key.post_id not in ^post_ids
|
||||
)
|
||||
|
||||
posts
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.each(fn {post, index} ->
|
||||
sync_post_if_enabled(post, refresh_index: false)
|
||||
:ok = report_rebuild_progress(on_progress, index, total_posts, "embedding entries")
|
||||
end)
|
||||
existing_keys = preload_keys_by_post_id(project_id)
|
||||
base_label = max_label_value()
|
||||
|
||||
{rows, _next_label} =
|
||||
posts
|
||||
|> 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 = rebuild_snapshot(project_id)
|
||||
@@ -196,6 +229,53 @@ defmodule BDS.Embeddings do
|
||||
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
|
||||
project_id =
|
||||
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]
|
||||
)
|
||||
|
||||
Enum.each(posts, fn post ->
|
||||
body = resolve_post_body(post)
|
||||
content_hash = hash_text(compose_embedding_source(post.title, body))
|
||||
existing_keys = preload_keys_by_post_id(project_id)
|
||||
base_label = max_label_value()
|
||||
|
||||
case Repo.get_by(Key, post_id: post.id, project_id: project_id) do
|
||||
%Key{content_hash: ^content_hash} ->
|
||||
:ok
|
||||
{rows, _next_label} =
|
||||
Enum.reduce(posts, {[], base_label + 1}, fn post, {acc, next_label} ->
|
||||
existing_key = Map.get(existing_keys, post.id)
|
||||
|
||||
_other ->
|
||||
:ok =
|
||||
sync_post_if_enabled(
|
||||
%{post | content: if(post.content in [nil, ""], do: body, else: post.content)},
|
||||
refresh_index: false
|
||||
)
|
||||
end
|
||||
end)
|
||||
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)
|
||||
|
||||
indexed =
|
||||
|
||||
@@ -118,7 +118,7 @@ defmodule BDS.Generation.Data do
|
||||
main = String.downcase(to_string(main_language || ""))
|
||||
|
||||
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
|
||||
|
||||
cond do
|
||||
@@ -373,18 +373,18 @@ defmodule BDS.Generation.Data do
|
||||
excerpt: translation.excerpt,
|
||||
content: nil,
|
||||
status: :published,
|
||||
author: Map.get(post, :author),
|
||||
author: post.author,
|
||||
created_at: post.created_at,
|
||||
updated_at: translation.updated_at,
|
||||
published_at: translation.published_at || post.published_at,
|
||||
file_path: translation.file_path,
|
||||
tags: Map.get(post, :tags, []),
|
||||
categories: Map.get(post, :categories, []),
|
||||
template_slug: Map.get(post, :template_slug),
|
||||
tags: post.tags,
|
||||
categories: post.categories,
|
||||
template_slug: post.template_slug,
|
||||
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_canonical_language: Map.get(post, :language),
|
||||
translation_canonical_language: post.language,
|
||||
translation_file_path: translation.file_path
|
||||
}
|
||||
end
|
||||
|
||||
@@ -21,7 +21,7 @@ defmodule BDS.Generation.Outputs do
|
||||
|
||||
Enum.reject(route_posts, fn post ->
|
||||
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
|
||||
|
||||
@@ -80,10 +80,12 @@ defmodule BDS.Generation.Outputs do
|
||||
def category_route_paths(plan, posts_by_category, route_language) do
|
||||
if :category in plan.sections do
|
||||
Enum.flat_map(posts_by_category, fn {category, posts} ->
|
||||
post_count = length(posts)
|
||||
|
||||
paginated_archive_paths(
|
||||
route_language,
|
||||
["category", archive_route_segment(category)],
|
||||
length(posts),
|
||||
post_count,
|
||||
plan.max_posts_per_page
|
||||
)
|
||||
end)
|
||||
@@ -96,10 +98,12 @@ defmodule BDS.Generation.Outputs do
|
||||
def tag_route_paths(plan, posts_by_tag, route_language) do
|
||||
if :tag in plan.sections do
|
||||
Enum.flat_map(posts_by_tag, fn {tag, posts} ->
|
||||
post_count = length(posts)
|
||||
|
||||
paginated_archive_paths(
|
||||
route_language,
|
||||
["tag", archive_route_segment(tag)],
|
||||
length(posts),
|
||||
post_count,
|
||||
plan.max_posts_per_page
|
||||
)
|
||||
end)
|
||||
@@ -113,10 +117,12 @@ defmodule BDS.Generation.Outputs do
|
||||
if :date in plan.sections do
|
||||
year_paths =
|
||||
Enum.flat_map(post_index.posts_by_year, fn {year, posts} ->
|
||||
post_count = length(posts)
|
||||
|
||||
paginated_archive_paths(
|
||||
route_language,
|
||||
[Integer.to_string(year)],
|
||||
length(posts),
|
||||
post_count,
|
||||
plan.max_posts_per_page
|
||||
)
|
||||
end)
|
||||
@@ -124,11 +130,12 @@ defmodule BDS.Generation.Outputs do
|
||||
month_paths =
|
||||
Enum.flat_map(post_index.posts_by_year_month, fn {year_month, posts} ->
|
||||
[year, month] = String.split(year_month, "/", parts: 2)
|
||||
post_count = length(posts)
|
||||
|
||||
paginated_archive_paths(
|
||||
route_language,
|
||||
[year, month],
|
||||
length(posts),
|
||||
post_count,
|
||||
plan.max_posts_per_page
|
||||
)
|
||||
end)
|
||||
@@ -136,11 +143,12 @@ defmodule BDS.Generation.Outputs do
|
||||
day_paths =
|
||||
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)
|
||||
post_count = length(posts)
|
||||
|
||||
paginated_archive_paths(
|
||||
route_language,
|
||||
[year, month, day],
|
||||
length(posts),
|
||||
post_count,
|
||||
plan.max_posts_per_page
|
||||
)
|
||||
end)
|
||||
@@ -424,11 +432,11 @@ defmodule BDS.Generation.Outputs do
|
||||
title: post.title,
|
||||
content: body,
|
||||
slug: post.slug,
|
||||
language: Map.get(post, :language),
|
||||
language: post.language,
|
||||
excerpt: post.excerpt,
|
||||
_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)
|
||||
@@ -552,11 +560,11 @@ defmodule BDS.Generation.Outputs do
|
||||
title: post.title,
|
||||
content: body,
|
||||
slug: post.slug,
|
||||
language: Map.get(post, :language),
|
||||
language: post.language,
|
||||
excerpt: post.excerpt,
|
||||
_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)
|
||||
|
||||
@@ -75,7 +75,7 @@ defmodule BDS.Generation.Sitemap do
|
||||
page_path = Paths.relative_path_to_url_path(Paths.page_output_path(post.slug, nil))
|
||||
|
||||
languages =
|
||||
if Paths.truthy_flag?(Map.get(post, :do_not_translate)),
|
||||
if Paths.truthy_flag?(post.do_not_translate),
|
||||
do: [plan.language],
|
||||
else: all_languages
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ defmodule BDS.Generation.Validation do
|
||||
post_file_path:
|
||||
source_full_path(
|
||||
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)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ defmodule BDS.Generation.Validation do
|
||||
|
||||
%{
|
||||
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)
|
||||
}
|
||||
end)
|
||||
|
||||
@@ -605,6 +605,6 @@ defmodule BDS.ImportExecution do
|
||||
|
||||
defp project_default_author(project_id) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
Map.get(metadata, :default_author)
|
||||
metadata.default_author
|
||||
end
|
||||
end
|
||||
|
||||
@@ -106,7 +106,7 @@ defmodule BDS.Media do
|
||||
|> Repo.insert!()
|
||||
end) do
|
||||
{: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)
|
||||
:ok = Search.sync_media(media)
|
||||
{:ok, media}
|
||||
@@ -148,7 +148,7 @@ defmodule BDS.Media do
|
||||
|> Repo.update!()
|
||||
end) do
|
||||
{: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, updated_media}
|
||||
|
||||
@@ -240,7 +240,7 @@ defmodule BDS.Media do
|
||||
|> Repo.insert_or_update!()
|
||||
end) do
|
||||
{: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, updated_translation}
|
||||
|
||||
@@ -275,7 +275,7 @@ defmodule BDS.Media do
|
||||
)
|
||||
|
||||
:ok = Search.sync_media(media)
|
||||
:ok = write_sidecar(project, media)
|
||||
log_sidecar_error(write_sidecar(project, media), media.id)
|
||||
{:ok, true}
|
||||
|
||||
{:error, changeset} ->
|
||||
@@ -322,7 +322,7 @@ defmodule BDS.Media do
|
||||
end) do
|
||||
{:ok, updated_media} ->
|
||||
_ = 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)
|
||||
:ok = Search.sync_media(updated_media)
|
||||
{:ok, updated_media}
|
||||
@@ -350,4 +350,10 @@ defmodule BDS.Media do
|
||||
defp log_thumbnail_error({:error, reason}, media_id) do
|
||||
Logger.warning("Thumbnail generation failed for media #{media_id}: #{inspect(reason)}")
|
||||
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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
defmodule BDS.Media.Linking do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.Media.Media
|
||||
@@ -64,7 +66,7 @@ defmodule BDS.Media.Linking do
|
||||
end
|
||||
end) do
|
||||
{:ok, _result} ->
|
||||
:ok = Sidecars.write_sidecar(project, media)
|
||||
log_sidecar_error(Sidecars.write_sidecar(project, media), media.id)
|
||||
{:ok, :linked}
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -93,7 +95,7 @@ defmodule BDS.Media.Linking do
|
||||
:ok
|
||||
end) do
|
||||
{:ok, :ok} ->
|
||||
:ok = Sidecars.write_sidecar(project, media)
|
||||
log_sidecar_error(Sidecars.write_sidecar(project, media), media.id)
|
||||
{:ok, :unlinked}
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -112,6 +114,12 @@ defmodule BDS.Media.Linking do
|
||||
)
|
||||
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
|
||||
case Repo.one(
|
||||
from pm in PostMedia,
|
||||
|
||||
@@ -18,10 +18,9 @@ defmodule BDS.Media.Sidecars do
|
||||
alias BDS.Search
|
||||
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
|
||||
path = Path.join(Projects.project_data_dir(project), media.sidecar_path)
|
||||
:ok = File.mkdir_p(Path.dirname(path))
|
||||
|
||||
atomic_write(
|
||||
path,
|
||||
@@ -45,7 +44,8 @@ defmodule BDS.Media.Sidecars do
|
||||
)
|
||||
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
|
||||
path =
|
||||
Path.join(
|
||||
@@ -53,8 +53,6 @@ defmodule BDS.Media.Sidecars do
|
||||
translation_sidecar_path(media, translation.language)
|
||||
)
|
||||
|
||||
:ok = File.mkdir_p(Path.dirname(path))
|
||||
|
||||
atomic_write(
|
||||
path,
|
||||
Sidecar.serialize_document([
|
||||
@@ -189,8 +187,7 @@ defmodule BDS.Media.Sidecars do
|
||||
|
||||
media ->
|
||||
project = Projects.get_project!(media.project_id)
|
||||
:ok = write_sidecar(project, media)
|
||||
:ok
|
||||
write_sidecar(project, media)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -224,8 +221,11 @@ defmodule BDS.Media.Sidecars do
|
||||
%Translation{} = translation ->
|
||||
media = Repo.get!(Media, translation.translation_for)
|
||||
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
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ defmodule BDS.Posts.AutoTranslation do
|
||||
end
|
||||
|
||||
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.reject(&(&1 in [nil, ""]))
|
||||
|> Enum.uniq()
|
||||
|
||||
@@ -312,7 +312,7 @@ defmodule BDS.Posts.TranslationValidation do
|
||||
|
||||
defp legacy_missing_entries(source_posts, translation_rows, metadata) do
|
||||
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.reject(&(&1 in [nil, ""]))
|
||||
|> Enum.uniq()
|
||||
@@ -444,7 +444,7 @@ defmodule BDS.Posts.TranslationValidation do
|
||||
language = do_normalize_language(source_post.language)
|
||||
|
||||
if language == "" do
|
||||
do_normalize_language(Map.get(metadata, :main_language))
|
||||
do_normalize_language(metadata.main_language)
|
||||
else
|
||||
language
|
||||
end
|
||||
|
||||
@@ -101,7 +101,7 @@ defmodule BDS.Preview do
|
||||
with :ok <- ensure_running(state.current, project_id),
|
||||
{:ok, payload} <- load_draft_preview_payload(project_id, post_id, query_params) do
|
||||
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
|
||||
{:error, _reason} -> render_draft(payload)
|
||||
end
|
||||
@@ -178,7 +178,7 @@ defmodule BDS.Preview 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
|
||||
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
|
||||
{:error, _reason} -> render_draft(payload)
|
||||
end
|
||||
|
||||
@@ -28,8 +28,11 @@ defmodule BDS.PreviewAssets do
|
||||
end)
|
||||
|> Enum.filter(&File.regular?/1)
|
||||
|> Enum.sort()
|
||||
|> Enum.map(fn path ->
|
||||
{Path.relative_to(path, @preview_root), File.read!(path)}
|
||||
|> Enum.flat_map(fn path ->
|
||||
case File.read(path) do
|
||||
{:ok, contents} -> [{Path.relative_to(path, @preview_root), contents}]
|
||||
{:error, _reason} -> []
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ defmodule BDS.Publishing do
|
||||
{:reply, Repo.get(PublishJob, job_id), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:update_job, job_id, attrs}, _from, state) do
|
||||
with %PublishJob{} = job <- Repo.get(PublishJob, job_id) do
|
||||
attrs = Map.put(attrs, :updated_at, Persistence.now_ms())
|
||||
@@ -55,6 +56,7 @@ defmodule BDS.Publishing do
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:should_upload_scp_file, upload_key, local_mtime}, _from, state) do
|
||||
should_upload? =
|
||||
case state.scp_uploads[upload_key] do
|
||||
@@ -65,10 +67,12 @@ defmodule BDS.Publishing do
|
||||
{:reply, should_upload?, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
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)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:upload_site, project_id, credentials, targets, opts}, _from, state) do
|
||||
job_id = "publish-" <> Integer.to_string(System.unique_integer([:positive, :monotonic]))
|
||||
uploader = build_uploader(Keyword.put_new(opts, :project_id, project_id))
|
||||
|
||||
@@ -77,16 +77,15 @@ defmodule BDS.ReleasePackaging do
|
||||
defp reset_output(metadata) do
|
||||
File.rm_rf!(metadata.payload_root)
|
||||
File.rm_rf!(metadata.archive_path)
|
||||
File.mkdir_p!(metadata.output_dir)
|
||||
:ok
|
||||
File.mkdir_p(metadata.output_dir)
|
||||
end
|
||||
|
||||
defp copy_release(source, destination) do
|
||||
File.mkdir_p!(Path.dirname(destination))
|
||||
|
||||
case File.cp_r(source, destination) do
|
||||
{:ok, _files} -> :ok
|
||||
{:error, reason, _file} -> {:error, reason}
|
||||
with :ok <- File.mkdir_p(Path.dirname(destination)) do
|
||||
case File.cp_r(source, destination) do
|
||||
{:ok, _files} -> :ok
|
||||
{:error, reason, _file} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -102,8 +101,7 @@ defmodule BDS.ReleasePackaging do
|
||||
}
|
||||
|
||||
manifest_path = Path.join(metadata.payload_root, "manifest.json")
|
||||
File.write!(manifest_path, Jason.encode!(manifest, pretty: true))
|
||||
:ok
|
||||
File.write(manifest_path, Jason.encode!(manifest, pretty: true))
|
||||
end
|
||||
|
||||
defp create_archive(%Metadata{platform: :windows} = metadata) do
|
||||
|
||||
@@ -135,30 +135,44 @@ defmodule BDS.Rendering.Filters do
|
||||
""
|
||||
|
||||
_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
|
||||
{: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} ->
|
||||
{:error, :enoent} ->
|
||||
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
|
||||
|
||||
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
|
||||
case Earmark.as_html(markdown) do
|
||||
{:ok, html, _messages} -> html
|
||||
|
||||
@@ -93,45 +93,45 @@ defmodule BDS.Rendering.TemplateSelection do
|
||||
@spec render_template(String.t(), String.t(), map()) ::
|
||||
{:ok, String.t()} | {:error, String.t()}
|
||||
def render_template(project_id, source, assigns) do
|
||||
with {:ok, template_ast} <- Liquex.parse(source) 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))
|
||||
)
|
||||
|
||||
try do
|
||||
{result, _context} = Liquex.render!(template_ast, context)
|
||||
{:ok, IO.iodata_to_binary(result)}
|
||||
rescue
|
||||
e in Liquex.Error -> {:error, e.message}
|
||||
end
|
||||
with {:ok, template_ast} <- Liquex.parse(source),
|
||||
{:ok, _rendered} = ok <- safe_liquex_render(template_ast, project_id, assigns) do
|
||||
ok
|
||||
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
|
||||
|
||||
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
|
||||
desired_slug = bundled_template_slug(kind, slug)
|
||||
|
||||
if is_binary(desired_slug) do
|
||||
file_system = project |> StarterTemplates.template_roots() |> FileSystem.new()
|
||||
source = Liquex.FileSystem.read_template_file(file_system, desired_slug)
|
||||
|
||||
with true <- is_binary(desired_slug),
|
||||
file_system = project |> StarterTemplates.template_roots() |> FileSystem.new(),
|
||||
{:ok, source} <- FileSystem.try_read(file_system, desired_slug) do
|
||||
case Frontmatter.parse_document(source) do
|
||||
{:ok, %{body: body}} -> {:ok, body}
|
||||
{:error, :invalid_frontmatter} -> {:ok, source}
|
||||
end
|
||||
else
|
||||
{:error, :template_not_found}
|
||||
false -> {:error, :template_not_found}
|
||||
{:error, :enoent} -> {:error, :template_not_found}
|
||||
end
|
||||
rescue
|
||||
error in [Liquex.Error] ->
|
||||
_ = error
|
||||
{:error, :template_not_found}
|
||||
end
|
||||
|
||||
defp maybe_load_bundled_template_source(project, kind, slug, template, reason, error)
|
||||
|
||||
@@ -4,6 +4,8 @@ defmodule BDS.Templates do
|
||||
including slug derivation, status transitions, and filesystem synchronization.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
import Ecto.Query
|
||||
import BDS.MapUtils, only: [attr: 2, maybe_put: 3]
|
||||
|
||||
@@ -184,10 +186,18 @@ defmodule BDS.Templates do
|
||||
templates =
|
||||
template_paths
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.map(fn {path, index} ->
|
||||
template = upsert_template_from_file(project_id, project, path)
|
||||
|> Enum.flat_map(fn {path, index} ->
|
||||
result = upsert_template_from_file(project_id, project, path)
|
||||
: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)
|
||||
|
||||
remove_stale_published_templates(project_id, project, template_paths)
|
||||
@@ -241,10 +251,9 @@ defmodule BDS.Templates do
|
||||
project = Projects.get_project!(template.project_id)
|
||||
full_path = Path.join(Projects.project_data_dir(project), template.file_path)
|
||||
|
||||
if File.exists?(full_path) do
|
||||
{:ok, upsert_template_from_file(template.project_id, project, full_path)}
|
||||
else
|
||||
{:error, :not_found}
|
||||
case upsert_template_from_file(template.project_id, project, full_path) do
|
||||
{:ok, _template} = ok -> ok
|
||||
{:error, _reason} -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -278,10 +287,9 @@ defmodule BDS.Templates do
|
||||
project = Projects.get_project!(project_id)
|
||||
full_path = Path.join(Projects.project_data_dir(project), relative_path)
|
||||
|
||||
if File.exists?(full_path) do
|
||||
{:ok, upsert_template_from_file(project_id, project, full_path)}
|
||||
else
|
||||
{:error, :not_found}
|
||||
case upsert_template_from_file(project_id, project, full_path) do
|
||||
{:ok, _template} = ok -> ok
|
||||
{:error, _reason} -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -448,13 +456,12 @@ defmodule BDS.Templates do
|
||||
body = published_template_body(original_template)
|
||||
new_full_path = full_file_path(updated_template.project_id, updated_template.file_path)
|
||||
|
||||
result =
|
||||
Persistence.atomic_write(new_full_path, serialize_template_file(updated_template, body))
|
||||
case Persistence.atomic_write(new_full_path, serialize_template_file(updated_template, body)) do
|
||||
: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
|
||||
delete_file_if_present(original_template.project_id, original_template.file_path)
|
||||
else
|
||||
result
|
||||
other ->
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
@@ -494,31 +501,33 @@ defmodule BDS.Templates do
|
||||
end
|
||||
|
||||
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))
|
||||
now = Persistence.now_ms()
|
||||
|
||||
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)
|
||||
}
|
||||
with {:ok, contents} <- File.read(path),
|
||||
{:ok, %{fields: fields}} <- Frontmatter.parse_document(contents) do
|
||||
now = Persistence.now_ms()
|
||||
|
||||
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.changeset(attrs)
|
||||
|> Repo.insert_or_update!()
|
||||
template = Repo.get_by(Template, project_id: project_id, slug: attrs.slug) || %Template{}
|
||||
|
||||
template
|
||||
|> Template.changeset(attrs)
|
||||
|> Repo.insert_or_update()
|
||||
end
|
||||
end
|
||||
|
||||
defp remove_stale_published_templates(project_id, project, template_paths) do
|
||||
|
||||
@@ -821,11 +821,13 @@ defmodule BDS.UI.Sidebar do
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp build_post_section(title, status, posts, translation_counts, published_meta?) do
|
||||
post_count = length(posts)
|
||||
|
||||
%{
|
||||
id: Atom.to_string(status),
|
||||
title: title,
|
||||
status: Atom.to_string(status),
|
||||
count: length(posts),
|
||||
count: post_count,
|
||||
items:
|
||||
Enum.map(posts, fn post ->
|
||||
%{
|
||||
|
||||
@@ -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
|
||||
@@ -1477,6 +1477,39 @@ defmodule BDS.AITest do
|
||||
assert Enum.map(messages, & &1.role) == [:user]
|
||||
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
|
||||
temp_dir = Path.join(System.tmp_dir!(), "bds-ai-#{System.unique_integer([:positive])}")
|
||||
on_exit(fn -> File.rm_rf(temp_dir) end)
|
||||
|
||||
22
test/bds/csm028_broad_rescue_test.exs
Normal file
22
test/bds/csm028_broad_rescue_test.exs
Normal 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
|
||||
55
test/bds/csm029_length_in_guards_test.exs
Normal file
55
test/bds/csm029_length_in_guards_test.exs
Normal 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
|
||||
42
test/bds/csm030_unchecked_mkdir_test.exs
Normal file
42
test/bds/csm030_unchecked_mkdir_test.exs
Normal 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
|
||||
68
test/bds/csm031_try_rescue_test.exs
Normal file
68
test/bds/csm031_try_rescue_test.exs
Normal 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
|
||||
98
test/bds/csm032_map_get_pattern_match_test.exs
Normal file
98
test/bds/csm032_map_get_pattern_match_test.exs
Normal 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
|
||||
206
test/bds/csm033_batch_inserts_test.exs
Normal file
206
test/bds/csm033_batch_inserts_test.exs
Normal 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
|
||||
107
test/bds/csm034_file_read_bang_test.exs
Normal file
107
test/bds/csm034_file_read_bang_test.exs
Normal 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
|
||||
86
test/bds/csm035_process_dict_test.exs
Normal file
86
test/bds/csm035_process_dict_test.exs
Normal 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
|
||||
45
test/bds/csm036_impl_true_test.exs
Normal file
45
test/bds/csm036_impl_true_test.exs
Normal 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
|
||||
@@ -206,11 +206,17 @@ defmodule BDS.Desktop.OverlayTest do
|
||||
}
|
||||
],
|
||||
delete_details: %{
|
||||
title: "Delete Media",
|
||||
entity_name: "Street Scene",
|
||||
entity_type: "media",
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -4043,6 +4043,87 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
assert live_js =~ "this.syncExpandedSurfaces();"
|
||||
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
|
||||
assert {:ok, conversation} = AI.start_chat(%{title: "Tool Chat", model: "gpt-4.1"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user