From e8e22a20adea6aec1ce1e1b8227c34bae295a08d Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Thu, 30 Apr 2026 23:04:01 +0930 Subject: [PATCH] feat(artefact): pipeline-friendly combine/3, prep 0.1.3 --- .drafts/convenience-combine.md | 58 +++++++++++++++ artefact/CHANGELOG.md | 7 ++ artefact/lib/artefact.ex | 39 ++++++++++ artefact/mix.exs | 2 +- artefact/test/artefact_test.exs | 115 +++++++++++++++++++++++++++++ artefact_kino/CHANGELOG.md | 7 ++ artefact_kino/artefact_kino.livemd | 4 +- artefact_kino/mix.exs | 4 +- 8 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 .drafts/convenience-combine.md diff --git a/.drafts/convenience-combine.md b/.drafts/convenience-combine.md new file mode 100644 index 0000000..296ac4b --- /dev/null +++ b/.drafts/convenience-combine.md @@ -0,0 +1,58 @@ + + +# Drafts — Artefact.combine convenience wrapper + +Drafts only. Branch `10-convenience-combine`. Per `.claude/settings.json` no +`git commit / push / add` was run. + +--- + +## Draft commit message — `artefact` + +``` +feat(artefact): pipeline-friendly combine/3 + +Artefact.combine(heart, other, opts \\ []) is a convenience wrapper around +Artefact.Binding.find/2 + Artefact.harmonise/4 designed for pipelines — +the heart flows through the pipe as the first argument: + + me_knowing + |> Artefact.combine(me_valuing) + |> Artefact.combine(me_being) + |> Artefact.combine(me_doing, title: "MeMind", description: "Mind of Me") + +Bindings are auto-found via shared uuids; opts pass through to harmonise +for :title and :base_label overrides. :description is patched onto the +result (since harmonise itself does not yet honour :description in opts). +Raises MatchError when there are no shared nodes. + +Provenance: :harmonised, with the calling module. Combine is sugar over +harmonise — the underlying operation IS a harmonise, so the trace stays +honest. The convenience is the binding-find and the heart-first arg order. +``` + +## Notes for the next yarn + +- **harmonise/compose could honour :description in opts.** Right now combine + patches it post-hoc. If you later extend `do_harmonise/5` and `do_compose/4` + to read `:description` from opts and pass it into `build/1`, combine can + drop the patch and just delegate. Worth a small follow-up issue, not part + of this branch. + +- **Portmanteau base_label grows.** Each combine step concatenates the + heart and other base_labels. Through a five-step pipeline that becomes a + long word like `KnowingValuingBeingKnowingMoreDoing`. The final step's + opts can rename it (`base_label: "MeMind"`), which is what the example + in the docstring shows. Worth a note in any usage example. + +- **No `combine!` variant.** combine raises MatchError on no bindings, which + matches the user's original livebook helper. If we later want a + `combine_with/3` that explicitly accepts `inject:` bindings, that's a new + function — keep combine as the simple shared-uuid case. + +--- + +*Held in the commons.* diff --git a/artefact/CHANGELOG.md b/artefact/CHANGELOG.md index 212cb21..558b582 100644 --- a/artefact/CHANGELOG.md +++ b/artefact/CHANGELOG.md @@ -5,6 +5,13 @@ SPDX-License-Identifier: MIT # Changelog +## 0.1.3 — 2026-04-30 + +- `Artefact.Mermaid.export/2` — derives Mermaid `graph` source from an `%Artefact{}`, alongside `Artefact.Cypher` and `Artefact.Arrows`; nodes render as circles, `:direction` option for `:LR`, `:RL`, `:TB`, `:BT`, `:TD` +- `:description` field on `%Artefact{}` — optional human-readable description, defaults to `nil`; accepted by `Artefact.new/1` and round-tripped through `Artefact.Arrows` +- Mermaid front-matter `title:` (Mermaid 9.4+ heading) and body `accTitle:` derived from `artefact.title`; `accDescr:` derived from `artefact.description` (inline form for single-line, block form `accDescr { ... }` for multi-line) +- `Artefact.combine/3` — pipeline-friendly convenience over `Artefact.Binding.find/2` + `Artefact.harmonise/4`; the heart flows through the pipe as the first argument, opts honour `:title`, `:base_label` and `:description` overrides; raises `MatchError` when no shared bindings exist + ## 0.1.2 — 2026-04-21 - Improved `Artefact.new/1` macro — nodes and relationships declared inline with atom keys and keyword options diff --git a/artefact/lib/artefact.ex b/artefact/lib/artefact.ex index 9441451..94ab2b1 100644 --- a/artefact/lib/artefact.ex +++ b/artefact/lib/artefact.ex @@ -75,6 +75,45 @@ defmodule Artefact do build([{:title, title}, {:base_label, base_label}, {:graph, graph}, {:metadata, metadata}]) end + @doc """ + Combine `other` into `heart` using bindings auto-found between them. + + Designed for pipelines — `heart` flows through the pipe as the first argument, + so a chain of combines accumulates a single heart from many others: + + me_knowing + |> Artefact.combine(me_valuing) + |> Artefact.combine(me_being) + |> Artefact.combine(me_doing, title: "MeMind", description: "Mind of Me.") + + Bindings are found via `Artefact.Binding.find/2` — every node sharing a uuid + between `heart` and `other` becomes a binding. Raises `MatchError` if no + bindings are found. + + Internally delegates to `harmonise/4`, so `:title` and `:base_label` overrides + in `opts` are honoured. `:description` is also accepted and applied to the + result. + + Records `:harmonised` provenance with the calling module. + """ + defmacro combine(heart, other, opts \\ []) do + caller = __CALLER__.module + quote do + Artefact.do_combine(unquote(heart), unquote(other), unquote(opts), unquote(caller)) + end + end + + @doc false + def do_combine(%__MODULE__{} = heart, %__MODULE__{} = other, opts, caller) do + {:ok, bindings} = Artefact.Binding.find(heart, other) + result = do_harmonise(heart, other, bindings, opts, caller) + + case Keyword.fetch(opts, :description) do + {:ok, description} -> %{result | description: description} + :error -> result + end + end + @doc """ Harmonise two artefacts using declared bindings. diff --git a/artefact/mix.exs b/artefact/mix.exs index f194bcf..d96965b 100644 --- a/artefact/mix.exs +++ b/artefact/mix.exs @@ -5,7 +5,7 @@ defmodule Artefact.MixProject do @moduledoc false use Mix.Project - @version "0.1.2" + @version "0.1.3" @github_url "https://github.com/diffo-dev/artefactory" def project do diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs index 449c395..a4ff4ad 100644 --- a/artefact/test/artefact_test.exs +++ b/artefact/test/artefact_test.exs @@ -975,4 +975,119 @@ defmodule ArtefactTest do assert descr_idx < node_idx end end + + describe "Artefact.combine/3" do + @uuid_shared "019d0000-0000-7000-8000-000000000000" + + defp combine_artefact(base_label, uuid) do + %Artefact{ + id: Artefact.UUID.generate_v7(), + uuid: Artefact.UUID.generate_v7(), + title: base_label, + base_label: base_label, + style: nil, + metadata: %{}, + graph: %Artefact.Graph{ + nodes: [%Artefact.Node{id: "n0", uuid: uuid, labels: [], properties: %{}}], + relationships: [] + } + } + end + + test "combines two artefacts via auto-found bindings" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert length(result.graph.nodes) == 1 + end + + test "default base_label is portmanteau of heart + other" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert result.base_label == "KnowingValuing" + end + + test "title defaults to base_label when not given" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert result.title == "KnowingValuing" + end + + test "description defaults to nil when not given" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert result.description == nil + end + + test "applies title override from opts" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other, title: "Custom") + assert result.title == "Custom" + end + + test "applies description override from opts" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other, description: "yarned") + assert result.description == "yarned" + end + + test "applies title and description together" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other, title: "MeMind", description: "Mind of Me") + assert result.title == "MeMind" + assert result.description == "Mind of Me" + end + + test "chains in a pipeline" do + a = combine_artefact("Knowing", @uuid_shared) + b = combine_artefact("Valuing", @uuid_shared) + c = combine_artefact("Being", @uuid_shared) + + result = a |> Artefact.combine(b) |> Artefact.combine(c) + assert result.base_label == "KnowingValuingBeing" + assert length(result.graph.nodes) == 1 + end + + test "pipeline applies title and description on the final step" do + a = combine_artefact("Knowing", @uuid_shared) + b = combine_artefact("Valuing", @uuid_shared) + c = combine_artefact("Being", @uuid_shared) + + result = + a + |> Artefact.combine(b) + |> Artefact.combine(c, title: "MeMind", description: "Mind of Me") + + assert result.title == "MeMind" + assert result.description == "Mind of Me" + end + + test "records :harmonised provenance with calling module" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert %{provenance: %{source: :harmonised, module: ArtefactTest}} = result.metadata + end + + test "raises when artefacts have no shared nodes" do + heart = combine_artefact("Knowing", "019d0000-0000-7000-8000-000000000010") + other = combine_artefact("Valuing", "019d0000-0000-7000-8000-000000000020") + + assert_raise MatchError, fn -> Artefact.combine(heart, other) end + end + end end diff --git a/artefact_kino/CHANGELOG.md b/artefact_kino/CHANGELOG.md index feb76d2..41b18e7 100644 --- a/artefact_kino/CHANGELOG.md +++ b/artefact_kino/CHANGELOG.md @@ -5,6 +5,13 @@ SPDX-License-Identifier: MIT # Changelog +## 0.1.3 — 2026-04-30 + +- Compatible with `artefact ~> 0.1.3` +- MERMAID button on the export panel — pasteable Mermaid `graph` source alongside CREATE / MERGE / JSON +- Header bar renders `artefact.description` under `artefact.title` when set; multi-line descriptions preserve their newlines (`white-space: pre-line`) +- `description` row added to the Artefact tab in the Elixir inspector, alongside `title`, `base_label` and `metadata` + ## 0.1.2 — 2026-04-21 - Compatible with `artefact ~> 0.1.2` diff --git a/artefact_kino/artefact_kino.livemd b/artefact_kino/artefact_kino.livemd index 68b3b10..59f72cb 100644 --- a/artefact_kino/artefact_kino.livemd +++ b/artefact_kino/artefact_kino.livemd @@ -7,9 +7,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install([ - #{:artefact_kino, "~> 0.1.2"}, - {:artefact_kino, path: "/Users/beanlanda/git/artefactory/artefact_kino"}, - {:artefact, path: "/Users/beanlanda/git/artefactory/artefact", override: true}, + {:artefact_kino, "~> 0.1.3"}, {:req, "~> 0.5"} ]) ``` diff --git a/artefact_kino/mix.exs b/artefact_kino/mix.exs index bd4e9fa..0e44e36 100644 --- a/artefact_kino/mix.exs +++ b/artefact_kino/mix.exs @@ -5,7 +5,7 @@ defmodule ArtefactKino.MixProject do @moduledoc false use Mix.Project - @version "0.1.2" + @version "0.1.3" @github_url "https://github.com/diffo-dev/artefactory" def project do @@ -29,7 +29,7 @@ defmodule ArtefactKino.MixProject do defp deps do [ - {:artefact, "~> 0.1"}, + {:artefact, "~> 0.1.3"}, {:kino, "~> 0.14"}, {:ex_doc, "~> 0.37", only: [:dev, :test], runtime: false} ]