Thanks for considering a contribution to Episteme. This document covers
the mechanics — workflow, commit style, what has to pass before a
change lands — and assumes you're already comfortable with the
Prolog/logic-programming vocabulary it uses (unify, clause, cut-opaque,
...); if any of that's new, TUTORIAL.md explains it from
scratch, REFERENCE.md covers every feature in full
detail, and CHEATSHEET.md has a
compact glossary. For how the library itself is put together, start
with README.md and the moduledoc on
Episteme.Engine.
Before every commit
Run mix precommit and make sure it passes. No exceptions. It runs, in
order (fast/cheap checks first, so a broken commit fails quickly):
mix format
mix compile --warnings-as-errors
mix credo --strict
mix sobelow --skip
mix test
mix dialyzer
mix dialyzer's first run builds a PLT and is slow; every run after
that is fast. If mix precommit isn't defined for some reason, it's an
alias in mix.exs — check it's still there rather than running the
steps ad hoc.
Git workflow
This repository uses git flow:
main (releases), develop (integration), feature/*, release/*,
hotfix/*, support/*.
- No direct commits to
mainordevelop. - Branch off
develop(feature/your-thing), open a PR back intodevelopwhen it's ready. - Only
release/*/hotfix/*branches merge intomain.
Commits
Conventional Commits:
<type>[optional scope]: <description>.
Common types: feat, fix, docs, style, refactor, perf, test,
chore, build, ci. Breaking changes get a ! after the type/scope
(feat!: ...) or a BREAKING CHANGE: footer.
Tests
- A change to what code does needs its tests updated in the same commit, not "later" — a passing suite that no longer exercises real current behavior is worse than a failing one.
- Add tests for new behavior as you write it.
- Where the input space is bigger than a handful of examples usefully
covers — parsers, encoders/decoders, merge/normalization logic,
anything with an invariant that should hold for all inputs, not
just the ones you thought of — prefer a property-based test (this
project depends on
stream_datafor exactly this; see thedescribe "invariants over arbitrary ground terms"block intest/episteme/term_test.exsfor the pattern) over enumerating more example cases by hand. Ordinary example-based tests are still right for fixed, specific scenarios and regressions. - If you add a predicate that mutates the database (anything in the
assert/retractfamily) or that runs a sub-goal (findall,forall,call,once,\+,catch), write at least one test that checks it's cut-opaque and, separately, one that checks what it does or doesn't bind in the caller — those two properties are exactly what bites people writing Prolog-like engines, and exactly what's easy to get subtly wrong. - A trap worth knowing about: in Elixir,
%{}(and any partial map) as a pattern matches any map with at least those keys —%{} = %{"X" => 1}succeeds.{:ok, [%{}]} = Episteme.query(goal, db)does not assert the solution has no bindings; it only assertsquery/2returned one solution that's some kind of map. If you want to assert "no named variables are bound," compare with==against the exact expected map instead of pattern-matching a subset of it.
Documentation
- Every public module needs a
@moduledoc. Every public function needs a@doc. - Update
@moduledoc/@docwhenever behavior changes — stale docs are worse than none. - If your change is user-visible, update the relevant doc(s) in the same
commit: README.md for anything about how the library
fits together, TUTORIAL.md if it changes how someone
learns the library, EXAMPLES.md if it changes how a
worked example behaves, CHEATSHEET.md for a new or
changed predicate/function. If you add a code example to any doc,
actually run it (
mix runa scratch script, oriex -S mix) before committing it — a plausible-looking Prolog example that was never executed is exactly how a doc bug gets shipped; solution-map ordering and which variables end up bound are both easy to get wrong by hand. - Update CHANGELOG.md for every user-facing change,
following Keep a Changelog: add entries
under
[Unreleased]as you work. On release, entries move under a version heading and the (now-empty)[Unreleased]section is removed.
Static analysis findings
A new low-confidence Sobelow or Dialyzer finding isn't automatically wrong, but isn't automatically fine either. Give it a specific justification, not a blanket suppression:
- Sobelow: a
# sobelow_skip ["Check.Name"]comment directly above the flagged function (no colon aftersobelow_skip— that breaks the regex Sobelow matches on), plus a comment explaining why it's a false positive for this function specifically. SeeEpisteme.query_once/2for the pattern.mix precommitrunssobelow --skipso the skip actually takes effect. - Dialyzer: a targeted
@dialyzer {:nowarn_function, fun: arity}naming the exact function, with a comment on why the warning doesn't apply. See the privateappend_cons/5inlib/episteme/builtins/lists.exfor the pattern (an intentionally improper list, which Dialyzer'simproper_list_constrcheck doesn't expect).
Dependency boundaries
If you touch mix.exs deps or lib/, and a dependency is scoped
only: [:dev, :test]/runtime: false specifically to keep it out of a
production build (credo, dialyxir, sobelow, excoveralls,
ex_doc all are), double-check that scoping still holds — e.g. that
nothing under lib/ now references one of them — rather than assuming
it's untouched.
Versioning
Semantic Versioning: MAJOR.MINOR.PATCH.
MAJOR for breaking changes, MINOR for backward-compatible features,
PATCH for backward-compatible fixes. Bump the version in mix.exs as
part of a release, matching the changelog entry.
Adding a new predicate
If you're adding a new builtin predicate rather than changing an existing one, a few things to decide, roughly in order:
- Does it need engine internals (the cut barrier,
Tree.nextto force a sub-goal early, mutating theDatabase.t()it's given)? If so it belongs directly inEpisteme.Engine'sdispatch/4clauses —findall/3,assert/1, and\+/1are all this shape. If it's a self-contained goal that only needs unification (Bindings.unify) and doesn't need to look inside the engine's own search machinery, it belongs in aEpisteme.Builtins.*module instead, dispatched through that module'sdispatch/3and chained intoEpisteme.Engine's catch-alldispatch/4clause (see howArithmetic/Lists/Ioare wired in). - Which existing
Builtinsmodule does it belong to — arithmetic, list-shaped, I/O, or none of the above (a new module is fine; updatemix.exs'sgroups_for_modulesif so). - Match ISO/SWI-Prolog semantics for modes (which arguments can be
unbound), error conditions (
type_error/domain_error/instantiation_error, viaEpisteme.Builtins.Exceptions), and whether it's cut-opaque, unless there's a specific reason to diverge — and if you do diverge, say so in the@moduledoc/@doc, the wayEpisteme.Engine's moduledoc explains why cut isn'tonce/1. - Add it to CHEATSHEET.md and, if it's something a newcomer would plausibly reach for, TUTORIAL.md or EXAMPLES.md too.
Generated/checked-in files
doc/ (from mix docs) is generated and gitignored — never hand-edit
it or commit it.