Skip to content

Commit 668fa2c

Browse files
fahchenclaude
andcommitted
fix(dsl): tighten compile-time error reporting and call-site precision
Apply four fixes from PR #85 review feedback: 1. arc.ex `decompose_args/4`: raise CompileError when a positional expression is followed by a `do ... end` block. Previously `input :p, bind({1, x}) do ... end` silently dropped the bind expression and used the block body as the inscription, hiding user mistakes. 2. termination.ex: reject multiple `termination` blocks at macro-expansion time. The previous flow accumulated criteria and `pull_one` returned the stalest entry (List.last on a prepend-accumulated list), silently dropping later declarations. Builder's `pull_one` is simplified to `List.first/1` now that uniqueness is enforced upstream. 3. builder.ex: when `Validators.run/1` returns an identifiable error (`UniqueNameViolationError`, `MissingColourSetError`), point the raised CompileError at the offending declaration's source location instead of the `defmodule` line. Each accumulating macro (`colset`, `var`, `val`, `place`, `function`, `transition`) now also stores `{name, file, line}` metadata so Builder can resolve scope+name back to a callsite. Falls back to the `defmodule` callsite for unmappable error types. 4. Add `error.file` and `error.line` assertions across existing duplicate-detection tests (place, transition, guard, action, on_markings, multiple termination, arc options-before-do, validator errors) to lock the call-site precision into the test contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a72b04a commit 668fa2c

12 files changed

Lines changed: 469 additions & 44 deletions

File tree

lib/coloured_flow/dsl.ex

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ defmodule ColouredFlow.DSL do
6666
Module.register_attribute(__MODULE__, :cf_name, accumulate: false)
6767
Module.register_attribute(__MODULE__, :cf_version, accumulate: false)
6868

69+
# Per-declaration metadata: accumulates `{name, file, line}` triples so
70+
# `Builder` can map validator-driven errors back to the offending callsite.
71+
Module.register_attribute(__MODULE__, :cf_colour_sets_meta, accumulate: true)
72+
Module.register_attribute(__MODULE__, :cf_variables_meta, accumulate: true)
73+
Module.register_attribute(__MODULE__, :cf_constants_meta, accumulate: true)
74+
Module.register_attribute(__MODULE__, :cf_functions_meta, accumulate: true)
75+
Module.register_attribute(__MODULE__, :cf_places_meta, accumulate: true)
76+
Module.register_attribute(__MODULE__, :cf_transitions_meta, accumulate: true)
77+
6978
@before_compile ColouredFlow.DSL.Builder
7079
end
7180
end
@@ -109,12 +118,15 @@ defmodule ColouredFlow.DSL do
109118
"""
110119
defmacro colset(declaration) do
111120
{name, type} = ColouredFlow.Notation.Colset.__colset__(declaration)
121+
caller_file = __CALLER__.file
122+
caller_line = __CALLER__.line
112123

113124
quote do
114125
@cf_colour_sets %ColourSet{
115126
name: unquote(name),
116127
type: unquote(type)
117128
}
129+
@cf_colour_sets_meta {unquote(name), unquote(caller_file), unquote(caller_line)}
118130
end
119131
end
120132

@@ -128,12 +140,15 @@ defmodule ColouredFlow.DSL do
128140
"""
129141
defmacro var(declaration) do
130142
{name, colour_set} = ColouredFlow.Notation.Var.__var__(declaration)
143+
caller_file = __CALLER__.file
144+
caller_line = __CALLER__.line
131145

132146
quote do
133147
@cf_variables %Variable{
134148
name: unquote(name),
135149
colour_set: unquote(colour_set)
136150
}
151+
@cf_variables_meta {unquote(name), unquote(caller_file), unquote(caller_line)}
137152
end
138153
end
139154

@@ -147,13 +162,16 @@ defmodule ColouredFlow.DSL do
147162
"""
148163
defmacro val(declaration) do
149164
{name, colour_set, value} = ColouredFlow.Notation.Val.__val__(declaration)
165+
caller_file = __CALLER__.file
166+
caller_line = __CALLER__.line
150167

151168
quote do
152169
@cf_constants %Constant{
153170
name: unquote(name),
154171
colour_set: unquote(colour_set),
155172
value: unquote(value)
156173
}
174+
@cf_constants_meta {unquote(name), unquote(caller_file), unquote(caller_line)}
157175
end
158176
end
159177
end

lib/coloured_flow/dsl/arc.ex

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,24 @@ defmodule ColouredFlow.DSL.Arc do
100100
{arg2 ++ opts_after_do, body}
101101

102102
Keyword.has_key?(arg3, :do) ->
103-
{body, _opts} = Keyword.pop!(arg3, :do)
104-
{[], body}
103+
raise CompileError,
104+
description: """
105+
Invalid `#{label}` arguments: options must come before the `do ... end` block.
106+
107+
The expression is the block body for multi-line arcs; any positional \
108+
argument before the block (other than a keyword list of options) is \
109+
ambiguous and would be silently dropped.
110+
111+
Got: #{Macro.to_string(arg2)} as the second argument before the `do` block.
112+
113+
Use one of:
114+
#{label} :place, expression # single-line
115+
#{label} :place, expression, label: "..." # single-line, with options
116+
#{label} :place, do: expression # multi-line
117+
#{label} :place, label: "..." do ... end # multi-line, with options
118+
""",
119+
file: caller.file,
120+
line: caller.line
105121

106122
keyword?(arg3) ->
107123
{arg3, arg2}

lib/coloured_flow/dsl/builder.ex

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ defmodule ColouredFlow.DSL.Builder do
1212
alias ColouredFlow.Definition.Procedure
1313
alias ColouredFlow.Enactment.Marking
1414

15+
alias ColouredFlow.Validators.Exceptions.MissingColourSetError
16+
alias ColouredFlow.Validators.Exceptions.UniqueNameViolationError
17+
1518
@doc false
1619
defmacro __before_compile__(env) do
1720
cpnet = build_cpnet(env.module)
@@ -55,14 +58,153 @@ defmodule ColouredFlow.DSL.Builder do
5558
end
5659

5760
{:error, exception} ->
61+
{file, line} = locate_error(exception, env)
62+
5863
raise CompileError,
5964
description: """
6065
ColouredFlow.DSL: invalid workflow definition.
6166
6267
#{Exception.message(exception)}
6368
""",
64-
file: env.file,
65-
line: env.line
69+
file: file,
70+
line: line
71+
end
72+
end
73+
74+
# Map a validator-driven error back to the originating declaration's
75+
# `{file, line}`. Falls back to the `defmodule` callsite when the offending
76+
# declaration cannot be identified.
77+
@spec locate_error(Exception.t(), Macro.Env.t()) :: {String.t(), non_neg_integer()}
78+
defp locate_error(%UniqueNameViolationError{scope: scope, name: name}, env) do
79+
locate_unique_violation(scope, name, env)
80+
end
81+
82+
defp locate_error(%MissingColourSetError{colour_set: colour_set}, env) do
83+
locate_missing_colour_set(colour_set, env)
84+
end
85+
86+
defp locate_error(_other, env) do
87+
{env.file, env.line}
88+
end
89+
90+
# `UniqueNameValidator` halts on the *second* occurrence of a duplicate name.
91+
# The metadata accumulator stores entries in reverse declaration order
92+
# (most-recent first), so reverse to source order and pick the second
93+
# occurrence — that's the duplicate the validator rejected. The validator
94+
# uses `:variable_and_constant` to lump variables and constants together,
95+
# despite that not appearing in the exception's declared scope type.
96+
@unique_scope_attrs %{
97+
colour_set: [:cf_colour_sets_meta],
98+
place: [:cf_places_meta],
99+
transition: [:cf_transitions_meta],
100+
function: [:cf_functions_meta],
101+
variable_and_constant: [:cf_variables_meta, :cf_constants_meta]
102+
}
103+
104+
defp locate_unique_violation(scope, name, env) do
105+
case Map.fetch(@unique_scope_attrs, scope) do
106+
{:ok, attrs} ->
107+
attrs
108+
|> Enum.flat_map(&source_order_meta(env.module, &1))
109+
|> locate_duplicate(name, env)
110+
111+
:error ->
112+
{env.file, env.line}
113+
end
114+
end
115+
116+
# `MissingColourSetError` is raised by the places/functions/variables/
117+
# constants validators when a referenced colour set is not declared. The
118+
# exception carries the missing colour set name. We probe each declaration
119+
# scope in the same order the validator pipeline does and return the first
120+
# match.
121+
defp locate_missing_colour_set(colour_set, env) do
122+
locate_missing_in_places(colour_set, env) ||
123+
locate_missing_in_functions(colour_set, env) ||
124+
locate_missing_in_constants(colour_set, env) ||
125+
locate_missing_in_variables(colour_set, env) ||
126+
{env.file, env.line}
127+
end
128+
129+
defp locate_missing_in_places(colour_set, env) do
130+
env.module
131+
|> zip_meta(:cf_places, :cf_places_meta)
132+
|> Enum.find_value(fn
133+
{%Place{colour_set: ^colour_set}, {_name, file, line}} -> {file, line}
134+
_other -> nil
135+
end)
136+
end
137+
138+
defp locate_missing_in_functions(colour_set, env) do
139+
env.module
140+
|> zip_meta(:cf_functions, :cf_functions_meta)
141+
|> Enum.find_value(fn
142+
{%Procedure{result: result}, {_name, file, line}} ->
143+
if descr_references?(result, colour_set), do: {file, line}, else: nil
144+
145+
_other ->
146+
nil
147+
end)
148+
end
149+
150+
defp locate_missing_in_constants(colour_set, env) do
151+
env.module
152+
|> zip_meta(:cf_constants, :cf_constants_meta)
153+
|> Enum.find_value(fn
154+
{%{colour_set: ^colour_set}, {_name, file, line}} -> {file, line}
155+
_other -> nil
156+
end)
157+
end
158+
159+
defp locate_missing_in_variables(colour_set, env) do
160+
env.module
161+
|> zip_meta(:cf_variables, :cf_variables_meta)
162+
|> Enum.find_value(fn
163+
{%{colour_set: ^colour_set}, {_name, file, line}} -> {file, line}
164+
_other -> nil
165+
end)
166+
end
167+
168+
defp zip_meta(module, items_attr, meta_attr) do
169+
items = source_order_meta(module, items_attr)
170+
metas = source_order_meta(module, meta_attr)
171+
Enum.zip(items, metas)
172+
end
173+
174+
# Recursively check whether a `ColourSet.descr()` references the given
175+
# colour-set name as a leaf node.
176+
defp descr_references?({name, []}, target) when is_atom(name), do: name == target
177+
178+
defp descr_references?({:tuple, types}, target) when is_list(types) do
179+
Enum.any?(types, &descr_references?(&1, target))
180+
end
181+
182+
defp descr_references?({:list, type}, target), do: descr_references?(type, target)
183+
184+
defp descr_references?({:map, types}, target) when is_map(types) do
185+
Enum.any?(types, fn {_key, type} -> descr_references?(type, target) end)
186+
end
187+
188+
defp descr_references?({:union, types}, target) when is_map(types) do
189+
Enum.any?(types, fn {_tag, type} -> descr_references?(type, target) end)
190+
end
191+
192+
defp descr_references?(_other, _target), do: false
193+
194+
defp locate_duplicate(meta_list, name, env) do
195+
meta_list
196+
|> Enum.filter(fn {entry_name, _file, _line} -> entry_name == name end)
197+
|> case do
198+
[_first, {_name, file, line} | _rest] -> {file, line}
199+
[{_name, file, line}] -> {file, line}
200+
[] -> {env.file, env.line}
201+
end
202+
end
203+
204+
defp source_order_meta(module, attr) do
205+
case Module.get_attribute(module, attr) do
206+
nil -> []
207+
list when is_list(list) -> Enum.reverse(list)
66208
end
67209
end
68210

@@ -98,10 +240,14 @@ defmodule ColouredFlow.DSL.Builder do
98240
end
99241
end
100242

243+
# `Module.put_attribute/3` prepends in accumulate mode, so the head of the list
244+
# is the most recent entry. This pulls that entry. Macros that should only
245+
# appear once (e.g. `termination/1`) enforce uniqueness at macro-expansion
246+
# time, so this list will contain at most one element.
101247
defp pull_one(module, attr) do
102248
case Module.get_attribute(module, attr) do
103249
nil -> nil
104-
list when is_list(list) -> List.last(list)
250+
list when is_list(list) -> List.first(list)
105251
other -> other
106252
end
107253
end

lib/coloured_flow/dsl/function.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ defmodule ColouredFlow.DSL.Function do
5151
expression: unquote(Macro.escape(expression)),
5252
result: unquote(Macro.escape(result))
5353
}
54+
@cf_functions_meta {unquote(name), unquote(caller_file), unquote(caller_line)}
5455
end
5556
end
5657

lib/coloured_flow/dsl/place.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,16 @@ defmodule ColouredFlow.DSL.Place do
1717
defmacro place(name, colour_set) do
1818
name_value = unquote_atom!(name, "place name", __CALLER__)
1919
colour_set_value = unquote_atom!(colour_set, "place colour set", __CALLER__)
20+
name_str = Atom.to_string(name_value)
21+
caller_file = __CALLER__.file
22+
caller_line = __CALLER__.line
2023

2124
quote do
2225
@cf_places %Place{
23-
name: unquote(Atom.to_string(name_value)),
26+
name: unquote(name_str),
2427
colour_set: unquote(colour_set_value)
2528
}
29+
@cf_places_meta {unquote(name_str), unquote(caller_file), unquote(caller_line)}
2630
end
2731
end
2832

lib/coloured_flow/dsl/termination.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ defmodule ColouredFlow.DSL.Termination do
4444
end
4545

4646
quote do
47+
ColouredFlow.DSL.Termination.__check_unique_termination__!(
48+
__MODULE__,
49+
unquote(caller_file),
50+
unquote(caller_line)
51+
)
52+
4753
ColouredFlow.DSL.Termination.__open_termination__!(
4854
__MODULE__,
4955
unquote(caller_file),
@@ -85,6 +91,22 @@ defmodule ColouredFlow.DSL.Termination do
8591
end
8692
end
8793

94+
@doc false
95+
@spec __check_unique_termination__!(module(), String.t(), non_neg_integer()) :: :ok
96+
def __check_unique_termination__!(module, file, line) do
97+
case Module.get_attribute(module, :cf_termination_criteria) do
98+
[] ->
99+
:ok
100+
101+
list when is_list(list) ->
102+
raise CompileError,
103+
description:
104+
"termination/1 already declared in this workflow; only a single termination block is allowed",
105+
file: file,
106+
line: line
107+
end
108+
end
109+
88110
@doc false
89111
@spec __open_termination__!(module(), String.t(), non_neg_integer()) :: :ok
90112
def __open_termination__!(module, file, line) do

lib/coloured_flow/dsl/transition.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ defmodule ColouredFlow.DSL.Transition do
6464
unquote(block)
6565

6666
ColouredFlow.DSL.Transition.__close_transition__!(__MODULE__)
67+
68+
@cf_transitions_meta {unquote(name_str), unquote(caller_file), unquote(caller_line)}
6769
end
6870
end
6971

test/coloured_flow/dsl/arc_test.exs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,5 +163,39 @@ defmodule ColouredFlow.DSL.ArcTest do
163163
end
164164
end
165165
end
166+
167+
test "rejects do block when arg2 is not a keyword list of options" do
168+
source = """
169+
defmodule ColouredFlow.DSL.ArcTest.ArcExprBeforeDo do
170+
use ColouredFlow.DSL
171+
172+
name "ArcExprBeforeDo"
173+
174+
colset int() :: integer()
175+
176+
var x :: int()
177+
178+
place :input, :int
179+
place :output, :int
180+
181+
transition :t do
182+
input :input, bind({1, x}) do
183+
bind({2, x})
184+
end
185+
186+
output :output, {1, x}
187+
end
188+
end
189+
"""
190+
191+
error =
192+
assert_raise CompileError, ~r/options.+before.+do/i, fn ->
193+
Code.compile_string(source, "arc_expr_before_do.exs")
194+
end
195+
196+
assert error.file == "arc_expr_before_do.exs"
197+
# Points at the offending `input :input, bind({1, x}) do ... end` (line 14, 1-indexed).
198+
assert error.line == 14
199+
end
166200
end
167201
end

0 commit comments

Comments
 (0)