The documented behaviour is now the real behaviour: every option and op the docs promised works, and a derive string that used to be dropped silently fails to compile.
Tracking PR: #15
Bugs:
- Fix
main_validator: {Mod, :fun}on the section and on asub_field— it was never called, only amain_validator/1defined in the module ran. The option now wins; amain_validator/1in the module still runs when the option is not set - Fix the
validate_derive/sanitize_derivesection options — only the application config was used at runtime. The modules of the section option (and of itssub_fields) are tried first, the application config serves the ops they leave unhandled.Validate.field/4andValidate.partial/2read them too - Fix
sanitize(default_when_nil=...)— it never filled anilor missing field because derives skipnil. It now does, and every op of the string then runs on the default, wherever it sits in the string; every other op still skipsnil - Fix the derive parser silently dropping operands:
equal=Type::value(equal=Integer::1,String::,Float::,Atom::,Map::,Tuple::), negative numbers (min_len=-2,clamp=[-10, 10],default_when_empty=-1), floats and other literals (default_when_nil=false). They are parsed now; an operand that can't be parsed, a derives string the Elixir parser refuses (such asenum=Atom[a, b]) andenum=Integer[...]/equal=Integer::...values that aren't numbers raiseSpark.Error.DslErrorat compile time. A field whose derives were all dropped is no longer set tonil - Fix the derive parser silently dropping a group it cannot use: one that is not
sanitize(...)orvalidate(...)(validte(string),foo) was skipped, and a group with no closing)(validate(string) was closed for you. Both raiseSpark.Error.DslErrorthat names the bad part when the module compiles, andValidate.run/2returns the:bad_parameterserror. An empty or blank string stays a no-op. A string that relied on the missing)needs it written - Fix a
validate()/sanitize()group with no ops setting the field tonil— the value is kept - Fix
equal=String::valuenever matching once pre-evaluated - Fix
builder/1turning every struct other thanDate,DateTimeandNaiveDateTimeinto a plain map, which madevalidate(time_struct)/validate(struct)fail for%Time{}/%URI{}. Structs are kept intact - Fix extension ops inside
each=[...]/optional=[...]being a compile error (unknown validate op) whileeither=[...]accepted them. Ops of the module'sderive_extensions:, of the global config and (when avalidate_derive/sanitize_deriveis set) of those modules are accepted - Fix
domain:rules not being evaluated inside aGuardedStruct.AshResourceresource — the extension now runsParseDomain(andParseCoreKeys) like the standalone DSL - Fix the optional packages
ex_url,ex_phone_numberandemail_checkerbeing detected while GuardedStruct itself compiled. When it compiled before them, as in aMix.installthat lists it first,validate(tell),validate(tell=98),validate(geo_url),validate(location)andvalidate(email)all failed withUnexpected type error. The package is looked up when the op runs, and an op whose package is missing returns an error that names the package to add - Fix
Validate.run/2skipping the checks a module makes of its derive strings:validate(max_len=foo)returned{:ok, value}, so did a string the parser refuses such asvalidate(enum=Atom[a, b]), andvalidate(equal=Integer::abc)raisedArgumentError. It now returns{:error, [%{field: :__value__, action: :bad_parameters, message: ...}]}for each of them. The inner ops ofeach=[...]/optional=[...]may be the ops of the global derive extensions, or any name when avalidate_derive/sanitize_deriveis configured - Fix the Ash extension not accepting
guardedstruct opts do … end,sub_field name, type, opts do … endandconditional_field name, type, opts do … end—undefined function sub_field/4— although the docs show them anduse GuardedStructtakes them. They work in a resource now, next to the Spark form that sets each option as a setter at the top of its block (auto_wire true); the@derivesdecorator stays standalone-only - Fix
dynamic_fieldinside asub_fieldnot being identity-preserved: the parent converted its keys ("role"came back as:role). Keys stay as given in asub_fieldof any depth, a listsub_field, astruct:module and aconditional_field'ssub_field;Validate.partial/2keeps them too - Fix
GuardedStruct.Info.fields/1raisingKeyErroron a pattern-keyed (regex) module — it lists the patterns.field_kind/2returns:pattern_fieldfor a key a pattern matches, andfield/2/field?/2follow the patterns - Fix
enforce_keys(:all)listing every key of asub_fieldinstead of only the enforced ones - Fix the
validator:error of a pattern-keyed field missing:field— it carriesfield: :__map__like the other errors of the module - Fix
GuardedStruct.Runtime.build_pattern_map/3being typed to take a map: the generatedbuilder/2of a pattern-keyed module also passes the keyed and typed tuple forms and gets:bad_parametersback, so Dialyzer reported every such module in the project that uses the library. The second argument is any term - Fix the input type of
GuardedStruct.Derive.derive/1: it had no:derivekey and took a map asderive_ops, while the builder andValidate.run/2passed the key andderive_opsmay be a list of maps ornil. Dialyzer reported the call as one that cannot succeed. The key, which nothing read, is no longer passed
Refactors:
- Remove four clauses no input reaches: the catch-alls of
Parser.syntax_error_message/1andRuntime.telemetry_result/1, the non-list error clause of the build pipeline, and the non-string clause ofParseDerive.parse_or_raise/3(the schema typesderives:andderive:as:string)
Docs:
- Fix
autobeing documented as filling only missing values — in:addmode it replaces a value the caller sent;:editkeeps it. The Livebook's example labels were inverted - Fix
frombeing documented as filling a field only when it is unset — a source that holds a value replaces the field's own value, a missing ornilsource keeps it - Fix
onbeing documented as comparing a value (on: "role=admin") and as emitting a:domain_parameterserror — it only checks that the path is present and emits:dependent_keys;domaincompares the value - Fix
validate(url)being documented as needingex_url(it needs no package) andvalidate(tell=98)as needing onlyex_url(it also needsex_phone_number); document that the order of the optional dependencies does not matter - Fix
priority: truebeing documented on a child — it works on theconditional_fieldand keeps only the first child's error when none match - Fix the README example that wrote a
sanitize(...)group aftervalidate(...); everysanitizegroup runs before everyvalidategroup - Fix the verifiers being documented as rejecting the module — they only warn at compile time and the module still compiles
- Fix
usage-rules/api.md:Diff.diff/2returns{:changed, old, new}and the raising form isbuilder(attrs, true) - Fix the
domainexampleAtom[admin::moderator]toAtom[admin, moderator] - Fix
no_controlbeing documented as stripping every control character — tab, LF and CR are kept - Fix
html_sanitize_exbeing documented as optional; the minimum is Elixir 1.17 and the Livebook'sMix.installversions install - Fix the
custom=[Mod, :ok?]sample inusage-rules/ash.md(the op was dropped); documentmain_validatoron asub_field, thevalidate_derive/sanitize_deriveprecedence, the operand forms andInfoon pattern-keyed modules - Document
main_validator,validate_deriveandsanitize_derivein the DSL cheat sheets
Tests:
- Add the tests for each of the fixes above:
derive_operands_test.exs,derive_section_options_test.exsand new cases in the parser, op-param, registry, derive, extension, validator, Ash, dynamic, Info and pattern-map suites,core_keys_test.exsforfrom,onanddomain,optional_packages_test.exsfor a missing or late package,validate_run_derive_test.exsfor a wrong derive string inValidate.run/2, and the option forms of the Ash extension inash_resource_test.exsandash_resource_change_test.exs - Add the keyed and typed tuple forms of
builder/1on a pattern-keyed module topattern_map_test.exs
Dependencies:
- Update
ash3.33.11 → 3.36.1,spark2.7.3 → 2.7.8,sourceror1.12.3 → 1.12.4 (anddecimal3.2.0,ex_ast0.16.0,makeup1.2.3,req0.7.5,spitfire0.4.3,sweet_xmlmaster) - Raise the dev/test requirements to
{:ash, "~> 3.36"}and{:sourceror, "~> 1.12"} - Add
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}and adialyzerproject config (plt_add_apps: [:mix]);mix dialyzerruns in:test, where the optional packages and the fixtures are, and reports no warnings
Changelog for GuardedStruct 0.1.0
We are delighted to introduce v0.1.0 — a from-scratch rewrite of the macro core on top of Spark. Every existing 0.0.x public API is preserved. Bump the dep, run
mix deps.get, and existing tests stay green.
Tracking PR: #13
Features:
- Rewrite the 2,910-LOC
defmacrocore onSpark.Dsl.Extensionwith one:guardedstructsection, five entities (field,sub_field,conditional_field,virtual_field,dynamic_field), six transformers, three verifiers #13 - Add
Pattern-keyed maps—fieldwhose name is a regex declares a free-form map shape (closes #11) #13 - Add
virtual_field— validated through the full pipeline but excluded fromdefstruct(closes #5) #13 - Add
dynamic_field— free-form map with passthrough; atom-attack-safe (string keys stay strings) #13 - Add Erlang
Recordsupport viavalidate(record)andvalidate(record=tag)(closes #6) #13 - Add
GuardedStruct.Validatestandalone API —Validate.run/2,Validate.field/3,4,Validate.partial/2(closes #2) #13 - Add Spark-native custom derive DSL —
use GuardedStruct.Derive.Extension+derives do validator/2, sanitizer/2 endfor declarative custom ops #13 - Add per-module
derive_extensions:opt with:configsentinel for in-position merge with global registry #13 - Add compile-time shadow warning when a custom op-name collides with a built-in registered in
Derive.Registry#13 - Add
Splodeerror wrapping —GuardedStruct.Errors.from_tuple/1,traverse_errors/2,to_class/1, JSON-serializable shape (opt-in) #13 - Add
GuardedStruct.AshResourceextension — same DSL insideAsh.Resource; generates__guarded_change__/1,__guarded_information__/0,__guarded_fields__/0under the prefixed namespace #13 - Add
GuardedStruct.AshResource.Change— ready-madeAsh.Resource.Changemodule bridging__guarded_change__/1into the changeset pipeline #13 - Add
auto_wire: truesection option — Spark transformer injects the change into the resource'schangessection viaAsh.Resource.Builder.add_change/3; no manual wiring needed #13 - Add
batch_change/3on the Ash change —Ash.bulk_create/3andAsh.bulk_update/3(withstrategy: :stream) work end-to-end #13 - Add auto-map cascade for the Ash extension — every nested
sub_fieldreturns a plain map at every depth (matches Ash's:mapattribute type) #13 - Add Ash atomic-mode support —
Change.atomic/3runs the pipeline on plain literal inputs (from bothchangeset.attributesandchangeset.atomics) and returns{:atomic, sanitized_map}; updates stay atomic withoutrequire_atomic? false. Only theAsh.Changeset.atomic_update/3+Ash.Exprpath falls back to{:not_atomic, reason}#13 - Add
json: truesection option — auto-derivesJason.Encoder(if:jasonin deps) with fallback to built-inJSON.Encoderon Elixir 1.18+ #13 - Add
GuardedStruct.Info— full introspection API:describe/1,field_kind/2,enforce?/1,2,virtual?/2,dynamic?/2,sub_module/2,conditional_children/2, collection helpers, section-option shorthands #13 - Add
GuardedStruct.Diff—diff/2,apply/2,equal?/2for audit-log-friendly struct diffing #13 - Add
MyStruct.example/0— REPL helper returning a struct populated with defaults / type placeholders #13 Add telemetry events —
[:guarded_struct, :builder, :start | :stop | :exception]on every top-levelbuilder/1call #13- Add
@derivesdecorator attribute — alternative to inlinederives:for keeping fields short #13 - Add editor autocomplete inside
guardedstruct do … endvia Spark's ElixirSense plugin (closes #1) #13 - Add igniter installer —
mix igniter.install guarded_struct#13 - Add
each=[ops]combinator on both sanitize and validate — applies inner ops to every element of a list; validate error message reports failing indices. - Add
optional=[ops]validator wrapper — passesnilthrough, runs inner ops on non-nil values. - Add list hygiene sanitizers —
:uniq,:compact,:reject_empty,:sort. - Add string hygiene sanitizers —
:squish(collapse runs of whitespace + trim),:no_control(strip ASCII control chars, but keep tab, LF and CR),:no_zero_width(strip zero-width unicode). - Add
:rich_text_safesanitizer — sugar that composes:no_zero_width+:no_controlin one named op for template/content/bio fields; non-binary inputs pass through untouched. - Add named regex validators —
:slug,:hostname,:port_number,:hex_color,:semver. Patterns are anchored, bounded, and ReDoS-safe; compiled once at module load. - Add
:language_codevalidator — BCP-47 / ISO 639-1 shape (e.g.en,en-US,zh-Hant,pt-BR). Anchored regex, case-insensitive, primary 2–3 letters + optional-subtagsegments (2–8 alnum each). - Add datetime shape validators —
:utc_datetime,:naive_datetime,:date_struct,:time_struct. Each accepts both the matching Elixir struct and the ISO-8601 binary form, so the same op covers Ash struct inputs and raw string inputs from JSON/form bodies. - Add datetime comparison validators —
:past_datetime,:future_datetime. Inclusive semantics: "now" passes both, so callers never trip on clock skew between submit and validate. - Add
:ipv6validator — accepts only literal v6 addresses; rejects v4-as-v6 by requiring at least one:character before handing off to:inet.parse_ipv6_address/1. - Add
:ipvalidator — accepts either v4 or v6 form; thin wrapper that tries v4 first, falls back to v6. - Add
{:clamp, [min, max]}sanitizer — snap out-of-range numbers to the nearest bound. - Add
{:default_when_nil, value}/{:default_when_empty, value}sanitizers — fill missing values in the pipeline. - Compile-time param shape validation extended to every new parameterised op via
OpParamValidator. - Localised error messages added through the
Messagescallbacks forslug,hostname,port_number,hex_color,semver,each,language_code,utc_datetime,naive_datetime,date_struct,time_struct,past_datetime,future_datetime,ipv6, andip.
Refactors:
- Move every static-string parse to compile time — derive op-strings,
from:/on:paths,domain:patterns are now parsed once during compilation; the runtime reads pre-built op-maps from__fields__/0#13 - Pre-evaluate
enum=Map[…]/enum=Tuple[…]/equal=Map::…operands at compile time — zeroCode.eval_stringon the runtime hot path #13 - Replace plain-macro
validator/2andsanitizer/2with proper Spark entities underderives do ... endblock — Spark.Formatter handles paren-stripping consistently with the rest of the DSL #13 - Surface compile-time warnings via the transformer's documented
{:warn, dsl_state, warnings}return shape instead ofIO.warn/2— shadow detection inDerive.Extension.Transformers.Codegenand legacy-derive:deprecation inTransformers.ParseDeriveboth flow through Spark, so warnings appear at the user's source line and the DSL state remains the transformer's only side effect #13 - Rename
__guarded_validate__/1→__guarded_change__/1on the Ash extension — name reflects that the function transforms (sanitize, auto-fill) as well as validates #13 - Rename
derive:option toderives:(plural) — aligns with the@derivesdecorator; legacyderive:still works but the transformer emits a compile-time deprecation warning through Spark's{:warn, ...}transformer return #13 - Rename
jason: truesection option tojson: true— option now derives whichever JSON encoder is available (Jason or built-in) #13 - Extract test fixtures (Ash resources + custom-derive modules) to top-level modules in
test/support/so Spark.Formatter applies paren-removal and section-ordering rules uniformly #13 - Normalize the error wire format — every
{:error, …}frombuilder/1,__guarded_change__/1,Validate.run/2,Validate.field/3,4,Validate.partial/2and the AshChangeis always{:error, [error_map, ...]}. Each error map follows the canonical shape%{field: atom, action: atom, message: String, [errors: [...]]}.:required_fieldsand:authorized_fieldsemit one error per affected field instead of one map with afields:list.:bad_parameterscarries:field => :__root__#13 - Flip
GuardedStruct.Derive.SanitizerDerive.sanitize/2to pipe-friendly(value, op)arg order — was(op, value). Applies project-wide:Extension.dispatch_sanitize/2,3, the generated__sanitize__/2callback on extension modules, and any user-supplied:sanitize_derivemodule'ssanitize/2function follow the same convention. Internal hot path now reduces withEnum.reduce(ops, value, fn op, acc -> sanitize(acc, op) end)#13 - Bake compile-time predicates on every guarded module —
__guarded_has_validator__/0,__guarded_has_main_validator__/0,__guarded_error_module__/0,__guarded_field_meta__/1(and Ash's__guarded_field_name_set__/0MapSet) — drops everyfunction_exported?/Code.ensure_loaded?call from the runtime hot path #13 - Cache
GuardedStruct.Derive.Extension.registered_extensions/0in:persistent_term, keyed by raw app config; auto-invalidates when config changes. Newclear_cache/0helper for test setup #13
Bugs:
- Fix nested
conditional_field— works to arbitrary depth viarecursive_as: :conditional_fields(closes #7, #8, #25) #13 - Restore i18n via
GuardedStruct.Messages.translated_message/1,2for orchestration-layer errors (authorized_fields,required_fields,:on/:domaincore keys, list-builder errors) — all 14 message callbacks reachable again #13 - Fix
__information__/0to populateconditional_keyswith actual conditional-field names (was always[]) #13 - Fix
MyStruct.Error.message/1to match master's format and usetranslated_message(:message_exception)for i18n #13 - Unblock the legacy
Parserraisesites that prevented nestedconditional_fieldfrom compiling #13 - Surface malformed
derives:strings asSpark.Error.DslErrorwith file:line — previously swallowed by arescue _ -> niland silently produced no validation #13 - Fix re-entrancy in the auto-map cascade — process-dict flag is saved+restored across nested
validate/3calls so a validator callback can recursively validate without clobbering outer state #13 - Fix
Logger.configure(level: :warning)global side-effect intest_helper.exs— replaced with@moduletag capture_log: trueon Ash test modules #13 - Parser silently dropped the entire derive string when a
regex=<pattern>op contained unquoted special characters (^,[,+,$, …). Fixed viaquote_regex_values/1pre-processor that wraps the pattern in"…"before AST conversion. def validate/3clauses inValidationDerivewere interrupted by fourdefphelpers wedged between them, triggering the compiler's "clauses of the same name should be grouped together" warning and risking mis-dispatch of later clauses. All helpers moved below the catchall; added a dispatch-regression test that probes 43 op atoms with deliberately-bad inputs and asserts none fall through to the catchall.- Per-element
:eacherrors now carry an__index__:key after flattening. Previously errors from list-element validation came back as a nested{:children, [...]}5-tuple with no way to tell which element failed;call/3now flattens children into the top-level errors list with the originating index attached, so callers can pinpoint which list position broke.
Tests:
- Add 743+ tests (up from 146 in 0.0.4), including 6 property-based tests via
stream_data#13 - Add real Ash 3.x integration suite — ETS data layer, end-to-end
Ash.create/1,Ash.update/1,Ash.bulk_create/3,Ash.bulk_update/3#13 - Add
test/ash_integration_test.exsatomic-mode coverage — end-to-end create/update through Ash with sanitize/validate semantics intact under atomic SQL #13 - Add
test/info_test.exs— 38 tests covering every introspection helper includingdescribe/1consolidated dump #13 - Add
test/derive_extension_shadow_warning_test.exs— 9 tests for compile-time shadow detection #13 - Add
test/derive_extensions_per_module_test.exs— 19 tests for per-module opt resolution including the:configsentinel #13 - Add
test/jason_encoder_test.exs— Jason + built-in JSON encoder coverage with nested sub_field #13 - Add
test/telemetry_test.exs— start/stop/exception event coverage, including nested-build inheritance #13
Docs:
- Add full LiveBook walkthrough at
guidance/guarded-struct.livemdwith runnable end-to-end examples #13 - Add auto-generated DSL cheat sheets at
documentation/dsls/viamix spark.cheat_sheets#13 - Add
mix lintandmix cheataliases — wrapspark.formatter+formatandspark.cheat_sheets#13 - Add "Atom-attack safety" section to the
GuardedStructmodule @moduledoc covering the dynamic_field / pattern-keyed map threat model #13 - Add LLM agent context — root
usage-rules.mdplus topic-scoped sub-rules atusage-rules/dsl.md,derive.md,conditional.md,validators.md,core-keys.md,extensions.md,ash.md,api.md,errors.md(compatible with ash-project/usage_rules; consumers runmix usage_rules.syncand address sub-rules asguarded_struct:dsl,guarded_struct:ash, etc.) #13 - Add skills.sh-compatible
SKILL.mdfiles under.claude/skills/— one per subsystem (guarded-struct,-dsl,-derive,-conditional,-ash,-extensions,-api) with YAML frontmatter triggers so Claude Code / Cursor / Copilot auto-load the right context #13
Internals dropped:
- Remove
builder/4@doc falseform (with(actions, key, type, error)arity) — replaced by an internal runtime helper #13 - Remove
register_struct/4,__field__/6,__type__/2,delete_temporary_revaluation/1,create_builder/1,create_error_module/0#13 - Remove the 12
gs_*accumulator module attributes (gs_fields,gs_types,gs_enforce_keys, etc.) — replaced by Spark DSL state #13 - Remove
parser/3(the conditional variant ofParser.parser),elements_unification/2,find_node_tags/1,add_parent_tags/3,conds_list/2,find_conds_children_recursive/2#13 - Remove
Derive.pre_derives_check/3,get_derives_from_success_conditional_data/1,error_handler/2,halt_errors/1, the alternate-shapederive/1clauses #13 - Remove
Messages.unsupported_conditional_field/0andMessages.parser_field_value/0callbacks (dead code after the nested-conditional fix) #13
Dependencies:
- Add
{:spark, "~> 2.7"}(runtime — DSL framework) #13 - Add
{:splode, "~> 0.3"}(runtime — error class hierarchy for opt-in wrapper) #13 - Add
{:telemetry, "~> 1.0"}(runtime — builder events) #13 - Add
{:sourceror, "~> 1.7", only: [:dev, :test]}(required by Spark.Formatter) #13 - Add
{:igniter, "~> 0.8", only: [:dev, :test]}(installer mix task) #13 - Add
{:ash, "~> 3.0", only: [:dev, :test]}(real Ash integration suite — not a runtime dep) #13 html_sanitize_exis a required dependency; the optional deps are unchanged (email_checker,ex_url,ex_phone_number,sweet_xml) #13
Changelog for GuardedStruct 0.0.4
Bugs:
- Fix deprecated code from Elixir 1.18
Features:
- Support overridable messages for the
GuardedStructmodule with support for multiple languages
Changelog for GuardedStruct 0.0.3
Bugs:
- Fix deprecated code from Elixir 1.18.0-rc.0
Changelog for GuardedStruct 0.0.2
Bugs:
- Support charlists sigil warning and keep backward compatibility for charlist regex
Changelog for GuardedStruct 0.0.1
We are delighted to introduce our first standalone release of GuardedStruct — extracted from the Mishka developer tools library.
For more information please see: https://mishka.tools
Features:
- Detach from the Mishka developer tools library
Refactors:
- Remove optional libraries (must be enabled by the user)
- Improvements in some tests