diff --git a/artefact/CHANGELOG.md b/artefact/CHANGELOG.md index 906121b..c2a51cd 100644 --- a/artefact/CHANGELOG.md +++ b/artefact/CHANGELOG.md @@ -5,6 +5,38 @@ SPDX-License-Identifier: MIT # Changelog +## 0.2.0 — 2026-05-05 *(breaking)* + +### API shape + +- All public ops now have **two variants**: + - `new/1`, `compose/3`, `combine/3`, `harmonise/4`, `graft/3` return `{:ok, %Artefact{}} | {:error, error}`. + - `new!/1`, `compose!/3`, `combine!/3`, `harmonise!/4`, `graft!/3` return `%Artefact{}` directly or raise the error struct. Behaviour matches 0.1.5's raise-everywhere — the `!` variants are the gentle migration path. +- `validate/1` shape: `:ok | {:error, %Artefact.Error.Invalid{reasons: [...]}}` (was `{:error, [reason_strings]}`). +- `validate!/1` raises `Artefact.Error.Invalid` (was `ArgumentError`). +- Closes [#23], [#25]. + +### Errors as structured values + +- New `:splode` runtime dependency (`{:splode, "~> 0.3"}`). +- `Artefact.Error` — Splode root with two error classes (`:invalid`, `:operation`). +- `Artefact.Error.Invalid` — validation rule violations; `:reasons` field carries the list of human-readable strings. +- `Artefact.Error.Operation` — op-specific outcomes; `:op`, `:tag`, `:details` fields. See `MIGRATION.md` for the full per-op tag table. +- Errors are real Elixir exceptions — raisable by the `!` variants, pattern-matchable as struct values from the non-`!` variants, and aggregatable by Splode-using callers (e.g. UsTwo libraries). + +### Module reorg (internal) + +- `Artefact.Op` — implementation home for the operations. +- `Artefact.Validator` — implementation home for validation rules; surfaced via `defdelegate` from `Artefact`. +- The `Artefact` module is now a thin macro facade plus the `%Artefact{}` struct definition. Future internal refactors won't churn the consumer-visible surface. + +### Migration + +See [`MIGRATION.md`](MIGRATION.md) for the migration guide. TL;DR — append `!` to every op call and you're done; use the non-`!` variant + `with`/`case` if you want explicit error handling. + +[#23]: https://github.com/diffo-dev/artefactory/issues/23 +[#25]: https://github.com/diffo-dev/artefactory/issues/25 + ## 0.1.5 — 2026-05-05 - `Artefact.is_artefact?/1`, `Artefact.is_valid?/1`, `Artefact.validate/1`, `Artefact.validate!/1` — public validation API. Closes [#26], [#27] diff --git a/artefact/MIGRATION.md b/artefact/MIGRATION.md new file mode 100644 index 0000000..c5b4396 --- /dev/null +++ b/artefact/MIGRATION.md @@ -0,0 +1,176 @@ + + +# Migration: artefact 0.1.x → 0.2.0 + +0.2.0 reshapes the public API to be idiomatic Elixir. Operations now +return `{:ok, _}` / `{:error, _}` tuples and have `!` variants that +raise. Errors are `Splode`-typed structs that pattern-match cleanly. + +## TL;DR + +If you were using 0.1.x and you're happy with raise-on-error +semantics, append `!` to every op call and you're done: + +```diff +- artefact = Artefact.new(title: "x", nodes: [...]) ++ artefact = Artefact.new!(title: "x", nodes: [...]) + +- result = a |> Artefact.combine(b) |> Artefact.combine(c) ++ result = a |> Artefact.combine!(b) |> Artefact.combine!(c) +``` + +That's the whole migration if you don't want to use the new return +shape. + +## Per-op changes + +### `new/1` — now returns `{:ok, _}` + +```elixir +# 0.1.x +artefact = Artefact.new(title: "x", nodes: [...]) + +# 0.2.0 +{:ok, artefact} = Artefact.new(title: "x", nodes: [...]) +# or +artefact = Artefact.new!(title: "x", nodes: [...]) +``` + +### `compose/3`, `combine/3`, `harmonise/4`, `graft/3` + +All shifted to `{:ok, _}` / `{:error, _}` returns. The `!` variants +match the old 0.1.x raise behaviour exactly. + +```elixir +# 0.1.x +result = + me_knowing + |> Artefact.combine(me_valuing) + |> Artefact.combine(me_being) + +# 0.2.0 — pipeline-friendly with `!` +result = + me_knowing + |> Artefact.combine!(me_valuing) + |> Artefact.combine!(me_being) + +# 0.2.0 — explicit `with` for error handling +with {:ok, knowing} <- Artefact.combine(me_knowing, me_valuing), + {:ok, being} <- Artefact.combine(knowing, me_being) do + {:ok, being} +end +``` + +## Error shapes + +Errors are now structs from the `Splode`-based `Artefact.Error.*` +namespace. Two flavours: + +### `Artefact.Error.Invalid` — class `:invalid` + +Validation rule violations on the produced or input artefact. + +```elixir +%Artefact.Error.Invalid{reasons: ["uuid is not a valid UUIDv7"]} +``` + +`:reasons` is a list of human-readable strings — same shape as 0.1.5's +`validate/1` reasons, just wrapped in a struct. + +### `Artefact.Error.Operation` — class `:operation` + +Op-specific outcomes that prevent the op from proceeding even with +valid input. The `:tag` field discriminates the specific outcome: + +| Op | `:tag` values | `:details` | +|----|---------------|------------| +| `combine` | `:no_shared_bindings` | `%{}` | +| `harmonise` | `:self_harmonise` | `%{uuid: ...}` | +| `harmonise` | `:same_base_label` | `%{base_label: ...}` | +| `graft` | `:missing_uuid` | `%{key: ...}` | +| `graft` | `:invalid_uuid` | `%{key: ..., uuid: ...}` | +| `graft` | `:invalid_labels` | `%{key: ..., labels: ...}` | +| `graft` | `:invalid_properties` | `%{key: ..., properties: ...}` | +| `graft` | `:duplicate_keys` | `%{keys: [...]}` | +| `graft` | `:unknown_rel_key` | `%{key: ...}` | +| `graft` | `:islands` | `%{keys: [...]}` | + +Pattern matching on the tag is the idiomatic way: + +```elixir +case Artefact.combine(heart, other) do + {:ok, result} -> result + {:error, %Artefact.Error.Operation{tag: :no_shared_bindings}} -> + Artefact.compose!(heart, other) +end +``` + +## Specific raise-type changes + +If you were rescuing exceptions from 0.1.x: + +| 0.1.x raise | 0.2.0 raise (from `!` variants) | +|-------------|---------------------------------| +| `ArgumentError` "invalid artefact: ..." | `Artefact.Error.Invalid` | +| `ArgumentError` "cannot harmonise an artefact with itself" | `Artefact.Error.Operation` (tag `:self_harmonise`) | +| `ArgumentError` "cannot harmonise artefacts with the same base_label" | `Artefact.Error.Operation` (tag `:same_base_label`) | +| `MatchError` (combine, no shared bindings) | `Artefact.Error.Operation` (tag `:no_shared_bindings`) | +| `ArgumentError` "graft: ..." | `Artefact.Error.Operation` (op `:graft`, various tags) | + +`rescue` clauses should switch to the new types: + +```elixir +# 0.1.x +try do + Artefact.combine(heart, other) +rescue + MatchError -> :ok +end + +# 0.2.0 +case Artefact.combine(heart, other) do + {:ok, _} -> :ok + {:error, _} -> :ok +end +``` + +## Validation API + +`is_artefact?/1`, `is_valid?/1`, `validate/1`, `validate!/1` are now +delegated from `Artefact` to the new `Artefact.Validator` module — +the surface call site is unchanged. Two shape changes: + +* `validate/1` — return is now `:ok` or `{:error, %Artefact.Error.Invalid{reasons: [...]}}` + (was `{:error, [reason_strings]}`). +* `validate!/1` — raises `Artefact.Error.Invalid` (was `ArgumentError`). + +## New module surface + +Internal modules introduced in 0.2.0: + +* `Artefact.Op` — implementation home for `new`, `compose`, `combine`, + `harmonise`, `graft`. Don't depend on this directly — `Artefact` is + still the supported surface. +* `Artefact.Validator` — validation rule implementation, surfaced via + `Artefact`'s defdelegated functions. +* `Artefact.Error` — Splode root. +* `Artefact.Error.Invalid`, `Artefact.Error.Operation`, + `Artefact.Error.Unknown` — error structs. + +The `Artefact` module itself becomes a thin macro facade plus the +struct definition. Future internal refactors won't require consumer +changes if you stick to `Artefact.*`. + +## Dependency added + +`{:splode, "~> 0.3"}` — error-class library used for the new error +structs. Adds about 1KB compiled, no transitive runtime deps beyond +Elixir core. + +## Held in the commons + +If your migration surfaces a sharp edge or a missing escape hatch, +file an issue at https://github.com/diffo-dev/artefactory/issues. diff --git a/artefact/README.md b/artefact/README.md index dd42bef..eae6ce1 100644 --- a/artefact/README.md +++ b/artefact/README.md @@ -78,43 +78,62 @@ Artefact.Arrows.to_json(us_two) ## Combining and Extending Artefacts +Operations come in two variants: `op/n` returns `{:ok, %Artefact{}} | {:error, error}`; `op!/n` returns the artefact directly or raises the error struct. Use `!` in pipelines or when you'd rather let exceptions propagate; use the non-`!` form when you want to handle errors explicitly. + ```elixir # compose — disjoint union, nodes remain independent -combined = Artefact.compose(a1, a2) +{:ok, combined} = Artefact.compose(a1, a2) # combine — pipeline-friendly union; bindings auto-found via shared uuid. -# Delegates to harmonise. Raises MatchError if no shared nodes. -my_knowing -|> Artefact.combine(my_valuing) -|> Artefact.combine(my_being) -|> Artefact.combine(my_doing, title: "MeMind", description: "Mind of Me") +# Returns {:error, %Artefact.Error.Operation{tag: :no_shared_bindings}} +# if heart and other share no node uuids. +result = + my_knowing + |> Artefact.combine!(my_valuing) + |> Artefact.combine!(my_being) + |> Artefact.combine!(my_doing, title: "MeMind", description: "Mind of Me") # harmonise — union via declared bindings. # Lower uuid wins identity, labels are unioned, left wins on property conflict. {:ok, bindings} = Artefact.Binding.find(a1, a2) -harmonised = Artefact.harmonise(a1, a2, bindings) +{:ok, harmonised} = Artefact.harmonise(a1, a2, bindings) # graft — extend an existing artefact inline with new nodes and # relationships. args matches Artefact.new's inline shape, but every # node MUST carry :uuid (no auto-find — uuid is the binding). # Nodes whose uuid lives in left bind to it (labels unioned, properties # merged left-wins). Nodes with new uuids are added. -me_mind -|> Artefact.graft( - [ - nodes: [ - {:me, [uuid: "019ddb71-c70b-7b3e-83b1-58f4d0be2852"]}, # bind-only - {:stewardship, [labels: ["Knowing"], uuid: "019df318-698c-77d6-bc7b-ea041a019a7f"]} +result = + me_mind + |> Artefact.graft!( + [ + nodes: [ + {:me, [uuid: "019ddb71-c70b-7b3e-83b1-58f4d0be2852"]}, + {:stewardship, [labels: ["Knowing"], + uuid: "019df318-698c-77d6-bc7b-ea041a019a7f"]} + ], + relationships: [[from: :me, type: "KNOWING", to: :stewardship]] ], - relationships: [[from: :me, type: "KNOWING", to: :stewardship]] - ], - title: "MeMind + Stewardship", - description: "Stewardship grafted onto MeMind." - ) + title: "MeMind + Stewardship", + description: "Stewardship grafted onto MeMind." + ) ``` +Errors are `Splode`-typed structs — pattern-match on `Artefact.Error.Invalid` (validation-rule violations) or `Artefact.Error.Operation` (op-specific outcomes) to handle each case. See [`MIGRATION.md`](MIGRATION.md) for the full error shape table. + Provenance is recorded automatically — every artefact carries metadata describing how it was created, including the calling module and, for derived artefacts, a summary of each source. +## Validation + +```elixir +Artefact.is_artefact?(value) # boolean +Artefact.is_valid?(artefact) # boolean +Artefact.validate(artefact) # :ok | {:error, %Artefact.Error.Invalid{reasons: [...]}} +Artefact.validate!(artefact) # :ok | raises Artefact.Error.Invalid +``` + +Every operation validates its inputs and the produced artefact, so corruption fails at the call site rather than steps downstream. + ## Importing from Arrows JSON ```elixir diff --git a/artefact/lib/artefact.ex b/artefact/lib/artefact.ex index 5af5be9..2ea39ad 100644 --- a/artefact/lib/artefact.ex +++ b/artefact/lib/artefact.ex @@ -23,17 +23,25 @@ defmodule Artefact do (same shape as `new`'s inline form, but every node MUST carry `:uuid`) into an existing artefact. - Every operation records its lineage in the result's `metadata.provenance`, - validates its inputs, and validates the produced artefact before returning - — so corruption fails at the call site rather than five steps downstream. + Each op has two variants: + + * `op/n` returns `{:ok, %Artefact{}} | {:error, error}` where `error` + is `Artefact.Error.Invalid` (a validation rule was violated) or + `Artefact.Error.Operation` (an op-specific outcome — e.g. combine + with no shared bindings, graft with disconnected islands). + * `op!/n` returns the `%Artefact{}` directly or raises the error + struct. Use when you'd rather let exceptions propagate (livebooks, + scripts, tests). + + Every operation records its lineage in the result's `metadata.provenance` + and validates its inputs and the produced artefact. ## Validation * `is_artefact?/1` — true when the value is an `%Artefact{}` struct. * `is_valid?/1` — true when the artefact passes every structural rule. - * `validate/1` — returns `:ok` or `{:error, reasons}` (a list of strings). - * `validate!/1` — returns `:ok` or raises `ArgumentError` with the - collected reasons. + * `validate/1` — `:ok | {:error, %Artefact.Error.Invalid{reasons: [...]}}`. + * `validate!/1` — `:ok` or raises `Artefact.Error.Invalid`. An artefact is *valid* when its uuid is a UUIDv7, every node has a UUIDv7 uuid, every node's labels is a list of strings, every node's @@ -73,161 +81,33 @@ defmodule Artefact do } # ===================================================================== - # Validation API + # Validation API — delegated to Artefact.Validator # ===================================================================== - @doc "Returns `true` when `value` is an `%Artefact{}` struct." - def is_artefact?(%__MODULE__{}), do: true - def is_artefact?(_), do: false - - @doc "Returns `true` when `value` is a valid artefact (see module docs)." - def is_valid?(value) do - case validate(value) do - :ok -> true - {:error, _} -> false - end - end - - @doc """ - Validate an artefact. Returns `:ok` or `{:error, reasons}` where reasons - is a list of human-readable strings describing each rule violation. - """ - def validate(%__MODULE__{} = a) do - errors = - [] - |> check(Artefact.UUID.valid?(a.uuid), "uuid is not a valid UUIDv7") - |> check_string_or_nil(a.title, :title) - |> check_string_or_nil(a.description, :description) - |> check_string_or_nil(a.base_label, :base_label) - |> check_graph(a.graph) - - case errors do - [] -> :ok - _ -> {:error, Enum.reverse(errors)} - end - end - - def validate(_), do: {:error, ["not an %Artefact{} struct"]} - - @doc """ - Validate an artefact. Returns `:ok` or raises `ArgumentError` with the - collected reasons. - """ - def validate!(value) do - case validate(value) do - :ok -> - :ok - - {:error, reasons} -> - raise ArgumentError, "invalid artefact: " <> Enum.join(reasons, "; ") - end - end - - defp check(errors, true, _msg), do: errors - defp check(errors, false, msg), do: [msg | errors] - - defp check_string_or_nil(errors, nil, _field), do: errors - defp check_string_or_nil(errors, value, _field) when is_binary(value), do: errors - defp check_string_or_nil(errors, _value, field), do: ["#{field} is not a string or nil" | errors] - - defp check_graph(errors, %Artefact.Graph{nodes: nodes, relationships: rels}) - when is_list(nodes) and is_list(rels) do - errors - |> check_nodes(nodes) - |> check_relationships(rels, nodes) - end - - defp check_graph(errors, _), do: ["graph is not %Artefact.Graph{} with list nodes/relationships" | errors] - - defp check_nodes(errors, nodes) do - errors = - nodes - |> Enum.with_index() - |> Enum.reduce(errors, fn {n, i}, acc -> check_node(acc, n, i) end) - - errors - |> check_unique(Enum.map(nodes, &node_uuid/1), "node uuid") - |> check_unique(Enum.map(nodes, &node_id/1), "node id") - end - - defp node_uuid(%Artefact.Node{uuid: u}), do: u - defp node_uuid(_), do: nil - defp node_id(%Artefact.Node{id: id}), do: id - defp node_id(_), do: nil - - defp check_node(errors, %Artefact.Node{} = n, idx) do - p = "node[#{idx}]" - - errors - |> check(is_binary(n.id) and n.id != "", "#{p} id is not a non-empty string") - |> check(Artefact.UUID.valid?(n.uuid), "#{p} uuid is not a valid UUIDv7") - |> check(is_list(n.labels) and Enum.all?(n.labels, &is_binary/1), - "#{p} labels is not a list of strings") - |> check(is_map(n.properties), "#{p} properties is not a map") - end - - defp check_node(errors, _, idx), do: ["node[#{idx}] is not %Artefact.Node{}" | errors] - - defp check_relationships(errors, rels, nodes) do - node_ids = MapSet.new(nodes, fn - %Artefact.Node{id: id} -> id - _ -> nil - end) - - errors = - rels - |> Enum.with_index() - |> Enum.reduce(errors, fn {r, i}, acc -> check_relationship(acc, r, i, node_ids) end) - - check_unique(errors, Enum.map(rels, fn - %Artefact.Relationship{id: id} -> id - _ -> nil - end), "relationship id") - end - - defp check_relationship(errors, %Artefact.Relationship{} = r, idx, node_ids) do - p = "relationship[#{idx}]" - - errors - |> check(is_binary(r.id) and r.id != "", "#{p} id is not a non-empty string") - |> check(is_binary(r.type) and r.type != "", "#{p} type is not a non-empty string") - |> check(MapSet.member?(node_ids, r.from_id), "#{p} from_id #{inspect(r.from_id)} not in graph") - |> check(MapSet.member?(node_ids, r.to_id), "#{p} to_id #{inspect(r.to_id)} not in graph") - |> check(is_map(r.properties), "#{p} properties is not a map") - end - - defp check_relationship(errors, _, idx, _), do: ["relationship[#{idx}] is not %Artefact.Relationship{}" | errors] - - defp check_unique(errors, list, label) do - duplicates = (list -- Enum.uniq(list)) |> Enum.uniq() |> Enum.reject(&is_nil/1) - - case duplicates do - [] -> errors - dupes -> ["duplicate #{label}s: #{inspect(dupes)}" | errors] - end - end + defdelegate is_artefact?(value), to: Artefact.Validator + defdelegate is_valid?(value), to: Artefact.Validator + defdelegate validate(value), to: Artefact.Validator + defdelegate validate!(value), to: Artefact.Validator # ===================================================================== - # Construction & Operations + # new / new! # ===================================================================== @doc """ - Create a new Artefact. Defaults `base_label` and `title` to the short name - of the calling module. Override with `title:` or `base_label:` in attrs. + Create a new Artefact. Returns `{:ok, %Artefact{}}` or + `{:error, %Artefact.Error.Invalid{}}`. - Optional `description:` is a longer human-readable note about the artefact — - surfaced as Mermaid `accDescr` and in the `ArtefactKino` inspector. Defaults - to `nil`; pass it explicitly when you have something to say. + Defaults `base_label` and `title` to the short name of the calling + module. Override with `title:` or `base_label:` in attrs. Optional + `description:` is a longer human-readable note about the artefact. Records `:struct` provenance with the calling module. """ defmacro new(attrs \\ []) do - caller = __CALLER__.module - caller_name = caller && caller |> Module.split() |> List.last() - default_base_label = caller_name && String.replace(caller_name, ~r/[^A-Za-z0-9]/, "") + {caller, caller_name, default_base_label} = caller_info(__CALLER__.module) quote do - Artefact.do_new( + Artefact.Op.new( unquote(attrs), unquote(caller), unquote(caller_name), @@ -236,419 +116,118 @@ defmodule Artefact do end end - @doc false - def do_new(attrs, caller, caller_name, default_base_label) do - metadata = %{provenance: %{source: :struct, module: caller}} - title = Keyword.get(attrs, :title, caller_name) - base_label = Keyword.get(attrs, :base_label, default_base_label) - - result = - build([ - {:title, title}, - {:base_label, base_label}, - {:metadata, metadata} | Keyword.drop(attrs, [:title, :base_label, :metadata]) - ]) - - validate!(result) - result + @doc "Same as `new/1` but raises `Artefact.Error.Invalid` on failure." + defmacro new!(attrs \\ []) do + {caller, caller_name, default_base_label} = caller_info(__CALLER__.module) + + quote do + Artefact.bang!( + Artefact.Op.new( + unquote(attrs), + unquote(caller), + unquote(caller_name), + unquote(default_base_label) + ) + ) + end end + # ===================================================================== + # compose / compose! + # ===================================================================== + @doc """ - Compose two artefacts into one. Graphs are concatenated without merging. - Nodes remain disjoint; label-based relationships are implicit. + Compose two artefacts. Graphs are concatenated without merging. + Returns `{:ok, %Artefact{}}` or `{:error, %Artefact.Error.Invalid{}}`. `base_label` defaults to the portmanteau of both artefacts' base_labels. Override with `base_label:` or `title:` in opts. - Records `:composed` provenance with the calling module and the metadata - of both source artefacts. + Records `:composed` provenance with the calling module. """ defmacro compose(a1, a2, opts \\ []) do caller = __CALLER__.module quote do - Artefact.do_compose(unquote(a1), unquote(a2), unquote(opts), unquote(caller)) + Artefact.Op.compose(unquote(a1), unquote(a2), unquote(opts), unquote(caller)) end end - @doc false - def do_compose(%__MODULE__{} = a1, %__MODULE__{} = a2, opts, caller) do - validate!(a1) - validate!(a2) - - base_label = Keyword.get(opts, :base_label, portmanteau(a1.base_label, a2.base_label)) - title = Keyword.get(opts, :title, base_label) - graph = merge_graphs(a1.graph, a2.graph) - - metadata = %{ - provenance: %{ - source: :composed, - module: caller, - left: %{ - title: a1.title, - base_label: a1.base_label, - uuid: a1.uuid, - provenance: Map.get(a1.metadata, :provenance) - }, - right: %{ - title: a2.title, - base_label: a2.base_label, - uuid: a2.uuid, - provenance: Map.get(a2.metadata, :provenance) - } - } - } - - result = - build([{:title, title}, {:base_label, base_label}, {:graph, graph}, {:metadata, metadata}]) - - validate!(result) - result - 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 + @doc "Same as `compose/3` but raises on failure." + defmacro compose!(a1, a2, opts \\ []) do caller = __CALLER__.module quote do - Artefact.do_combine(unquote(heart), unquote(other), unquote(opts), unquote(caller)) + Artefact.bang!( + Artefact.Op.compose(unquote(a1), unquote(a2), unquote(opts), unquote(caller)) + ) end end - @doc false - def do_combine(%__MODULE__{} = heart, %__MODULE__{} = other, opts, caller) do - validate!(heart) - validate!(other) - - {:ok, bindings} = Artefact.Binding.find(heart, other) - harmonised = do_harmonise(heart, other, bindings, opts, caller) - - result = - case Keyword.fetch(opts, :description) do - {:ok, description} -> %{harmonised | description: description} - :error -> harmonised - end - - validate!(result) - result - end + # ===================================================================== + # combine / combine! + # ===================================================================== @doc """ - Graft `args` onto `left`, integrating new nodes and relationships - declared inline (same shape as `Artefact.new` accepts) without creating - a second artefact. - - Designed for pipelines after a series of `combine`s — `args` flows in as - the second argument, with the result's `:title` and `:description` named - in `opts`: - - our_shells_artefact - |> Artefact.combine(our_manifesto_artefact) - |> Artefact.graft(args, title: "Our Shells and Manifesto", - description: "Our Shells and Manifesto shape our Association Knowing.") - - ## args - - A keyword list with `:nodes` and `:relationships`, identical in shape to - what `Artefact.new` accepts inline — except that **every node entry must - carry an explicit `:uuid`**. There is no auto-find: the uuid is the - binding. - - Each args node either: + Combine `other` into `heart` using bindings auto-found via shared uuid. - * **Binds** to an existing left node (uuid present in `left.graph.nodes`). - Labels are unioned, properties merged with **left winning** on key - conflicts. Position is untouched. + Returns `{:ok, %Artefact{}}` or `{:error, error}` where `error` is + `Artefact.Error.Invalid` or `Artefact.Error.Operation` with `tag: + :no_shared_bindings` if `heart` and `other` share no node uuids. - * **Adds** a new node (uuid not in left). Receives a fresh sequential id - continuing left's offset. + Designed for pipelines: - Args relationships use args-local atom keys, just like `Artefact.new`. - Every key referenced by a relationship must be declared in `args.nodes`. - - ## opts - - Honours `:title` and `:description` only — both name the result. If - omitted, `left`'s title and description carry forward. `:base_label` is - **not** honoured; the result keeps `left.base_label`. - - ## Raises - - * `ArgumentError` — any args node missing `:uuid` - * `ArgumentError` — duplicate keys in `args.nodes` - * `ArgumentError` — a relationship references a key not in `args.nodes` + with {:ok, knowing} <- Artefact.combine(my_knowing, my_valuing), + {:ok, being} <- Artefact.combine(knowing, my_being), + {:ok, mind} <- Artefact.combine(being, my_doing, + title: "MeMind", description: "Mind of Me.") do + ... + end - ## Provenance + Or with `combine!/3` if you'd rather let it raise. - Records `:grafted` with the calling module, a summary of `left`, and - `right: %{title: , description: }` — the - result's name as provided. + Records `:harmonised` provenance with the calling module. """ - defmacro graft(left, args, opts \\ []) do + defmacro combine(heart, other, opts \\ []) do caller = __CALLER__.module quote do - Artefact.do_graft(unquote(left), unquote(args), unquote(opts), unquote(caller)) + Artefact.Op.combine(unquote(heart), unquote(other), unquote(opts), unquote(caller)) end end - @doc false - def do_graft(%__MODULE__{} = left, args, opts, caller) do - validate!(left) - - node_specs = Keyword.get(args, :nodes, []) - rel_specs = Keyword.get(args, :relationships, []) - - validate_graft_node_specs!(node_specs) - validate_graft_unique_keys!(node_specs) - - left_by_uuid = Map.new(left.graph.nodes, &{&1.uuid, &1}) - - {bind_specs, new_specs} = - Enum.split_with(node_specs, fn {_key, node_opts} -> - Map.has_key?(left_by_uuid, Keyword.fetch!(node_opts, :uuid)) - end) - - bind_key_map = - Map.new(bind_specs, fn {key, node_opts} -> - existing = Map.fetch!(left_by_uuid, Keyword.fetch!(node_opts, :uuid)) - {key, existing.id} - end) - - offset = length(left.graph.nodes) - - {new_nodes, new_key_map} = - new_specs - |> Enum.with_index(offset) - |> Enum.map_reduce(%{}, fn {{key, node_opts}, i}, acc -> - id = "n#{i}" - - node = %Artefact.Node{ - id: id, - uuid: Keyword.fetch!(node_opts, :uuid), - labels: Keyword.get(node_opts, :labels, []), - properties: Keyword.get(node_opts, :properties, %{}), - position: Keyword.get(node_opts, :position) - } - - {node, Map.put(acc, key, id)} - end) - - key_map = Map.merge(bind_key_map, new_key_map) - - validate_graft_rel_keys!(rel_specs, key_map) - validate_graft_no_new_islands!(rel_specs, bind_key_map, new_key_map) - - bind_updates = - Map.new(bind_specs, fn {_key, node_opts} -> - uuid = Keyword.fetch!(node_opts, :uuid) - existing = Map.fetch!(left_by_uuid, uuid) - - merged = %{ - existing - | labels: Enum.uniq(existing.labels ++ Keyword.get(node_opts, :labels, [])), - properties: Map.merge(Keyword.get(node_opts, :properties, %{}), existing.properties) - } - - {uuid, merged} - end) - - updated_left_nodes = - Enum.map(left.graph.nodes, fn n -> Map.get(bind_updates, n.uuid, n) end) - - rel_offset = length(left.graph.relationships) - - new_rels = - rel_specs - |> Enum.with_index(rel_offset) - |> Enum.map(fn {spec, i} -> - %Artefact.Relationship{ - id: "r#{i}", - from_id: Map.fetch!(key_map, Keyword.fetch!(spec, :from)), - to_id: Map.fetch!(key_map, Keyword.fetch!(spec, :to)), - type: Keyword.fetch!(spec, :type), - properties: Keyword.get(spec, :properties, %{}) - } - end) - - relationships = deduplicate_rels(left.graph.relationships, new_rels) - - graph = %Artefact.Graph{ - nodes: updated_left_nodes ++ new_nodes, - relationships: relationships - } - - title = Keyword.get(opts, :title, left.title) - description = Keyword.get(opts, :description, left.description) - - metadata = %{ - provenance: %{ - source: :grafted, - module: caller, - left: %{ - title: left.title, - base_label: left.base_label, - uuid: left.uuid, - provenance: Map.get(left.metadata, :provenance) - }, - right: %{title: Keyword.get(opts, :title), description: Keyword.get(opts, :description)} - } - } - - result = - build([ - {:title, title}, - {:description, description}, - {:base_label, left.base_label}, - {:graph, graph}, - {:metadata, metadata} - ]) - - validate!(result) - result - end - - defp validate_graft_node_specs!(node_specs) do - Enum.each(node_specs, fn {key, node_opts} -> - uuid = - case Keyword.fetch(node_opts, :uuid) do - {:ok, u} -> u - :error -> raise ArgumentError, "graft: node #{inspect(key)} is missing required :uuid" - end - - unless Artefact.UUID.valid?(uuid) do - raise ArgumentError, - "graft: node #{inspect(key)} :uuid #{inspect(uuid)} is not a valid UUIDv7" - end - - case Keyword.fetch(node_opts, :labels) do - :error -> - :ok - - {:ok, labels} -> - unless is_list(labels) and Enum.all?(labels, &is_binary/1) do - raise ArgumentError, - "graft: node #{inspect(key)} :labels #{inspect(labels)} is not a list of strings" - end - end - - case Keyword.fetch(node_opts, :properties) do - :error -> - :ok - - {:ok, properties} -> - unless is_map(properties) do - raise ArgumentError, - "graft: node #{inspect(key)} :properties #{inspect(properties)} is not a map" - end - end - end) - end - - defp validate_graft_no_new_islands!(rel_specs, bind_key_map, new_key_map) do - bind_keys = Map.keys(bind_key_map) - new_keys = Map.keys(new_key_map) - - if new_keys == [] do - :ok - else - adjacency = - Enum.reduce(rel_specs, %{}, fn spec, acc -> - f = Keyword.fetch!(spec, :from) - t = Keyword.fetch!(spec, :to) - - acc - |> Map.update(f, MapSet.new([t]), &MapSet.put(&1, t)) - |> Map.update(t, MapSet.new([f]), &MapSet.put(&1, f)) - end) - - anchored = reach(adjacency, MapSet.new(bind_keys)) - islands = MapSet.difference(MapSet.new(new_keys), anchored) - - if MapSet.size(islands) > 0 do - raise ArgumentError, - "graft: args introduces disconnected islands — these new node keys are not reachable from any bind-only key via args.relationships: " <> - inspect(Enum.sort(MapSet.to_list(islands))) - end - end - end - - defp reach(adjacency, seeds) do - Enum.reduce(seeds, seeds, fn seed, visited -> - reach_from(adjacency, seed, visited) - end) - end - - defp reach_from(adjacency, node, visited) do - visited = MapSet.put(visited, node) - neighbours = Map.get(adjacency, node, MapSet.new()) - - Enum.reduce(neighbours, visited, fn n, acc -> - if MapSet.member?(acc, n), do: acc, else: reach_from(adjacency, n, acc) - end) - end - - defp validate_graft_unique_keys!(node_specs) do - keys = Enum.map(node_specs, fn {k, _} -> k end) - dupes = keys -- Enum.uniq(keys) + @doc "Same as `combine/3` but raises on failure." + defmacro combine!(heart, other, opts \\ []) do + caller = __CALLER__.module - if dupes != [] do - raise ArgumentError, "graft: duplicate node keys: #{inspect(Enum.uniq(dupes))}" + quote do + Artefact.bang!( + Artefact.Op.combine(unquote(heart), unquote(other), unquote(opts), unquote(caller)) + ) end end - defp validate_graft_rel_keys!(rel_specs, key_map) do - Enum.each(rel_specs, fn spec -> - from = Keyword.fetch!(spec, :from) - to = Keyword.fetch!(spec, :to) - - unless Map.has_key?(key_map, from) do - raise ArgumentError, - "graft: relationship references unknown node key #{inspect(from)} (not in args.nodes)" - end - - unless Map.has_key?(key_map, to) do - raise ArgumentError, - "graft: relationship references unknown node key #{inspect(to)} (not in args.nodes)" - end - end) - end + # ===================================================================== + # harmonise / harmonise! + # ===================================================================== @doc """ Harmonise two artefacts using declared bindings. + Returns `{:ok, %Artefact{}}` or `{:error, error}` where `error` is + `Artefact.Error.Invalid` or `Artefact.Error.Operation` with `tag: + :self_harmonise` (same artefact) or `tag: :same_base_label`. + Bound nodes are merged: lower uuid wins for identity and properties, labels are unioned. All relationships are preserved and remapped. - Returns a new artefact with a portmanteau base_label unless overridden. - Records `:harmonised` provenance with the calling module and the metadata - of both source artefacts. + Records `:harmonised` provenance with the calling module. """ defmacro harmonise(a1, a2, bindings, opts \\ []) do caller = __CALLER__.module quote do - Artefact.do_harmonise( + Artefact.Op.harmonise( unquote(a1), unquote(a2), unquote(bindings), @@ -658,203 +237,77 @@ defmodule Artefact do end end - @doc false - def do_harmonise(%__MODULE__{} = a1, %__MODULE__{} = a2, bindings, opts, caller) do - validate!(a1) - validate!(a2) - - if a1.uuid == a2.uuid do - raise ArgumentError, "cannot harmonise an artefact with itself (uuid: #{a1.uuid})" - end + @doc "Same as `harmonise/4` but raises on failure." + defmacro harmonise!(a1, a2, bindings, opts \\ []) do + caller = __CALLER__.module - if a1.base_label != nil and a1.base_label == a2.base_label do - raise ArgumentError, - "cannot harmonise artefacts with the same base_label (#{a1.base_label})" + quote do + Artefact.bang!( + Artefact.Op.harmonise( + unquote(a1), + unquote(a2), + unquote(bindings), + unquote(opts), + unquote(caller) + ) + ) end - - base_label = Keyword.get(opts, :base_label, portmanteau(a1.base_label, a2.base_label)) - title = Keyword.get(opts, :title, base_label) - - nodes_a = Map.new(a1.graph.nodes, &{&1.uuid, &1}) - nodes_b = Map.new(a2.graph.nodes, &{&1.uuid, &1}) - - # Resolve each binding: primary (lower uuid) absorbs secondary - {primary_updates, b_id_remap} = - Enum.reduce(bindings, {%{}, %{}}, fn %Artefact.Binding{uuid_a: ua, uuid_b: ub}, - {updates, remap} -> - node_a = nodes_a[ua] - node_b = nodes_b[ub] - surviving = Artefact.UUID.harmonise(ua, ub) - - {primary, secondary} = - if surviving == ua, do: {node_a, node_b}, else: {node_b, node_a} - - merged = %{ - primary - | labels: Enum.uniq(node_a.labels ++ node_b.labels), - properties: Map.merge(node_b.properties, node_a.properties) - } - - {Map.put(updates, primary.uuid, merged), Map.put(remap, secondary.id, primary.id)} - end) - - bound_uuids_b = MapSet.new(bindings, & &1.uuid_b) - offset = length(a1.graph.nodes) - - # Reindex a2 non-bound nodes to avoid id collision - {b_nodes_reindexed, b_id_remap} = - a2.graph.nodes - |> Enum.reject(&MapSet.member?(bound_uuids_b, &1.uuid)) - |> Enum.with_index(offset) - |> Enum.reduce({[], b_id_remap}, fn {node, i}, {acc, remap} -> - new_id = "n#{i}" - {acc ++ [%{node | id: new_id}], Map.put(remap, node.id, new_id)} - end) - - nodes_from_a = - Enum.map(a1.graph.nodes, fn n -> - Map.get(primary_updates, n.uuid, n) - end) - - rels_from_b = - Enum.map(a2.graph.relationships, fn rel -> - %{ - rel - | from_id: Map.get(b_id_remap, rel.from_id, rel.from_id), - to_id: Map.get(b_id_remap, rel.to_id, rel.to_id) - } - end) - - relationships = deduplicate_rels(a1.graph.relationships, rels_from_b) - - graph = %Artefact.Graph{ - nodes: nodes_from_a ++ b_nodes_reindexed, - relationships: relationships - } - - metadata = %{ - provenance: %{ - source: :harmonised, - module: caller, - left: %{ - title: a1.title, - base_label: a1.base_label, - uuid: a1.uuid, - provenance: Map.get(a1.metadata, :provenance) - }, - right: %{ - title: a2.title, - base_label: a2.base_label, - uuid: a2.uuid, - provenance: Map.get(a2.metadata, :provenance) - } - } - } - - result = - build([{:title, title}, {:base_label, base_label}, {:graph, graph}, {:metadata, metadata}]) - - validate!(result) - result end - @doc false - def build(attrs) do - {node_specs, attrs} = Keyword.pop(attrs, :nodes, []) - {rel_specs, attrs} = Keyword.pop(attrs, :relationships, []) - - attrs = - if node_specs != [] or rel_specs != [] do - Keyword.put(attrs, :graph, build_graph(node_specs, rel_specs)) - else - attrs - end - - struct!(__MODULE__, [ - {:id, Artefact.UUID.generate_v7()}, - {:uuid, Artefact.UUID.generate_v7()} | attrs - ]) - end - - defp build_graph(node_specs, rel_specs) do - {nodes, key_map} = - node_specs - |> Enum.with_index() - |> Enum.map_reduce(%{}, fn {{key, opts}, i}, acc -> - id = "n#{i}" - - node = %Artefact.Node{ - id: id, - uuid: Keyword.get(opts, :uuid, Artefact.UUID.generate_v7()), - labels: Keyword.get(opts, :labels, []), - properties: Keyword.get(opts, :properties, %{}), - position: Keyword.get(opts, :position) - } - - {node, Map.put(acc, key, id)} - end) - - relationships = - rel_specs - |> Enum.with_index() - |> Enum.map(fn {spec, i} -> - %Artefact.Relationship{ - id: "r#{i}", - from_id: Map.fetch!(key_map, Keyword.fetch!(spec, :from)), - to_id: Map.fetch!(key_map, Keyword.fetch!(spec, :to)), - type: Keyword.fetch!(spec, :type), - properties: Keyword.get(spec, :properties, %{}) - } - end) + # ===================================================================== + # graft / graft! + # ===================================================================== - %Artefact.Graph{nodes: nodes, relationships: relationships} - end + @doc """ + Graft `args` onto `left`, integrating new nodes and relationships + declared inline (same shape as `Artefact.new` accepts). Every args + node MUST carry an explicit `:uuid` — uuid is the binding. - defp deduplicate_rels(rels_a, rels_b) do - index = Map.new(rels_a, fn rel -> {{rel.from_id, rel.type, rel.to_id}, rel} end) + Returns `{:ok, %Artefact{}}` or `{:error, error}` where `error` is + `Artefact.Error.Invalid` or `Artefact.Error.Operation` with one of + `:missing_uuid`, `:invalid_uuid`, `:invalid_labels`, + `:invalid_properties`, `:duplicate_keys`, `:unknown_rel_key`, or + `:islands` (new nodes that don't reach a bind-only key). - merged_index = - Enum.reduce(rels_b, index, fn rel, acc -> - key = {rel.from_id, rel.type, rel.to_id} + ## opts - case Map.fetch(acc, key) do - {:ok, existing} -> - Map.put(acc, key, %{ - existing - | properties: Map.merge(rel.properties, existing.properties) - }) + Honours `:title` and `:description` only — both name the result. If + omitted, `left`'s title and description carry forward. + `:base_label` is **not** honoured; the result keeps `left.base_label`. - :error -> - Map.put(acc, key, rel) - end - end) + Records `:grafted` provenance with the calling module. + """ + defmacro graft(left, args, opts \\ []) do + caller = __CALLER__.module - Map.values(merged_index) + quote do + Artefact.Op.graft(unquote(left), unquote(args), unquote(opts), unquote(caller)) + end end - defp merge_graphs(g1, g2) do - offset = length(g1.nodes) + @doc "Same as `graft/3` but raises on failure." + defmacro graft!(left, args, opts \\ []) do + caller = __CALLER__.module - id_map = - g2.nodes - |> Enum.with_index(offset) - |> Map.new(fn {node, i} -> {node.id, "n#{i}"} end) + quote do + Artefact.bang!( + Artefact.Op.graft(unquote(left), unquote(args), unquote(opts), unquote(caller)) + ) + end + end - nodes = - g1.nodes ++ - Enum.map(g2.nodes, fn node -> %{node | id: id_map[node.id]} end) + # ===================================================================== + # Internal helpers — used by the `!` macros + # ===================================================================== - rels = - g1.relationships ++ - Enum.map(g2.relationships, fn rel -> - %{rel | from_id: id_map[rel.from_id], to_id: id_map[rel.to_id]} - end) + @doc false + def bang!({:ok, result}), do: result + def bang!({:error, e}), do: raise(e) - %Artefact.Graph{nodes: nodes, relationships: rels} + @doc false + def caller_info(caller_module) do + caller_name = caller_module && caller_module |> Module.split() |> List.last() + default_base_label = caller_name && String.replace(caller_name, ~r/[^A-Za-z0-9]/, "") + {caller_module, caller_name, default_base_label} end - - defp portmanteau(nil, nil), do: nil - defp portmanteau(a, nil), do: a - defp portmanteau(nil, b), do: b - defp portmanteau(a, b), do: a <> b end diff --git a/artefact/lib/artefact/error.ex b/artefact/lib/artefact/error.ex new file mode 100644 index 0000000..a14e9f2 --- /dev/null +++ b/artefact/lib/artefact/error.ex @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule Artefact.Error do + @moduledoc """ + Splode root for Artefact errors. + + Two error classes: + + * `:invalid` — the input or produced artefact violates one or more + validation rules. See `Artefact.Error.Invalid`. + * `:operation` — the input is a valid artefact, but the requested + operation cannot proceed for a deterministic reason (no shared + bindings, self-harmonise, graft islands, etc.). See + `Artefact.Error.Operation`. + + Errors are real Elixir exceptions, so they can be raised by the `!` + variants of the operations (`combine!`, `graft!`, etc.) and pattern + matched on as struct values from the non-`!` variants. + """ + + use Splode, + error_classes: [ + invalid: Artefact.Error.Invalid, + operation: Artefact.Error.Operation + ], + unknown_error: Artefact.Error.Unknown +end diff --git a/artefact/lib/artefact/error/invalid.ex b/artefact/lib/artefact/error/invalid.ex new file mode 100644 index 0000000..8078586 --- /dev/null +++ b/artefact/lib/artefact/error/invalid.ex @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule Artefact.Error.Invalid do + @moduledoc """ + Validation rule violations on an `%Artefact{}`. + + Returned as `{:error, %Artefact.Error.Invalid{reasons: [...]}}` from + `Artefact.validate/1` and from any operation that received an invalid + input or produced an invalid output. Raised by `Artefact.validate!/1` + and by the `!` variants of the operations. + + `:reasons` is a list of human-readable strings, one per rule + violation. + """ + + use Splode.Error, fields: [:reasons], class: :invalid + + def message(%{reasons: reasons}) when is_list(reasons) do + "invalid artefact: " <> Enum.join(reasons, "; ") + end + + def message(_), do: "invalid artefact" +end diff --git a/artefact/lib/artefact/error/operation.ex b/artefact/lib/artefact/error/operation.ex new file mode 100644 index 0000000..29960ba --- /dev/null +++ b/artefact/lib/artefact/error/operation.ex @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule Artefact.Error.Operation do + @moduledoc """ + Op-specific outcomes that prevent an operation from proceeding even + when its inputs are valid. + + Returned as `{:error, %Artefact.Error.Operation{op: op, tag: tag, details: details}}` + from the operation that couldn't proceed. Raised by the `!` variants. + + Tags by op: + + * `combine` — `:no_shared_bindings` + * `harmonise` — `:self_harmonise` (details: `%{uuid: ...}`), + `:same_base_label` (details: `%{base_label: ...}`) + * `graft` — `:missing_uuid` (details: `%{key: ...}`), + `:invalid_uuid` (details: `%{key: ..., uuid: ...}`), + `:invalid_labels` (details: `%{key: ..., labels: ...}`), + `:invalid_properties` (details: `%{key: ..., properties: ...}`), + `:duplicate_keys` (details: `%{keys: [...]}`), + `:unknown_rel_key` (details: `%{key: ...}`), + `:islands` (details: `%{keys: [...]}`) + + `:details` is always a map; empty when the tag carries no extra info. + """ + + use Splode.Error, fields: [:op, :tag, :details], class: :operation + + def message(%{op: op, tag: tag, details: details}) + when is_map(details) and map_size(details) > 0 do + "#{op}: #{tag} #{inspect(details)}" + end + + def message(%{op: op, tag: tag}), do: "#{op}: #{tag}" +end diff --git a/artefact/lib/artefact/error/unknown.ex b/artefact/lib/artefact/error/unknown.ex new file mode 100644 index 0000000..e796d1a --- /dev/null +++ b/artefact/lib/artefact/error/unknown.ex @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule Artefact.Error.Unknown do + @moduledoc """ + Catch-all for errors that don't match any known Artefact error class. + + Required by `Artefact.Error`'s Splode configuration as the + `:unknown_error` fallback. + """ + + use Splode.Error, fields: [:error], class: :operation + + def message(%{error: error}), do: "unknown artefact error: #{inspect(error)}" +end diff --git a/artefact/lib/artefact/op.ex b/artefact/lib/artefact/op.ex new file mode 100644 index 0000000..31eac86 --- /dev/null +++ b/artefact/lib/artefact/op.ex @@ -0,0 +1,584 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule Artefact.Op do + @moduledoc """ + Implementation home for Artefact operations. + + Public ops are surfaced through `Artefact` itself via macros (which + capture `__CALLER__.module` for provenance). This module is the + function-form home — it takes `caller` as an explicit argument. + + Every function returns `{:ok, %Artefact{}}` or `{:error, _}`. Errors + are `Artefact.Error.Invalid` (validation rule violations) or + `Artefact.Error.Operation` (op-specific outcomes — see the latter's + moduledoc for the per-op tag list). + """ + + alias Artefact.Error.Invalid + alias Artefact.Error.Operation + alias Artefact.Validator + + # ===================================================================== + # new + # ===================================================================== + + @doc false + def new(attrs, caller, caller_name, default_base_label) do + metadata = %{provenance: %{source: :struct, module: caller}} + title = Keyword.get(attrs, :title, caller_name) + base_label = Keyword.get(attrs, :base_label, default_base_label) + + result = + build([ + {:title, title}, + {:base_label, base_label}, + {:metadata, metadata} | Keyword.drop(attrs, [:title, :base_label, :metadata]) + ]) + + finish(result) + end + + # ===================================================================== + # compose + # ===================================================================== + + @doc false + def compose(%Artefact{} = a1, %Artefact{} = a2, opts, caller) do + with :ok <- Validator.validate(a1), + :ok <- Validator.validate(a2) do + base_label = Keyword.get(opts, :base_label, portmanteau(a1.base_label, a2.base_label)) + title = Keyword.get(opts, :title, base_label) + graph = merge_graphs(a1.graph, a2.graph) + + metadata = %{ + provenance: %{ + source: :composed, + module: caller, + left: source_summary(a1), + right: source_summary(a2) + } + } + + result = + build([ + {:title, title}, + {:base_label, base_label}, + {:graph, graph}, + {:metadata, metadata} + ]) + + finish(result) + end + end + + # ===================================================================== + # combine + # ===================================================================== + + @doc false + def combine(%Artefact{} = heart, %Artefact{} = other, opts, caller) do + with :ok <- Validator.validate(heart), + :ok <- Validator.validate(other), + {:ok, bindings} <- find_bindings_for_combine(heart, other), + {:ok, harmonised} <- harmonise(heart, other, bindings, opts, caller) do + result = + case Keyword.fetch(opts, :description) do + {:ok, description} -> %{harmonised | description: description} + :error -> harmonised + end + + finish(result) + end + end + + defp find_bindings_for_combine(heart, other) do + case Artefact.Binding.find(heart, other) do + {:ok, bindings} -> + {:ok, bindings} + + {:error, :no_match} -> + {:error, + %Operation{ + op: :combine, + tag: :no_shared_bindings, + details: %{} + }} + end + end + + # ===================================================================== + # harmonise + # ===================================================================== + + @doc false + def harmonise(%Artefact{} = a1, %Artefact{} = a2, bindings, opts, caller) do + with :ok <- Validator.validate(a1), + :ok <- Validator.validate(a2), + :ok <- check_not_self(a1, a2), + :ok <- check_different_base_labels(a1, a2) do + base_label = Keyword.get(opts, :base_label, portmanteau(a1.base_label, a2.base_label)) + title = Keyword.get(opts, :title, base_label) + + nodes_a = Map.new(a1.graph.nodes, &{&1.uuid, &1}) + nodes_b = Map.new(a2.graph.nodes, &{&1.uuid, &1}) + + {primary_updates, b_id_remap} = + Enum.reduce(bindings, {%{}, %{}}, fn %Artefact.Binding{uuid_a: ua, uuid_b: ub}, + {updates, remap} -> + node_a = nodes_a[ua] + node_b = nodes_b[ub] + surviving = Artefact.UUID.harmonise(ua, ub) + + {primary, secondary} = + if surviving == ua, do: {node_a, node_b}, else: {node_b, node_a} + + merged = %{ + primary + | labels: Enum.uniq(node_a.labels ++ node_b.labels), + properties: Map.merge(node_b.properties, node_a.properties) + } + + {Map.put(updates, primary.uuid, merged), Map.put(remap, secondary.id, primary.id)} + end) + + bound_uuids_b = MapSet.new(bindings, & &1.uuid_b) + offset = length(a1.graph.nodes) + + {b_nodes_reindexed, b_id_remap} = + a2.graph.nodes + |> Enum.reject(&MapSet.member?(bound_uuids_b, &1.uuid)) + |> Enum.with_index(offset) + |> Enum.reduce({[], b_id_remap}, fn {node, i}, {acc, remap} -> + new_id = "n#{i}" + {acc ++ [%{node | id: new_id}], Map.put(remap, node.id, new_id)} + end) + + nodes_from_a = + Enum.map(a1.graph.nodes, fn n -> Map.get(primary_updates, n.uuid, n) end) + + rels_from_b = + Enum.map(a2.graph.relationships, fn rel -> + %{ + rel + | from_id: Map.get(b_id_remap, rel.from_id, rel.from_id), + to_id: Map.get(b_id_remap, rel.to_id, rel.to_id) + } + end) + + relationships = deduplicate_rels(a1.graph.relationships, rels_from_b) + + graph = %Artefact.Graph{ + nodes: nodes_from_a ++ b_nodes_reindexed, + relationships: relationships + } + + metadata = %{ + provenance: %{ + source: :harmonised, + module: caller, + left: source_summary(a1), + right: source_summary(a2) + } + } + + result = + build([ + {:title, title}, + {:base_label, base_label}, + {:graph, graph}, + {:metadata, metadata} + ]) + + finish(result) + end + end + + defp check_not_self(a1, a2) do + if a1.uuid == a2.uuid do + {:error, %Operation{op: :harmonise, tag: :self_harmonise, details: %{uuid: a1.uuid}}} + else + :ok + end + end + + defp check_different_base_labels(a1, a2) do + if a1.base_label != nil and a1.base_label == a2.base_label do + {:error, + %Operation{ + op: :harmonise, + tag: :same_base_label, + details: %{base_label: a1.base_label} + }} + else + :ok + end + end + + # ===================================================================== + # graft + # ===================================================================== + + @doc false + def graft(%Artefact{} = left, args, opts, caller) do + node_specs = Keyword.get(args, :nodes, []) + rel_specs = Keyword.get(args, :relationships, []) + + with :ok <- Validator.validate(left), + :ok <- check_graft_node_specs(node_specs), + :ok <- check_graft_unique_keys(node_specs) do + left_by_uuid = Map.new(left.graph.nodes, &{&1.uuid, &1}) + + {bind_specs, new_specs} = + Enum.split_with(node_specs, fn {_key, node_opts} -> + Map.has_key?(left_by_uuid, Keyword.fetch!(node_opts, :uuid)) + end) + + bind_key_map = + Map.new(bind_specs, fn {key, node_opts} -> + existing = Map.fetch!(left_by_uuid, Keyword.fetch!(node_opts, :uuid)) + {key, existing.id} + end) + + offset = length(left.graph.nodes) + + {new_nodes, new_key_map} = + new_specs + |> Enum.with_index(offset) + |> Enum.map_reduce(%{}, fn {{key, node_opts}, i}, acc -> + id = "n#{i}" + + node = %Artefact.Node{ + id: id, + uuid: Keyword.fetch!(node_opts, :uuid), + labels: Keyword.get(node_opts, :labels, []), + properties: Keyword.get(node_opts, :properties, %{}), + position: Keyword.get(node_opts, :position) + } + + {node, Map.put(acc, key, id)} + end) + + key_map = Map.merge(bind_key_map, new_key_map) + + with :ok <- check_graft_rel_keys(rel_specs, key_map), + :ok <- check_graft_no_islands(rel_specs, bind_key_map, new_key_map) do + bind_updates = + Map.new(bind_specs, fn {_key, node_opts} -> + uuid = Keyword.fetch!(node_opts, :uuid) + existing = Map.fetch!(left_by_uuid, uuid) + + merged = %{ + existing + | labels: Enum.uniq(existing.labels ++ Keyword.get(node_opts, :labels, [])), + properties: + Map.merge(Keyword.get(node_opts, :properties, %{}), existing.properties) + } + + {uuid, merged} + end) + + updated_left_nodes = + Enum.map(left.graph.nodes, fn n -> Map.get(bind_updates, n.uuid, n) end) + + rel_offset = length(left.graph.relationships) + + new_rels = + rel_specs + |> Enum.with_index(rel_offset) + |> Enum.map(fn {spec, i} -> + %Artefact.Relationship{ + id: "r#{i}", + from_id: Map.fetch!(key_map, Keyword.fetch!(spec, :from)), + to_id: Map.fetch!(key_map, Keyword.fetch!(spec, :to)), + type: Keyword.fetch!(spec, :type), + properties: Keyword.get(spec, :properties, %{}) + } + end) + + relationships = deduplicate_rels(left.graph.relationships, new_rels) + + graph = %Artefact.Graph{ + nodes: updated_left_nodes ++ new_nodes, + relationships: relationships + } + + title = Keyword.get(opts, :title, left.title) + description = Keyword.get(opts, :description, left.description) + + metadata = %{ + provenance: %{ + source: :grafted, + module: caller, + left: source_summary(left), + right: %{ + title: Keyword.get(opts, :title), + description: Keyword.get(opts, :description) + } + } + } + + result = + build([ + {:title, title}, + {:description, description}, + {:base_label, left.base_label}, + {:graph, graph}, + {:metadata, metadata} + ]) + + finish(result) + end + end + end + + defp check_graft_node_specs(node_specs) do + Enum.reduce_while(node_specs, :ok, fn {key, node_opts}, _acc -> + case check_graft_node_spec(key, node_opts) do + :ok -> {:cont, :ok} + {:error, _} = err -> {:halt, err} + end + end) + end + + defp check_graft_node_spec(key, node_opts) do + cond do + Keyword.fetch(node_opts, :uuid) == :error -> + {:error, %Operation{op: :graft, tag: :missing_uuid, details: %{key: key}}} + + not Artefact.UUID.valid?(Keyword.fetch!(node_opts, :uuid)) -> + {:error, + %Operation{ + op: :graft, + tag: :invalid_uuid, + details: %{key: key, uuid: Keyword.fetch!(node_opts, :uuid)} + }} + + Keyword.has_key?(node_opts, :labels) and + not valid_labels?(Keyword.fetch!(node_opts, :labels)) -> + {:error, + %Operation{ + op: :graft, + tag: :invalid_labels, + details: %{key: key, labels: Keyword.fetch!(node_opts, :labels)} + }} + + Keyword.has_key?(node_opts, :properties) and + not is_map(Keyword.fetch!(node_opts, :properties)) -> + {:error, + %Operation{ + op: :graft, + tag: :invalid_properties, + details: %{key: key, properties: Keyword.fetch!(node_opts, :properties)} + }} + + true -> + :ok + end + end + + defp valid_labels?(labels) do + is_list(labels) and Enum.all?(labels, &is_binary/1) + end + + defp check_graft_unique_keys(node_specs) do + keys = Enum.map(node_specs, fn {k, _} -> k end) + dupes = (keys -- Enum.uniq(keys)) |> Enum.uniq() + + case dupes do + [] -> + :ok + + _ -> + {:error, %Operation{op: :graft, tag: :duplicate_keys, details: %{keys: dupes}}} + end + end + + defp check_graft_rel_keys(rel_specs, key_map) do + Enum.reduce_while(rel_specs, :ok, fn spec, _acc -> + from = Keyword.fetch!(spec, :from) + to = Keyword.fetch!(spec, :to) + + cond do + not Map.has_key?(key_map, from) -> + {:halt, {:error, %Operation{op: :graft, tag: :unknown_rel_key, details: %{key: from}}}} + + not Map.has_key?(key_map, to) -> + {:halt, {:error, %Operation{op: :graft, tag: :unknown_rel_key, details: %{key: to}}}} + + true -> + {:cont, :ok} + end + end) + end + + defp check_graft_no_islands(rel_specs, bind_key_map, new_key_map) do + bind_keys = Map.keys(bind_key_map) + new_keys = Map.keys(new_key_map) + + if new_keys == [] do + :ok + else + adjacency = + Enum.reduce(rel_specs, %{}, fn spec, acc -> + f = Keyword.fetch!(spec, :from) + t = Keyword.fetch!(spec, :to) + + acc + |> Map.update(f, MapSet.new([t]), &MapSet.put(&1, t)) + |> Map.update(t, MapSet.new([f]), &MapSet.put(&1, f)) + end) + + anchored = reach(adjacency, MapSet.new(bind_keys)) + islands = MapSet.difference(MapSet.new(new_keys), anchored) + + if MapSet.size(islands) == 0 do + :ok + else + {:error, + %Operation{ + op: :graft, + tag: :islands, + details: %{keys: Enum.sort(MapSet.to_list(islands))} + }} + end + end + end + + defp reach(adjacency, seeds) do + Enum.reduce(seeds, seeds, fn seed, visited -> + reach_from(adjacency, seed, visited) + end) + end + + defp reach_from(adjacency, node, visited) do + visited = MapSet.put(visited, node) + neighbours = Map.get(adjacency, node, MapSet.new()) + + Enum.reduce(neighbours, visited, fn n, acc -> + if MapSet.member?(acc, n), do: acc, else: reach_from(adjacency, n, acc) + end) + end + + # ===================================================================== + # Shared helpers — build, validate-and-wrap, source summary, graph helpers + # ===================================================================== + + @doc false + def build(attrs) do + {node_specs, attrs} = Keyword.pop(attrs, :nodes, []) + {rel_specs, attrs} = Keyword.pop(attrs, :relationships, []) + + attrs = + if node_specs != [] or rel_specs != [] do + Keyword.put(attrs, :graph, build_graph(node_specs, rel_specs)) + else + attrs + end + + struct!(Artefact, [ + {:id, Artefact.UUID.generate_v7()}, + {:uuid, Artefact.UUID.generate_v7()} | attrs + ]) + end + + defp build_graph(node_specs, rel_specs) do + {nodes, key_map} = + node_specs + |> Enum.with_index() + |> Enum.map_reduce(%{}, fn {{key, opts}, i}, acc -> + id = "n#{i}" + + node = %Artefact.Node{ + id: id, + uuid: Keyword.get(opts, :uuid, Artefact.UUID.generate_v7()), + labels: Keyword.get(opts, :labels, []), + properties: Keyword.get(opts, :properties, %{}), + position: Keyword.get(opts, :position) + } + + {node, Map.put(acc, key, id)} + end) + + relationships = + rel_specs + |> Enum.with_index() + |> Enum.map(fn {spec, i} -> + %Artefact.Relationship{ + id: "r#{i}", + from_id: Map.fetch!(key_map, Keyword.fetch!(spec, :from)), + to_id: Map.fetch!(key_map, Keyword.fetch!(spec, :to)), + type: Keyword.fetch!(spec, :type), + properties: Keyword.get(spec, :properties, %{}) + } + end) + + %Artefact.Graph{nodes: nodes, relationships: relationships} + end + + defp deduplicate_rels(rels_a, rels_b) do + index = Map.new(rels_a, fn rel -> {{rel.from_id, rel.type, rel.to_id}, rel} end) + + merged_index = + Enum.reduce(rels_b, index, fn rel, acc -> + key = {rel.from_id, rel.type, rel.to_id} + + case Map.fetch(acc, key) do + {:ok, existing} -> + Map.put(acc, key, %{ + existing + | properties: Map.merge(rel.properties, existing.properties) + }) + + :error -> + Map.put(acc, key, rel) + end + end) + + Map.values(merged_index) + end + + defp merge_graphs(g1, g2) do + offset = length(g1.nodes) + + id_map = + g2.nodes + |> Enum.with_index(offset) + |> Map.new(fn {node, i} -> {node.id, "n#{i}"} end) + + nodes = + g1.nodes ++ + Enum.map(g2.nodes, fn node -> %{node | id: id_map[node.id]} end) + + rels = + g1.relationships ++ + Enum.map(g2.relationships, fn rel -> + %{rel | from_id: id_map[rel.from_id], to_id: id_map[rel.to_id]} + end) + + %Artefact.Graph{nodes: nodes, relationships: rels} + end + + defp portmanteau(nil, nil), do: nil + defp portmanteau(a, nil), do: a + defp portmanteau(nil, b), do: b + defp portmanteau(a, b), do: a <> b + + defp source_summary(%Artefact{} = a) do + %{ + title: a.title, + base_label: a.base_label, + uuid: a.uuid, + provenance: Map.get(a.metadata, :provenance) + } + end + + # Validate the produced artefact; wrap in {:ok, _} or pass the + # {:error, %Invalid{}} through unchanged. + defp finish(%Artefact{} = result) do + case Validator.validate(result) do + :ok -> {:ok, result} + {:error, %Invalid{}} = err -> err + end + end +end diff --git a/artefact/lib/artefact/validator.ex b/artefact/lib/artefact/validator.ex new file mode 100644 index 0000000..d0cc814 --- /dev/null +++ b/artefact/lib/artefact/validator.ex @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: 2026 artefactory contributors +# SPDX-License-Identifier: MIT + +defmodule Artefact.Validator do + @moduledoc """ + Validates `%Artefact{}` structs against the structural rules + documented in `Artefact`. + + Public predicates and validators are surfaced through `Artefact` + itself via `defdelegate`; this module is the implementation home. + + An artefact is *valid* when: + + * its uuid is a valid UUIDv7 + * `:title`, `:description`, `:base_label` are each `nil` or a string + * `:graph` is `%Artefact.Graph{}` with list `:nodes` and `:relationships` + * every node has a non-empty string `:id`, a UUIDv7 `:uuid`, a list + of string `:labels`, and a map `:properties` + * every relationship has a non-empty string `:id`, a non-empty + string `:type`, `:from_id` and `:to_id` referring to extant + nodes, and a map `:properties` + * node uuids, node ids and relationship ids are unique within the + graph + """ + + alias Artefact.Error.Invalid + + @doc "Returns `true` when `value` is an `%Artefact{}` struct." + def is_artefact?(%Artefact{}), do: true + def is_artefact?(_), do: false + + @doc "Returns `true` when `value` is a valid artefact." + def is_valid?(value) do + case validate(value) do + :ok -> true + {:error, _} -> false + end + end + + @doc """ + Validate an artefact. Returns `:ok` or `{:error, %Artefact.Error.Invalid{reasons: [...]}}`. + + `:reasons` is a list of human-readable strings, one per rule + violation, in source order. + """ + def validate(%Artefact{} = a) do + reasons = + [] + |> check(Artefact.UUID.valid?(a.uuid), "uuid is not a valid UUIDv7") + |> check_string_or_nil(a.title, :title) + |> check_string_or_nil(a.description, :description) + |> check_string_or_nil(a.base_label, :base_label) + |> check_graph(a.graph) + + case reasons do + [] -> :ok + _ -> {:error, %Invalid{reasons: Enum.reverse(reasons)}} + end + end + + def validate(_), do: {:error, %Invalid{reasons: ["not an %Artefact{} struct"]}} + + @doc """ + Validate an artefact. Returns `:ok` or raises `Artefact.Error.Invalid` + with the collected reasons. + """ + def validate!(value) do + case validate(value) do + :ok -> :ok + {:error, %Invalid{} = e} -> raise e + end + end + + # ---- helpers ------------------------------------------------------- + + defp check(reasons, true, _msg), do: reasons + defp check(reasons, false, msg), do: [msg | reasons] + + defp check_string_or_nil(reasons, nil, _field), do: reasons + defp check_string_or_nil(reasons, value, _field) when is_binary(value), do: reasons + + defp check_string_or_nil(reasons, _value, field), + do: ["#{field} is not a string or nil" | reasons] + + defp check_graph(reasons, %Artefact.Graph{nodes: nodes, relationships: rels}) + when is_list(nodes) and is_list(rels) do + reasons + |> check_nodes(nodes) + |> check_relationships(rels, nodes) + end + + defp check_graph(reasons, _), + do: ["graph is not %Artefact.Graph{} with list nodes/relationships" | reasons] + + defp check_nodes(reasons, nodes) do + reasons + |> then(fn r -> + nodes + |> Enum.with_index() + |> Enum.reduce(r, fn {n, i}, acc -> check_node(acc, n, i) end) + end) + |> check_unique(Enum.map(nodes, &node_uuid/1), "node uuid") + |> check_unique(Enum.map(nodes, &node_id/1), "node id") + end + + defp node_uuid(%Artefact.Node{uuid: u}), do: u + defp node_uuid(_), do: nil + defp node_id(%Artefact.Node{id: id}), do: id + defp node_id(_), do: nil + + defp check_node(reasons, %Artefact.Node{} = n, idx) do + p = "node[#{idx}]" + + reasons + |> check(is_binary(n.id) and n.id != "", "#{p} id is not a non-empty string") + |> check(Artefact.UUID.valid?(n.uuid), "#{p} uuid is not a valid UUIDv7") + |> check( + is_list(n.labels) and Enum.all?(n.labels, &is_binary/1), + "#{p} labels is not a list of strings" + ) + |> check(is_map(n.properties), "#{p} properties is not a map") + end + + defp check_node(reasons, _, idx), do: ["node[#{idx}] is not %Artefact.Node{}" | reasons] + + defp check_relationships(reasons, rels, nodes) do + node_ids = + MapSet.new(nodes, fn + %Artefact.Node{id: id} -> id + _ -> nil + end) + + reasons = + rels + |> Enum.with_index() + |> Enum.reduce(reasons, fn {r, i}, acc -> check_relationship(acc, r, i, node_ids) end) + + check_unique( + reasons, + Enum.map(rels, fn + %Artefact.Relationship{id: id} -> id + _ -> nil + end), + "relationship id" + ) + end + + defp check_relationship(reasons, %Artefact.Relationship{} = r, idx, node_ids) do + p = "relationship[#{idx}]" + + reasons + |> check(is_binary(r.id) and r.id != "", "#{p} id is not a non-empty string") + |> check(is_binary(r.type) and r.type != "", "#{p} type is not a non-empty string") + |> check( + MapSet.member?(node_ids, r.from_id), + "#{p} from_id #{inspect(r.from_id)} not in graph" + ) + |> check(MapSet.member?(node_ids, r.to_id), "#{p} to_id #{inspect(r.to_id)} not in graph") + |> check(is_map(r.properties), "#{p} properties is not a map") + end + + defp check_relationship(reasons, _, idx, _), + do: ["relationship[#{idx}] is not %Artefact.Relationship{}" | reasons] + + defp check_unique(reasons, list, label) do + duplicates = (list -- Enum.uniq(list)) |> Enum.uniq() |> Enum.reject(&is_nil/1) + + case duplicates do + [] -> reasons + dupes -> ["duplicate #{label}s: #{inspect(dupes)}" | reasons] + end + end +end diff --git a/artefact/mix.exs b/artefact/mix.exs index 5d27d20..121c326 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.5" + @version "0.2.0" @github_url "https://github.com/diffo-dev/artefactory" def project do @@ -25,7 +25,7 @@ defmodule Artefact.MixProject do end defp elixirc_paths(:test), do: ["lib", "test/support"] - defp elixirc_paths(_), do: ["lib"] + defp elixirc_paths(_), do: ["lib"] def application do [extra_applications: [:logger]] @@ -34,6 +34,7 @@ defmodule Artefact.MixProject do defp deps do [ {:jason, "~> 1.4"}, + {:splode, "~> 0.3"}, {:ex_doc, "~> 0.37", only: [:dev, :test], runtime: false} ] end @@ -41,7 +42,7 @@ defmodule Artefact.MixProject do defp package do [ licenses: ["MIT"], - files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* LICENSES), + files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* MIGRATION* LICENSES), links: %{"GitHub" => @github_url} ] end @@ -54,6 +55,7 @@ defmodule Artefact.MixProject do extras: [ "README.md", "CHANGELOG.md", + "MIGRATION.md", {"LICENSES/MIT.txt", title: "License (MIT)"} ] ] diff --git a/artefact/mix.lock b/artefact/mix.lock index 3d66be4..550b7ce 100644 --- a/artefact/mix.lock +++ b/artefact/mix.lock @@ -6,4 +6,5 @@ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, } diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs index 695683b..fb954dc 100644 --- a/artefact/test/artefact_test.exs +++ b/artefact/test/artefact_test.exs @@ -19,12 +19,12 @@ defmodule ArtefactTest do do: %Artefact.Node{id: "n1", uuid: uuid, labels: ["Other"], properties: %{}} defp artefact_with(nodes) do - Artefact.new(graph: %Artefact.Graph{nodes: nodes, relationships: []}) + Artefact.new!(graph: %Artefact.Graph{nodes: nodes, relationships: []}) end describe "provenance" do test "new records :struct provenance with calling module" do - a = Artefact.new() + a = Artefact.new!() assert %{provenance: %{source: :struct, module: ArtefactTest}} = a.metadata end @@ -41,9 +41,9 @@ defmodule ArtefactTest do end test "compose records :composed provenance with left and right title, base_label, uuid and provenance" do - a1 = Artefact.new() - a2 = Artefact.new() - composed = Artefact.compose(a1, a2) + a1 = Artefact.new!() + a2 = Artefact.new!() + composed = Artefact.compose!(a1, a2) assert %{ provenance: %{ @@ -76,19 +76,19 @@ defmodule ArtefactTest do test "harmonise records :harmonised provenance with left and right title, base_label, uuid and provenance" do a1 = - Artefact.new( + Artefact.new!( base_label: "LeftArtefact", graph: %Artefact.Graph{nodes: [shared_node()], relationships: []} ) a2 = - Artefact.new( + Artefact.new!( base_label: "RightArtefact", graph: %Artefact.Graph{nodes: [shared_node()], relationships: []} ) {:ok, bindings} = Artefact.Binding.find(a1, a2) - result = Artefact.harmonise(a1, a2, bindings) + result = Artefact.harmonise!(a1, a2, bindings) assert %{ provenance: %{ @@ -123,7 +123,7 @@ defmodule ArtefactTest do describe "Artefact.new/1 — inline nodes and relationships" do test "builds nodes with sequential ids" do a = - Artefact.new( + Artefact.new!( nodes: [ matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}], claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}] @@ -139,7 +139,7 @@ defmodule ArtefactTest do test "nodes have correct labels and properties" do a = - Artefact.new( + Artefact.new!( nodes: [ matt: [labels: ["Agent", "Me"], properties: %{"name" => "Matt"}], claude: [labels: ["Agent", "You"], properties: %{"name" => "Claude"}] @@ -155,7 +155,7 @@ defmodule ArtefactTest do end test "nodes get auto-generated uuids" do - a = Artefact.new(nodes: [n: [labels: ["X"]]], relationships: []) + a = Artefact.new!(nodes: [n: [labels: ["X"]]], relationships: []) [node] = a.graph.nodes assert is_binary(node.uuid) assert String.length(node.uuid) == 36 @@ -163,14 +163,14 @@ defmodule ArtefactTest do test "uuid option is preserved" do fixed_uuid = "019da897-f2de-77ca-b5a4-40f0c3730943" - a = Artefact.new(nodes: [n: [labels: [], uuid: fixed_uuid]], relationships: []) + a = Artefact.new!(nodes: [n: [labels: [], uuid: fixed_uuid]], relationships: []) [node] = a.graph.nodes assert node.uuid == fixed_uuid end test "builds relationship resolving atom keys to ids" do a = - Artefact.new( + Artefact.new!( nodes: [ matt: [labels: ["Agent"]], claude: [labels: ["Agent"]] @@ -188,7 +188,7 @@ defmodule ArtefactTest do test "relationship properties default to empty map" do a = - Artefact.new( + Artefact.new!( nodes: [a: [labels: []], b: [labels: []]], relationships: [[from: :a, type: "KNOWS", to: :b]] ) @@ -199,7 +199,7 @@ defmodule ArtefactTest do test "relationship properties are set when provided" do a = - Artefact.new( + Artefact.new!( nodes: [a: [labels: []], b: [labels: []]], relationships: [[from: :a, type: "KNOWS", to: :b, properties: %{"since" => "2024"}]] ) @@ -209,13 +209,13 @@ defmodule ArtefactTest do end test "empty nodes and relationships produces empty graph" do - a = Artefact.new(title: "Empty", nodes: [], relationships: []) + a = Artefact.new!(title: "Empty", nodes: [], relationships: []) assert a.graph.nodes == [] assert a.graph.relationships == [] end test "no nodes or relationships key leaves graph as default" do - a = Artefact.new(title: "NoGraph") + a = Artefact.new!(title: "NoGraph") assert a.graph == %Artefact.Graph{} end end @@ -223,7 +223,7 @@ defmodule ArtefactTest do describe "Artefact.new/1 — inline nodes and relationships — multiple relationships" do setup do a = - Artefact.new( + Artefact.new!( nodes: [x: [labels: ["X"]], y: [labels: ["Y"]], z: [labels: ["Z"]]], relationships: [ [from: :x, type: "NEXT", to: :y], @@ -259,7 +259,7 @@ defmodule ArtefactTest do from_json = Artefact.Arrows.from_json!(json) from_struct = - Artefact.new( + Artefact.new!( title: "UsTwo", base_label: "UsTwo", nodes: [ @@ -483,7 +483,7 @@ defmodule ArtefactTest do a1 = artefact_nodes([n_a]) a2 = artefact_nodes([n_b]) {:ok, bindings} = Artefact.Binding.find(a1, a2) - result = Artefact.harmonise(a1, a2, bindings) + result = Artefact.harmonise!(a1, a2, bindings) [merged] = result.graph.nodes assert merged.properties == %{"key_a" => "from_a", "key_b" => "from_b"} end @@ -494,7 +494,7 @@ defmodule ArtefactTest do a1 = artefact_nodes([n_a]) a2 = artefact_nodes([n_b]) {:ok, bindings} = Artefact.Binding.find(a1, a2) - result = Artefact.harmonise(a1, a2, bindings) + result = Artefact.harmonise!(a1, a2, bindings) [merged] = result.graph.nodes assert merged.properties == %{"key" => "same"} end @@ -505,7 +505,7 @@ defmodule ArtefactTest do a1 = artefact_nodes([n_a]) a2 = artefact_nodes([n_b]) {:ok, bindings} = Artefact.Binding.find(a1, a2) - result = Artefact.harmonise(a1, a2, bindings) + result = Artefact.harmonise!(a1, a2, bindings) [merged] = result.graph.nodes assert merged.properties["key"] == "left" end @@ -516,7 +516,7 @@ defmodule ArtefactTest do a1 = artefact_nodes([n_a]) a2 = artefact_nodes([n_b]) {:ok, bindings} = Artefact.Binding.find(a1, a2) - result = Artefact.harmonise(a1, a2, bindings) + result = Artefact.harmonise!(a1, a2, bindings) [merged] = result.graph.nodes assert Enum.sort(merged.labels) == ["LabelA", "LabelB"] end @@ -539,30 +539,46 @@ defmodule ArtefactTest do a1 = artefact_nodes([n_a]) a2 = artefact_nodes([n_b]) {:ok, bindings} = Artefact.Binding.find(a1, a2) - result = Artefact.harmonise(a1, a2, bindings) + result = Artefact.harmonise!(a1, a2, bindings) [merged] = result.graph.nodes assert Enum.sort(merged.labels) == ["OnlyA", "OnlyB", "Shared"] end end describe "Artefact.harmonise/4 — guards" do - test "raises when harmonising an artefact with itself" do + test "returns :self_harmonise when harmonising an artefact with itself" do a = artefact_with([shared_node()]) {:ok, bindings} = Artefact.Binding.find(a, a) - assert_raise ArgumentError, ~r/cannot harmonise an artefact with itself/, fn -> - Artefact.harmonise(a, a, bindings) - end + assert {:error, + %Artefact.Error.Operation{ + op: :harmonise, + tag: :self_harmonise, + details: %{uuid: uuid} + }} = Artefact.harmonise(a, a, bindings) + assert uuid == a.uuid end - test "raises when both artefacts have the same base_label" do - a1 = Artefact.new(base_label: "Same") - a2 = Artefact.new(base_label: "Same") + test "harmonise! raises Artefact.Error.Operation on self-harmonise" do + a = artefact_with([shared_node()]) + {:ok, bindings} = Artefact.Binding.find(a, a) - assert_raise ArgumentError, ~r/cannot harmonise artefacts with the same base_label/, fn -> - Artefact.harmonise(a1, a2, []) + assert_raise Artefact.Error.Operation, ~r/harmonise: self_harmonise/, fn -> + Artefact.harmonise!(a, a, bindings) end end + + test "returns :same_base_label when both artefacts have the same base_label" do + a1 = Artefact.new!(base_label: "Same") + a2 = Artefact.new!(base_label: "Same") + + assert {:error, + %Artefact.Error.Operation{ + op: :harmonise, + tag: :same_base_label, + details: %{base_label: "Same"} + }} = Artefact.harmonise(a1, a2, []) + end end describe "Artefact.harmonise/4 — relationship deduplication" do @@ -610,7 +626,7 @@ defmodule ArtefactTest do ]) {:ok, bindings} = Artefact.Binding.find(a1, a2) - result = Artefact.harmonise(a1, a2, bindings) + result = Artefact.harmonise!(a1, a2, bindings) assert length(result.graph.relationships) == 1 end @@ -638,7 +654,7 @@ defmodule ArtefactTest do ]) {:ok, bindings} = Artefact.Binding.find(a1, a2) - result = Artefact.harmonise(a1, a2, bindings) + result = Artefact.harmonise!(a1, a2, bindings) assert length(result.graph.relationships) == 2 end @@ -666,7 +682,7 @@ defmodule ArtefactTest do ]) {:ok, bindings} = Artefact.Binding.find(a1, a2) - result = Artefact.harmonise(a1, a2, bindings) + result = Artefact.harmonise!(a1, a2, bindings) assert length(result.graph.relationships) == 2 end @@ -694,7 +710,7 @@ defmodule ArtefactTest do ]) {:ok, bindings} = Artefact.Binding.find(a1, a2) - result = Artefact.harmonise(a1, a2, bindings) + result = Artefact.harmonise!(a1, a2, bindings) [rel] = result.graph.relationships assert rel.properties["since"] == "2020" assert rel.properties["trust"] == "high" @@ -1000,7 +1016,7 @@ defmodule ArtefactTest do describe "Artefact.Mermaid.export/2 — escapes and edge cases" do test "falls back to node id when no name property is present" do a = - Artefact.new( + Artefact.new!( base_label: "Bare", nodes: [n: [labels: ["X"]]], relationships: [] @@ -1011,7 +1027,7 @@ defmodule ArtefactTest do test "uses name only when no semantic labels remain" do a = - Artefact.new( + Artefact.new!( base_label: "Solo", nodes: [n: [labels: ["Solo"], properties: %{"name" => "alone"}]], relationships: [] @@ -1024,7 +1040,7 @@ defmodule ArtefactTest do test "escapes double quotes in node names" do a = - Artefact.new( + Artefact.new!( nodes: [q: [labels: [], properties: %{"name" => ~s|she said "hi"|}]], relationships: [] ) @@ -1034,7 +1050,7 @@ defmodule ArtefactTest do test "escapes pipes in relationship type" do a = - Artefact.new( + Artefact.new!( nodes: [a: [labels: []], b: [labels: []]], relationships: [[from: :a, type: "HAS|PIPE", to: :b]] ) @@ -1043,13 +1059,13 @@ defmodule ArtefactTest do end test "empty untitled graph still emits a header" do - a = Artefact.new(title: nil, nodes: [], relationships: []) + a = Artefact.new!(title: nil, nodes: [], relationships: []) assert Artefact.Mermaid.export(a) == "graph LR" end test "untitled artefact omits front-matter and accTitle" do a = - Artefact.new( + Artefact.new!( title: nil, nodes: [n: [labels: ["X"]]], relationships: [] @@ -1062,34 +1078,34 @@ defmodule ArtefactTest do end test "YAML-quotes a title containing a colon" do - a = Artefact.new(title: "Sand Talk: a yarn", nodes: [], relationships: []) + a = Artefact.new!(title: "Sand Talk: a yarn", nodes: [], relationships: []) assert String.contains?(Artefact.Mermaid.export(a), ~s|title: "Sand Talk: a yarn"|) end test "YAML-quotes and escapes a title with a double quote" do - a = Artefact.new(title: ~s|she said "hi"|, nodes: [], relationships: []) + a = Artefact.new!(title: ~s|she said "hi"|, nodes: [], relationships: []) assert String.contains?(Artefact.Mermaid.export(a), ~s|title: "she said \\"hi\\""|) end test "accTitle escaping is independent of YAML quoting" do - a = Artefact.new(title: "Sand Talk: a yarn", nodes: [], relationships: []) + a = Artefact.new!(title: "Sand Talk: a yarn", nodes: [], relationships: []) assert String.contains?(Artefact.Mermaid.export(a), " accTitle: Sand Talk: a yarn") end end describe "Artefact.new/1 — :description option" do test "defaults to nil when not provided" do - a = Artefact.new() + a = Artefact.new!() assert a.description == nil end test "stores the description when provided" do - a = Artefact.new(description: "the simplest true thing about us_two") + a = Artefact.new!(description: "the simplest true thing about us_two") assert a.description == "the simplest true thing about us_two" end test "description is independent of title" do - a = Artefact.new(title: "UsTwo", description: "Me toward You") + a = Artefact.new!(title: "UsTwo", description: "Me toward You") assert a.title == "UsTwo" assert a.description == "Me toward You" end @@ -1098,7 +1114,7 @@ defmodule ArtefactTest do describe "Artefact.Arrows round-trip — description" do test "preserves a set description" do original = - Artefact.new( + Artefact.new!( title: "UsTwo", description: "the simplest true thing", base_label: "UsTwo", @@ -1111,7 +1127,7 @@ defmodule ArtefactTest do end test "preserves a nil description" do - original = Artefact.new(title: "Bare", nodes: [], relationships: []) + original = Artefact.new!(title: "Bare", nodes: [], relationships: []) assert original.description == nil round_tripped = original |> Artefact.Arrows.to_json() |> Artefact.Arrows.from_json!() @@ -1122,7 +1138,7 @@ defmodule ArtefactTest do describe "Artefact.Mermaid.export/2 — description" do test "emits accDescr inline when description is single-line" do a = - Artefact.new( + Artefact.new!( title: "UsTwo", description: "Me toward You", nodes: [], @@ -1134,7 +1150,7 @@ defmodule ArtefactTest do test "uses block form when description contains newlines" do a = - Artefact.new( + Artefact.new!( title: "UsTwo", description: "first line\nsecond line", nodes: [], @@ -1147,14 +1163,14 @@ defmodule ArtefactTest do end test "omits accDescr when description is nil" do - a = Artefact.new(title: "Titled but undescribed", nodes: [], relationships: []) + a = Artefact.new!(title: "Titled but undescribed", nodes: [], relationships: []) mmd = Artefact.Mermaid.export(a) refute String.contains?(mmd, "accDescr") end test "accDescr appears after accTitle and before nodes" do a = - Artefact.new( + Artefact.new!( title: "Order", description: "matters", nodes: [n: [labels: ["X"]]], @@ -1192,7 +1208,7 @@ defmodule ArtefactTest do heart = combine_artefact("Knowing", @uuid_shared) other = combine_artefact("Valuing", @uuid_shared) - result = Artefact.combine(heart, other) + result = Artefact.combine!(heart, other) assert length(result.graph.nodes) == 1 end @@ -1200,7 +1216,7 @@ defmodule ArtefactTest do heart = combine_artefact("Knowing", @uuid_shared) other = combine_artefact("Valuing", @uuid_shared) - result = Artefact.combine(heart, other) + result = Artefact.combine!(heart, other) assert result.base_label == "KnowingValuing" end @@ -1208,7 +1224,7 @@ defmodule ArtefactTest do heart = combine_artefact("Knowing", @uuid_shared) other = combine_artefact("Valuing", @uuid_shared) - result = Artefact.combine(heart, other) + result = Artefact.combine!(heart, other) assert result.title == "KnowingValuing" end @@ -1216,7 +1232,7 @@ defmodule ArtefactTest do heart = combine_artefact("Knowing", @uuid_shared) other = combine_artefact("Valuing", @uuid_shared) - result = Artefact.combine(heart, other) + result = Artefact.combine!(heart, other) assert result.description == nil end @@ -1224,7 +1240,7 @@ defmodule ArtefactTest do heart = combine_artefact("Knowing", @uuid_shared) other = combine_artefact("Valuing", @uuid_shared) - result = Artefact.combine(heart, other, title: "Custom") + result = Artefact.combine!(heart, other, title: "Custom") assert result.title == "Custom" end @@ -1232,7 +1248,7 @@ defmodule ArtefactTest do heart = combine_artefact("Knowing", @uuid_shared) other = combine_artefact("Valuing", @uuid_shared) - result = Artefact.combine(heart, other, description: "yarned") + result = Artefact.combine!(heart, other, description: "yarned") assert result.description == "yarned" end @@ -1240,7 +1256,7 @@ defmodule ArtefactTest do heart = combine_artefact("Knowing", @uuid_shared) other = combine_artefact("Valuing", @uuid_shared) - result = Artefact.combine(heart, other, title: "MeMind", description: "Mind of Me") + result = Artefact.combine!(heart, other, title: "MeMind", description: "Mind of Me") assert result.title == "MeMind" assert result.description == "Mind of Me" end @@ -1250,7 +1266,7 @@ defmodule ArtefactTest do b = combine_artefact("Valuing", @uuid_shared) c = combine_artefact("Being", @uuid_shared) - result = a |> Artefact.combine(b) |> Artefact.combine(c) + result = a |> Artefact.combine!(b) |> Artefact.combine!(c) assert result.base_label == "KnowingValuingBeing" assert length(result.graph.nodes) == 1 end @@ -1262,8 +1278,8 @@ defmodule ArtefactTest do result = a - |> Artefact.combine(b) - |> Artefact.combine(c, title: "MeMind", description: "Mind of Me") + |> Artefact.combine!(b) + |> Artefact.combine!(c, title: "MeMind", description: "Mind of Me") assert result.title == "MeMind" assert result.description == "Mind of Me" @@ -1273,15 +1289,25 @@ defmodule ArtefactTest do heart = combine_artefact("Knowing", @uuid_shared) other = combine_artefact("Valuing", @uuid_shared) - result = Artefact.combine(heart, other) + result = Artefact.combine!(heart, other) assert %{provenance: %{source: :harmonised, module: ArtefactTest}} = result.metadata end - test "raises when artefacts have no shared nodes" do + test "returns :no_shared_bindings 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 {:error, %Artefact.Error.Operation{op: :combine, tag: :no_shared_bindings}} = + Artefact.combine(heart, other) + end + + test "combine! raises Artefact.Error.Operation when 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 + assert_raise Artefact.Error.Operation, ~r/combine: no_shared_bindings/, fn -> + Artefact.combine!(heart, other) + end end end @@ -1292,7 +1318,7 @@ defmodule ArtefactTest do left = OurShells.our_shells() result = - Artefact.graft(left, OurShells.manifesto_args(), + Artefact.graft!(left, OurShells.manifesto_args(), title: "Our Shells and Manifesto", description: "Our Shells and Manifesto shape our Association Knowing." ) @@ -1414,7 +1440,7 @@ defmodule ArtefactTest do test "title and description fall back to left when opts omits them" do left = OurShells.our_shells() - result = Artefact.graft(left, OurShells.manifesto_args()) + result = Artefact.graft!(left, OurShells.manifesto_args()) assert result.title == left.title assert result.description == left.description @@ -1422,14 +1448,14 @@ defmodule ArtefactTest do test "right provenance carries nil when opts omits title and description" do left = OurShells.our_shells() - result = Artefact.graft(left, OurShells.manifesto_args()) + result = Artefact.graft!(left, OurShells.manifesto_args()) assert %{provenance: %{right: %{title: nil, description: nil}}} = result.metadata end test "base_label in opts is ignored — left's base_label always wins" do left = OurShells.our_shells() - result = Artefact.graft(left, OurShells.manifesto_args(), base_label: "ShouldBeIgnored") + result = Artefact.graft!(left, OurShells.manifesto_args(), base_label: "ShouldBeIgnored") assert result.base_label == left.base_label end @@ -1439,7 +1465,7 @@ defmodule ArtefactTest do @left_uuid "019d0000-0000-7000-8000-0000000000aa" defp single_node_artefact(labels, properties) do - Artefact.new( + Artefact.new!( title: "Left", nodes: [n: [labels: labels, properties: properties, uuid: @left_uuid]], relationships: [] @@ -1450,7 +1476,7 @@ defmodule ArtefactTest do left = single_node_artefact(["LeftLabel"], %{}) result = - Artefact.graft(left, + Artefact.graft!(left, nodes: [n: [labels: ["RightLabel"], uuid: @left_uuid]], relationships: [] ) @@ -1463,7 +1489,7 @@ defmodule ArtefactTest do left = single_node_artefact(["Shared", "OnlyLeft"], %{}) result = - Artefact.graft(left, + Artefact.graft!(left, nodes: [n: [labels: ["Shared", "OnlyRight"], uuid: @left_uuid]], relationships: [] ) @@ -1476,7 +1502,7 @@ defmodule ArtefactTest do left = single_node_artefact([], %{"left_key" => "L"}) result = - Artefact.graft(left, + Artefact.graft!(left, nodes: [n: [properties: %{"right_key" => "R"}, uuid: @left_uuid]], relationships: [] ) @@ -1489,7 +1515,7 @@ defmodule ArtefactTest do left = single_node_artefact([], %{"shared_key" => "from_left"}) result = - Artefact.graft(left, + Artefact.graft!(left, nodes: [n: [properties: %{"shared_key" => "from_right"}, uuid: @left_uuid]], relationships: [] ) @@ -1502,7 +1528,7 @@ defmodule ArtefactTest do left = single_node_artefact(["X"], %{}) result = - Artefact.graft(left, + Artefact.graft!(left, nodes: [n: [uuid: @left_uuid]], relationships: [] ) @@ -1517,7 +1543,7 @@ defmodule ArtefactTest do test "args relationship matching an existing left relationship is deduped (left properties win)" do left = - Artefact.new( + Artefact.new!( title: "Pair", nodes: [ a: [labels: [], properties: %{}, uuid: @uuid_a], @@ -1527,7 +1553,7 @@ defmodule ArtefactTest do ) result = - Artefact.graft(left, + Artefact.graft!(left, nodes: [ a: [uuid: @uuid_a], b: [uuid: @uuid_b] @@ -1552,42 +1578,63 @@ defmodule ArtefactTest do describe "Artefact.graft/3 — guards" do alias Artefact.Test.Fixtures.OurShells - test "raises when an args node is missing :uuid" do + test "returns :missing_uuid when an args node is missing :uuid" do left = OurShells.our_shells() - assert_raise ArgumentError, ~r/graft: node :without_uuid is missing required :uuid/, fn -> - Artefact.graft(left, - nodes: [without_uuid: [labels: ["Knowing"]]], - relationships: [] - ) - end + assert {:error, + %Artefact.Error.Operation{ + op: :graft, + tag: :missing_uuid, + details: %{key: :without_uuid} + }} = + Artefact.graft(left, + nodes: [without_uuid: [labels: ["Knowing"]]], + relationships: [] + ) end - test "raises when args has duplicate node keys" do + test "returns :duplicate_keys when args has duplicate node keys" do left = OurShells.our_shells() - assert_raise ArgumentError, ~r/graft: duplicate node keys/, fn -> - Artefact.graft(left, - nodes: [ - {:dup, [uuid: "019d0000-0000-7000-8000-000000000c01"]}, - {:dup, [uuid: "019d0000-0000-7000-8000-000000000c02"]} - ], - relationships: [] - ) - end + assert {:error, + %Artefact.Error.Operation{ + op: :graft, + tag: :duplicate_keys, + details: %{keys: [:dup]} + }} = + Artefact.graft(left, + nodes: [ + {:dup, [uuid: "019d0000-0000-7000-8000-000000000c01"]}, + {:dup, [uuid: "019d0000-0000-7000-8000-000000000c02"]} + ], + relationships: [] + ) + end + + test "returns :unknown_rel_key when a relationship references a key not in args.nodes" do + left = OurShells.our_shells() + + assert {:error, + %Artefact.Error.Operation{ + op: :graft, + tag: :unknown_rel_key, + details: %{key: :ghost} + }} = + Artefact.graft(left, + nodes: [{:me, [uuid: OurShells.me_uuid()]}], + relationships: [[from: :me, type: "KNOWING", to: :ghost]] + ) end - test "raises when a relationship references a key not in args.nodes" do + test "graft! raises Artefact.Error.Operation on missing uuid" do left = OurShells.our_shells() - assert_raise ArgumentError, - ~r/graft: relationship references unknown node key :ghost/, - fn -> - Artefact.graft(left, - nodes: [{:me, [uuid: OurShells.me_uuid()]}], - relationships: [[from: :me, type: "KNOWING", to: :ghost]] - ) - end + assert_raise Artefact.Error.Operation, ~r/graft: missing_uuid/, fn -> + Artefact.graft!(left, + nodes: [without_uuid: [labels: ["Knowing"]]], + relationships: [] + ) + end end end @@ -1628,7 +1675,7 @@ defmodule ArtefactTest do describe "Artefact.is_artefact?/1" do test "true for an %Artefact{} struct" do - assert Artefact.is_artefact?(Artefact.new()) + assert Artefact.is_artefact?(Artefact.new!()) end test "false for non-artefact values" do @@ -1656,38 +1703,39 @@ defmodule ArtefactTest do end test "fresh Artefact.new is valid" do - assert Artefact.is_valid?(Artefact.new()) - assert Artefact.validate(Artefact.new()) == :ok + assert Artefact.is_valid?(Artefact.new!()) + assert Artefact.validate(Artefact.new!()) == :ok end test "non-artefact returns error" do - assert {:error, ["not an %Artefact{} struct"]} = Artefact.validate(%{}) + assert {:error, %Artefact.Error.Invalid{reasons: ["not an %Artefact{} struct"]}} = + Artefact.validate(%{}) end test "rejects empty uuid on the artefact itself" do - a = %{Artefact.new() | uuid: ""} - assert {:error, reasons} = Artefact.validate(a) + a = %{Artefact.new!() | uuid: ""} + assert {:error, %Artefact.Error.Invalid{reasons: reasons}} = Artefact.validate(a) assert Enum.any?(reasons, &(&1 =~ "uuid is not a valid UUIDv7")) end test "rejects non-list labels on a node" do n = %Artefact.Node{id: "n0", uuid: @good_uuid_a, labels: "Engine", properties: %{}} a = valid_artefact_with([n], []) - assert {:error, reasons} = Artefact.validate(a) + assert {:error, %Artefact.Error.Invalid{reasons: reasons}} = Artefact.validate(a) assert Enum.any?(reasons, &(&1 =~ "labels is not a list of strings")) end test "rejects list-of-non-strings labels on a node" do n = %Artefact.Node{id: "n0", uuid: @good_uuid_a, labels: [:Engine], properties: %{}} a = valid_artefact_with([n], []) - assert {:error, reasons} = Artefact.validate(a) + assert {:error, %Artefact.Error.Invalid{reasons: reasons}} = Artefact.validate(a) assert Enum.any?(reasons, &(&1 =~ "labels is not a list of strings")) end test "rejects non-map properties on a node" do n = %Artefact.Node{id: "n0", uuid: @good_uuid_a, labels: [], properties: []} a = valid_artefact_with([n], []) - assert {:error, reasons} = Artefact.validate(a) + assert {:error, %Artefact.Error.Invalid{reasons: reasons}} = Artefact.validate(a) assert Enum.any?(reasons, &(&1 =~ "properties is not a map")) end @@ -1703,7 +1751,7 @@ defmodule ArtefactTest do } a = valid_artefact_with([n], [r]) - assert {:error, reasons} = Artefact.validate(a) + assert {:error, %Artefact.Error.Invalid{reasons: reasons}} = Artefact.validate(a) assert Enum.any?(reasons, &(&1 =~ ~s(from_id "ghost" not in graph))) end @@ -1720,7 +1768,7 @@ defmodule ArtefactTest do } a = valid_artefact_with([n0, n1], [r]) - assert {:error, reasons} = Artefact.validate(a) + assert {:error, %Artefact.Error.Invalid{reasons: reasons}} = Artefact.validate(a) assert Enum.any?(reasons, &(&1 =~ "type is not a non-empty string")) end @@ -1728,7 +1776,7 @@ defmodule ArtefactTest do n0 = %Artefact.Node{id: "n0", uuid: @good_uuid_a, labels: [], properties: %{}} n1 = %Artefact.Node{id: "n1", uuid: @good_uuid_a, labels: [], properties: %{}} a = valid_artefact_with([n0, n1], []) - assert {:error, reasons} = Artefact.validate(a) + assert {:error, %Artefact.Error.Invalid{reasons: reasons}} = Artefact.validate(a) assert Enum.any?(reasons, &(&1 =~ "duplicate node uuids")) end @@ -1736,7 +1784,7 @@ defmodule ArtefactTest do n0 = %Artefact.Node{id: "n0", uuid: @good_uuid_a, labels: [], properties: %{}} n1 = %Artefact.Node{id: "n0", uuid: @good_uuid_b, labels: [], properties: %{}} a = valid_artefact_with([n0, n1], []) - assert {:error, reasons} = Artefact.validate(a) + assert {:error, %Artefact.Error.Invalid{reasons: reasons}} = Artefact.validate(a) assert Enum.any?(reasons, &(&1 =~ "duplicate node ids")) end @@ -1744,19 +1792,19 @@ defmodule ArtefactTest do n = %Artefact.Node{id: "n0", uuid: "", labels: [], properties: %{}} a = valid_artefact_with([n], []) refute Artefact.is_valid?(a) - assert Artefact.is_valid?(Artefact.new()) + assert Artefact.is_valid?(Artefact.new!()) end end describe "Artefact.validate!/1" do test ":ok for a valid artefact" do - assert Artefact.validate!(Artefact.new()) == :ok + assert Artefact.validate!(Artefact.new!()) == :ok end - test "raises ArgumentError with reasons for invalid artefact" do - a = %{Artefact.new() | uuid: ""} + test "raises Artefact.Error.Invalid with reasons for invalid artefact" do + a = %{Artefact.new!() | uuid: ""} - assert_raise ArgumentError, + assert_raise Artefact.Error.Invalid, ~r/invalid artefact:.*uuid is not a valid UUIDv7/, fn -> Artefact.validate!(a) end end @@ -1765,88 +1813,106 @@ defmodule ArtefactTest do describe "Artefact.graft/3 — input rejection (validation)" do alias Artefact.Test.Fixtures.OurShells - test "raises when a node :uuid is empty string" do + test "returns :invalid_uuid when a node :uuid is empty string" do left = OurShells.our_shells() - assert_raise ArgumentError, ~r/:uuid "" is not a valid UUIDv7/, fn -> - Artefact.graft(left, - nodes: [bad: [labels: ["Knowing"], uuid: ""]], - relationships: [] - ) - end + assert {:error, + %Artefact.Error.Operation{ + op: :graft, + tag: :invalid_uuid, + details: %{key: :bad, uuid: ""} + }} = + Artefact.graft(left, + nodes: [bad: [labels: ["Knowing"], uuid: ""]], + relationships: [] + ) end - test "raises when a node :uuid is malformed" do + test "returns :invalid_uuid when a node :uuid is malformed" do left = OurShells.our_shells() - assert_raise ArgumentError, ~r/is not a valid UUIDv7/, fn -> - Artefact.graft(left, - nodes: [bad: [labels: ["X"], uuid: "not-a-uuid"]], - relationships: [] - ) - end + assert {:error, %Artefact.Error.Operation{op: :graft, tag: :invalid_uuid}} = + Artefact.graft(left, + nodes: [bad: [labels: ["X"], uuid: "not-a-uuid"]], + relationships: [] + ) end - test "raises when a node :labels is not a list" do + test "returns :invalid_labels when a node :labels is not a list" do left = OurShells.our_shells() - assert_raise ArgumentError, ~r/:labels "Engine" is not a list of strings/, fn -> - Artefact.graft(left, - nodes: [bad: [labels: "Engine", uuid: "019d0000-0000-7000-8000-0000000000d1"]], - relationships: [] - ) - end - end - - test "raises when a node :properties is not a map" do + assert {:error, + %Artefact.Error.Operation{ + op: :graft, + tag: :invalid_labels, + details: %{key: :bad, labels: "Engine"} + }} = + Artefact.graft(left, + nodes: [ + bad: [labels: "Engine", uuid: "019d0000-0000-7000-8000-0000000000d1"] + ], + relationships: [] + ) + end + + test "returns :invalid_properties when a node :properties is not a map" do left = OurShells.our_shells() - assert_raise ArgumentError, ~r/:properties.* is not a map/, fn -> - Artefact.graft(left, - nodes: [bad: [properties: [], uuid: "019d0000-0000-7000-8000-0000000000d2"]], - relationships: [] - ) - end + assert {:error, %Artefact.Error.Operation{op: :graft, tag: :invalid_properties}} = + Artefact.graft(left, + nodes: [ + bad: [properties: [], uuid: "019d0000-0000-7000-8000-0000000000d2"] + ], + relationships: [] + ) end end describe "Artefact.graft/3 — no new islands (#29)" do alias Artefact.Test.Fixtures.OurShells - test "raises when a single new node has no relationship to a bind-only node" do + test "returns :islands when a single new node has no relationship to a bind-only node" do left = OurShells.our_shells() - assert_raise ArgumentError, ~r/disconnected islands/, fn -> - Artefact.graft(left, - nodes: [ - {:me, [uuid: OurShells.me_uuid()]}, - {:floating, [labels: ["X"], uuid: "019d0000-0000-7000-8000-0000000000e1"]} - ], - relationships: [] - ) - end - end - - test "raises when new nodes form a chain disconnected from any bind-only node" do + assert {:error, + %Artefact.Error.Operation{ + op: :graft, + tag: :islands, + details: %{keys: [:floating]} + }} = + Artefact.graft(left, + nodes: [ + {:me, [uuid: OurShells.me_uuid()]}, + {:floating, [labels: ["X"], uuid: "019d0000-0000-7000-8000-0000000000e1"]} + ], + relationships: [] + ) + end + + test "returns :islands when new nodes form a chain disconnected from any bind-only" do left = OurShells.our_shells() - assert_raise ArgumentError, ~r/disconnected islands/, fn -> - Artefact.graft(left, - nodes: [ - {:me, [uuid: OurShells.me_uuid()]}, - {:b, [labels: ["X"], uuid: "019d0000-0000-7000-8000-0000000000e2"]}, - {:c, [labels: ["X"], uuid: "019d0000-0000-7000-8000-0000000000e3"]} - ], - relationships: [[from: :b, type: "X", to: :c]] - ) - end + assert {:error, + %Artefact.Error.Operation{ + op: :graft, + tag: :islands, + details: %{keys: [:b, :c]} + }} = + Artefact.graft(left, + nodes: [ + {:me, [uuid: OurShells.me_uuid()]}, + {:b, [labels: ["X"], uuid: "019d0000-0000-7000-8000-0000000000e2"]}, + {:c, [labels: ["X"], uuid: "019d0000-0000-7000-8000-0000000000e3"]} + ], + relationships: [[from: :b, type: "X", to: :c]] + ) end test "passes when a new node connects directly to a bind-only node" do left = OurShells.our_shells() result = - Artefact.graft(left, + Artefact.graft!(left, nodes: [ {:me, [uuid: OurShells.me_uuid()]}, {:b, [labels: ["X"], uuid: "019d0000-0000-7000-8000-0000000000e4"]} @@ -1861,7 +1927,7 @@ defmodule ArtefactTest do left = OurShells.our_shells() result = - Artefact.graft(left, + Artefact.graft!(left, nodes: [ {:me, [uuid: OurShells.me_uuid()]}, {:b, [labels: ["X"], uuid: "019d0000-0000-7000-8000-0000000000e5"]}, @@ -1880,7 +1946,7 @@ defmodule ArtefactTest do left = OurShells.our_shells() result = - Artefact.graft(left, + Artefact.graft!(left, nodes: [{:me, [uuid: OurShells.me_uuid()]}], relationships: [] ) diff --git a/artefact/test/support/our_shells_fixture.ex b/artefact/test/support/our_shells_fixture.ex index 1c171d1..ab4d00d 100644 --- a/artefact/test/support/our_shells_fixture.ex +++ b/artefact/test/support/our_shells_fixture.ex @@ -71,7 +71,7 @@ defmodule Artefact.Test.Fixtures.OurShells do uuid: @association_uuid ]} - Artefact.new( + Artefact.new!( title: "Our Shells", description: "Our Shells help us value Beings.", nodes: [me, valuing, beings, shells, council, core, association], diff --git a/artefact_kino/CHANGELOG.md b/artefact_kino/CHANGELOG.md index 0feae67..b70532d 100644 --- a/artefact_kino/CHANGELOG.md +++ b/artefact_kino/CHANGELOG.md @@ -5,6 +5,10 @@ SPDX-License-Identifier: MIT # Changelog +## 0.2.0 — 2026-05-05 + +- Bumps `artefact` requirement to `~> 0.2.0`. `ArtefactKino.new/1,2` continues to validate its input via `Artefact.validate!/1`, which now raises `Artefact.Error.Invalid` instead of `ArgumentError` when an invalid artefact is passed in. Behaviour is otherwise unchanged. + ## 0.1.5 — 2026-05-05 - `ArtefactKino.new/1,2` now calls `Artefact.validate!/1` on its input — a hand-built `%Artefact{}` with malformed fields (non-list labels, missing uuid, dangling relationship endpoint, etc.) raises `ArgumentError` with structured reasons instead of a cryptic render-time error. Closes [#28]. Bumps `artefact` requirement to `~> 0.1.5` for the new validation API. diff --git a/artefact_kino/mix.exs b/artefact_kino/mix.exs index 0bd9e4f..fa2d876 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.5" + @version "0.2.0" @github_url "https://github.com/diffo-dev/artefactory" def project do @@ -42,13 +42,13 @@ defmodule ArtefactKino.MixProject do defp artefact_dep do cond do System.get_env("HEX_PUBLISH") == "1" -> - {:artefact, "~> 0.1.5"} + {:artefact, "~> 0.2.0"} File.exists?(Path.join(__DIR__, "../artefact/mix.exs")) -> {:artefact, path: "../artefact"} true -> - {:artefact, "~> 0.1.5"} + {:artefact, "~> 0.2.0"} end end