![]() |
Ansel 0.0
A darktable fork - bloat + design vision
|
Verified against
42eca0e8feon 2026-09-29.The five rules, and the evidence behind each. Most are enforced by CI, but not all and not all
on every change — see the note below. CLAUDE.md states them as imperatives; this file says why, and what each one cost to learn.
Each finding below is dated with the commit that established it. A finding is only as good as its hash: before acting on one older than the code you are changing, re-measure it — and re-date it here when you do. Where an earlier version of a claim was wrong, that is recorded rather than quietly corrected: how a claim was wrong is usually the more useful thing to know.
What CI actually enforces, measured 2026-09-29. An earlier version of this line said "the five rules CI enforces on every change". That is too strong, and the sentence was introduced by the migration rather than carried from CLAUDE.md. Rule 1 is checked on every build (
pragma_once_to_guards.py --verify,ci.yml:189). Rule 2 runs on pull requests only and in one matrix cell (check_unused_includes.sh --changed,ci.yml:217). Rule 3 is covered, but by gates this document does not name — the SQL-handle and SQL-outside-the-module ratchets incheck_module_boundaries.sh(ci.yml:177). Rules 4 and 5 have no gate at all: nothing intools/checks params threading or a stored-format version bump. CLAUDE.md hedges this correctly ("CI enforces most of them"); this file did not.
Related: `include-graph.md`, `reorganisation.md`, `include-hygiene-roadmap.md`.
Found 8de7446ff7, 2026-08-06.
Every header in src/ uses an explicit #ifndef DT_<PATH>_H / #define / #endif guard, named after the path relative to src/ (src/develop/masks/masks_history.h → DT_DEVELOP_MASKS_MASKS_HISTORY_H). Do not add #pragma once to an existing header, and do not start a new one with it.
This is not a style preference. #pragma once and an include guard behave identically at the preprocessor level, but #pragma once silently makes a cyclic include graph compile: a header re-entered mid-definition is skipped, and the first inclusion finishes with whatever it had at that point. That is how three include cycles survived unnoticed in this codebase for years, each one a header trailing-including a header that includes it back (see doc/include-graph.md). Explicit guards make the same situation greppable and reviewable instead of invisible.
Enforcement: python3 tools/pragma_once_to_guards.py --verify exits non-zero if any #pragma once reappears, and runs in CI's "Check include hygiene" step. It sweeps every header spelling — .h, .hh, .hpp, .hxx — from the repository root the tool itself sits in, not from the working directory: a sweep restricted to .h, or one run from the wrong directory, is a gate that passes by finding nothing. python3 tools/include_graph.py --summary must keep reporting cycles 0.
**darktable.h (at src/, not in a module) has no guard either — it has a TRIPWIRE.** It ends up included by at most one path per translation unit (an entry point calling dt_init(), or a subsystem that owns one of the darktable members), so a second inclusion is never legitimate: it means the header arrived through a path nobody intended. A guard would absorb that silently; instead the file #errors on re-inclusion. If you hit it, do not add a guard — find who included it and give that code the specific lib it needs (common/logging.h, system/mem_alloc.h, …) or the accessor for the global it wants (dt_dev_get_global(), dt_control_get_global(), …). No header may include it; as of this writing none does.
When auditing this, grep for darktable\.h"</tt> and check the spelling.</strong> Includes can be
written relative to the including file's own directory, which is how several files hid from
earlier audits while the header still lived in <tt>src/common/</tt>. It now sits at <tt>src/</tt>, so
<tt>\#include "darktable.h"</tt> IS the canonical root-relative spelling. Three files (and one <em>header</em>, <tt>common/colorchecker.h</tt>) hid behind
that spelling through several audits of this series; the compile-time tripwire is what
finally caught them. <tt>tools/include_graph.py</tt> resolves both spellings and was right when the
ad-hoc greps were wrong.
<strong>Five headers deliberately have NO guard at all</strong> and must never get one:
<tt>common/module_api.h</tt>, <tt>views/view_api.h</tt>, <tt>libs/lib_api.h</tt>,
<tt>imageio/format/imageio_format_api.h</tt>, <tt>imageio/storage/imageio_storage_api.h</tt>. They are
X-macro headers, re-included several times in the <em>same</em> translation unit with different macros
defined, and expanded <em>inside struct bodies</em> to generate members. For the same reason a
<strong>top-level <tt>\#include</tt> in one lands inside those structs</strong>. The precise rule, as the imageio
pair actually implement it: real includes must sit inside the <tt>\#ifdef FULL_API_H</tt> block —
that macro is defined only in full-API mode, while the struct-body expansion defines
<tt>INCLUDE_API_FROM_MODULE_H</tt> instead and skips the block — and only <em>other</em> X-macro headers
(<tt>common/module_api.h</tt>) may be included unguarded.
<blockquote>‍<strong>Two corrections, 2026-09-29.</strong> This used to say <tt>common/module_api.h</tt> "has no includes itself". It has one — <tt>\#include \<glib.h\></tt> at <tt>:32</tt>, four lines below its own comment saying no
include may ever be added. It is harmless only by accident: glib's own guard makes the
in-struct re-expansion empty. And only the <strong>imageio pair</strong> actually implement the rule as
stated; <tt>views/view_api.h:27</tt> and <tt>libs/lib_api.h:26</tt> carry real system includes
(<tt>\<gtk/gtk.h\></tt>, <tt>\<glib.h\></tt>) at struct-body-expansion level. So a reader auditing all five
against this paragraph will find two that do not conform, which the paragraph did not say. Symbols used
</blockquote>outside that block (<tt>dt_version()</tt>, <tt>dt_print()</tt>, <tt>IS_NULL_PTR</tt>) are the consuming <tt>.c</tt> file's
responsibility.
@subsection autotoc_md39 A header includes only what its own declarations need
<em>Found <tt>8400a289b4</tt>, 2026-08-08.</em>
Everything else belongs in the <tt>.c</tt>. A header that includes more becomes a supply line its
consumers never asked for and cannot see: they compile because something upstream happened to
pull in what they use, and the day anyone tidies that include away the breakage surfaces
somewhere else entirely, in a file that was never touched.
This is not theoretical. Removing <tt>gui/gtk.h</tt> broke a dozen IOPs because it had been the only
thing pulling <tt>sqlite3.h</tt> in ahead of <tt>common/points.h</tt> (whose vendored SFMT <tt>\#define N</tt> then
collided with <tt>sqlite3_compileoption_get(int N)</tt>). And within the same series, deleting an
unused <tt>widgets/label.h</tt> from <tt>widgets/dialog.c</tt> removed <tt>dt_free</tt> — arriving through
<tt>label.h</tt> -> <tt>system/mem_alloc.h</tt> — from a file that had never named either header.
Concretely: if a header declares <tt>void f(GtkWidget *w)</tt>, it includes <tt>\<gtk/gtk.h\></tt> and nothing
more. Implementations do not belong there either — <tt>widgets/label.h</tt> carried five <tt>static
inline</tt> helpers, and those five forced four extra includes on all ~30 of its consumers.
Moving them to <tt>label.c</tt> left the header needing only <tt>\<gtk/gtk.h\></tt>.
The one legitimate exception is a header whose published interface <em>is</em> inline code
(<tt>widgets/draw.h</tt>), which necessarily includes what that code calls. Keep those rare, and keep
them honest: they are a deliberate performance trade, not a convenience.
<tt>tools/check_unused_includes.sh</tt> gates the include lines a change adds.
<tt>tools/header_consumers.py</tt> reports what each includer of a header actually takes from it,
separating a header's own symbols from what it merely forwards.
**<tt>header_consumers.py</tt>'s "files using nothing from it" bucket does NOT mean the file can
drop the include.** It means <em>this include is redundant — the file reaches those symbols
through one of its other includes</em>. Those are exactly the files that are relying on the
supply line described above, so under this tree's rule they need the include <strong>added
explicitly</strong>, not removed. Deleting all seven such includes when <tt>colorprofiles/colorspaces.h</tt>
was split still compiled in Release <em>and</em> Debug, and broke <tt>build-nofeatures</tt>, where
<tt>control/jobs/control_jobs.h</tt> lost the two types <tt>dt_control_export()</tt> is declared with — the
other supplier only existed in the feature-full configurations. Confirm against the symbols
the file actually names (<tt>grep</tt> for the header's types and functions) before removing
anything, and never trust one build configuration to prove an include is unnecessary.
@subsection autotoc_md40 No SQL in GUI modules
<em>Found <tt>22f623c0be</tt>, 2026-06-25.</em>
<tt>src/libs/</tt> and <tt>src/views/</tt> modules must contain no raw SQL. Database access belongs behind
named functions in <tt>src/common/</tt> and <tt>src/database/</tt>. When a GUI
module needs data, add or extend a <tt>dt_collection_*</tt> / <tt>dt_film_*</tt> / <tt>dt_tag_*</tt> function and
call it. Reuse existing helpers (<tt>dt_film_get_id</tt>, <tt>dt_selection_select_list</tt>) rather than re-issuing SQL.
<blockquote>‍<strong>Two corrections, 2026-09-29.</strong> This named <tt>dt_collection_get_extended_where</tt> as a helper to
reuse: it does not exist anywhere in <tt>src/</tt> — the only survivor is the file-static
<tt>_extended_where()</tt> in <tt>src/database/collection_query.c</tt>, removed from the public surface by
<tt>ec5b7de3f0</tt>. Two orphaned doc comments in <tt>common/collection.h</tt> still describe it. And the
rule named <tt>common/collection.c</tt> and <tt>common/film.c</tt> as the places database access belongs:
both now contain <strong>zero</strong> SQL (measured: 0 matches for <tt>sqlite3_prepare</tt>,
<tt>DT_DEBUG_SQLITE3_PREPARE</tt> or <tt>sqlite3_exec</tt> in either). The SQL moved to <tt>src/database/</tt> and
its seven repositories; <tt>c75eaef473</tt>'s subject says it outright — "Collection: rules cross the boundary, not SQL". Following the old text, you would add a query to <tt>common/collection.c</tt>
and learn otherwise only when CI's boundary ratchet failed.
</blockquote>
Examples added during the collect rewrite: <tt>dt_collection_get_property_values()</tt>,
<tt>dt_collection_get_images_for_rule()</tt>, <tt>dt_film_relocate()</tt>.
@subsection autotoc_md41 Pipeline↔module interface is history
<em>Found <tt>22f623c0be</tt>, 2026-06-25.</em>
The ONLY thread-safe interface between the pixel pipeline and an IOP module is <strong>history</strong>
(guarded by <tt>dev-\>history_mutex</tt>). <tt>module-\>params</tt> and <tt>module-\>blend_params</tt> belong to the
GUI thread and are NOT thread-safe — the pipeline thread must never read or write them.
Do NOT call <tt>dt_iop_commit_params(module, module-\>params, ...)</tt> from pipeline code. Commit
from the history snapshot (<tt>hist-\>params</tt>), never the live module params.
To push live/transient state to the pipe (e.g. drawlayer realtime stroke, ashift edit mode),
either (a) write it through history under <tt>history_mutex</tt>, or (b) use the transient-resync
interface <tt>dt_dev_transient_params_{set,clear,get,active}</tt> in <tt>dev_history.{h,c}</tt>.
See <tt>doc/reorganisation.md</tt> for the threading model (GUI diamond nodes vs. pipeline round nodes).
@subsection autotoc_md42 A stored format's version is bumped only once it has shipped in a round-numbered release
<em>Found <tt>af78aa4e42</tt>, 2026-09-21.</em>
Three formats outlive the build that writes them: a module's params (<tt>DT_MODULE_INTROSPECTION</tt>), the
database schema (<tt>CURRENT_DATABASE_VERSION_LIBRARY</tt> / <tt>_DATA</tt>, <tt>database/database.c</tt>) and the XMP
sidecar (<tt>DT_XMP_EXIF_VERSION</tt>, <tt>common/xmp_sidecar.cc</tt>). Bump one only if its current version was
distributed in a round-numbered release: Ansel 1.0, 2.0, … or, for a version inherited from
darktable, a darktable release. A version that has not shipped in one is still open, and a change
goes into it without a bump. For the database schema and the XMP, the bump itself is made as late
as possible, just before Ansel's version number changes.
A bump rejects nothing: the build that makes it converts every older version (<tt>legacy_params()</tt>,
the schema migration steps, the readers of older XMP versions). What it adds is permanent, since
each version keeps its conversion code for good, so versions follow round-numbered releases instead
of piling up one per change in nightlies.
Changing an open version in place has its own conditions, since nightlies have already written it:
- <strong>Module params</strong>: append, never insert. The conversions from older versions commonly copy the
old layout as a prefix over the defaults, so existing members keep their offsets. A blob of the
same version but another size makes <tt>_sync_params()</tt> call <tt>legacy_params(N, N)</tt>, which is not
told the stored size and has no branch for it, so the step is dropped: an edit saved with the
shorter layout loses that module. <tt>_sync_params()</tt> does not fall back to copying the common
prefix.
- <strong>Database schema</strong>: a library already at version N never re-runs the step that brought it there
(<tt>_upgrade_library_schema_step()</tt>), so what is added to N in place must also be applied,
idempotently, to a library already at N: <tt>_sanitize_db()</tt> runs at every open.
- <strong>XMP</strong>: a new key is optional, its absence meaning the default, and an existing key keeps its
meaning: a sidecar written earlier under the same version still reads, and an older build
reading a newer one skips what it does not know.
Schema 36, data 9 and XMP 5 come from darktable releases. The bumps planned for Ansel 1.0 (schema
37, XMP 6) wait on the dedicated branch described in @ref "/home/runner/work/ansel/ansel/doc/masks_history_dedup.md" "`masks_history_dedup.md`" (this named it masks-history-dedup; no such branch exists locally or on the remote), see doc/masks_history_dedup.md.