Skip to content

Commit 3473d2a

Browse files
michaelmcneesclaude
andcommitted
fix: address PR #21 review — non-strict default, jsonDecode precision, docs accuracy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ee7f873 commit 3473d2a

8 files changed

Lines changed: 83 additions & 45 deletions

File tree

docs/superpowers/plans/2026-03-24-data-transformation.md

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
---
1414

15-
### Task 1: Test and document built-in macros
15+
## Task 1: Test and document built-in macros
1616

1717
**Files:**
1818
- Create: `internal/cel/macros_test.go`
@@ -130,7 +130,7 @@ git commit -m "test(cel): add coverage for built-in map/filter/exists/all macros
130130

131131
---
132132

133-
### Task 2: String functions — toLower, toUpper, trim
133+
## Task 2: String functions — toLower, toUpper, trim
134134

135135
**Files:**
136136
- Create: `internal/cel/functions.go`
@@ -329,7 +329,7 @@ git commit -m "feat(cel): add toLower, toUpper, trim string functions"
329329

330330
---
331331

332-
### Task 3: String functions — replace and split
332+
## Task 3: String functions — replace and split
333333

334334
**Files:**
335335
- Modify: `internal/cel/functions.go`
@@ -441,7 +441,7 @@ git commit -m "feat(cel): add replace and split string functions"
441441

442442
---
443443

444-
### Task 4: Type coercion — parseInt, parseFloat, toString
444+
## Task 4: Type coercion — parseInt, parseFloat, toString
445445

446446
**Files:**
447447
- Modify: `internal/cel/functions.go`
@@ -621,7 +621,9 @@ git commit -m "feat(cel): add parseInt, parseFloat, toString type coercion funct
621621

622622
---
623623

624-
### Task 5: Object construction — obj()
624+
## Task 5: Object construction — obj()
625+
626+
> **Implementation note:** The plan originally specified a variadic `obj()` overload, but cel-go does not support true variadic functions without macros. The implementation uses fixed-arity overloads for 2, 4, 6, 8, and 10 arguments (1–5 key-value pairs), all sharing a single `objBinding` function.
625627
626628
**Files:**
627629
- Modify: `internal/cel/functions.go`
@@ -755,7 +757,7 @@ git commit -m "feat(cel): add obj() map construction function"
755757

756758
---
757759

758-
### Task 6: Utility functions — default, flatten
760+
## Task 6: Utility functions — default, flatten
759761

760762
**Files:**
761763
- Modify: `internal/cel/functions.go`
@@ -881,7 +883,7 @@ git commit -m "feat(cel): add default() null coalescing and flatten() functions"
881883

882884
---
883885

884-
### Task 7: JSON functions — jsonEncode, jsonDecode
886+
## Task 7: JSON functions — jsonEncode, jsonDecode
885887

886888
**Files:**
887889
- Modify: `internal/cel/functions.go`
@@ -1015,7 +1017,7 @@ git commit -m "feat(cel): add jsonEncode and jsonDecode functions"
10151017

10161018
---
10171019

1018-
### Task 8: Date/time functions — timestamp, formatTimestamp
1020+
## Task 8: Date/time functions — parseTimestamp, formatTimestamp
10191021

10201022
**Files:**
10211023
- Modify: `internal/cel/functions.go`
@@ -1035,9 +1037,9 @@ func TestFunc_Timestamp(t *testing.T) {
10351037
expr string
10361038
wantErr bool
10371039
}{
1038-
{"iso8601", `timestamp("2026-03-24T19:00:00Z")`, false},
1039-
{"with_offset", `timestamp("2026-03-24T14:00:00-05:00")`, false},
1040-
{"invalid", `timestamp("not a date")`, true},
1040+
{"iso8601", `parseTimestamp("2026-03-24T19:00:00Z")`, false},
1041+
{"with_offset", `parseTimestamp("2026-03-24T14:00:00-05:00")`, false},
1042+
{"invalid", `parseTimestamp("not a date")`, true},
10411043
}
10421044
for _, tt := range tests {
10431045
t.Run(tt.name, func(t *testing.T) {
@@ -1055,11 +1057,11 @@ func TestFunc_FormatTimestamp(t *testing.T) {
10551057
eval, err := NewEvaluator()
10561058
require.NoError(t, err)
10571059

1058-
result, err := eval.Eval(`formatTimestamp(timestamp("2026-03-24T19:00:00Z"), "2006-01-02")`, newTestContext())
1060+
result, err := eval.Eval(`formatTimestamp(parseTimestamp("2026-03-24T19:00:00Z"), "2006-01-02")`, newTestContext())
10591061
require.NoError(t, err)
10601062
assert.Equal(t, "2026-03-24", result)
10611063

1062-
result, err = eval.Eval(`formatTimestamp(timestamp("2026-03-24T19:30:45Z"), "15:04:05")`, newTestContext())
1064+
result, err = eval.Eval(`formatTimestamp(parseTimestamp("2026-03-24T19:30:45Z"), "15:04:05")`, newTestContext())
10631065
require.NoError(t, err)
10641066
assert.Equal(t, "19:30:45", result)
10651067
}
@@ -1068,7 +1070,7 @@ func TestFunc_FormatTimestamp(t *testing.T) {
10681070
- [ ] **Step 2: Run tests to verify they fail**
10691071

10701072
Run: `go test ./internal/cel/ -run "TestFunc_Timestamp|TestFunc_FormatTimestamp" -v`
1071-
Expected: FAIL
1073+
Expected: FAIL (functions registered as `parseTimestamp`, not `timestamp`)
10721074

10731075
- [ ] **Step 3: Add date/time functions**
10741076

@@ -1083,15 +1085,15 @@ type timeLib struct{}
10831085

10841086
func (l *timeLib) CompileOptions() []cel.EnvOption {
10851087
return []cel.EnvOption{
1086-
cel.Function("timestamp",
1087-
cel.Overload("timestamp_string",
1088+
cel.Function("parseTimestamp",
1089+
cel.Overload("parseTimestamp_string",
10881090
[]*cel.Type{cel.StringType},
10891091
cel.TimestampType,
10901092
cel.UnaryBinding(func(val ref.Val) ref.Val {
10911093
s := string(val.(types.String))
10921094
t, err := time.Parse(time.RFC3339, s)
10931095
if err != nil {
1094-
return types.NewErr("timestamp: %v", err)
1096+
return types.NewErr("parseTimestamp: %v", err)
10951097
}
10961098
return types.Timestamp{Time: t}
10971099
}),
@@ -1144,12 +1146,12 @@ Expected: PASS — all tests including existing ones
11441146

11451147
```bash
11461148
git add internal/cel/functions.go internal/cel/functions_test.go
1147-
git commit -m "feat(cel): add timestamp and formatTimestamp date/time functions"
1149+
git commit -m "feat(cel): add parseTimestamp and formatTimestamp date/time functions"
11481150
```
11491151

11501152
---
11511153

1152-
### Task 9: Update CEL expressions documentation
1154+
## Task 9: Update CEL expressions documentation
11531155

11541156
**Files:**
11551157
- Modify: `site/src/content/docs/concepts/expressions.md`
@@ -1207,7 +1209,7 @@ git commit -m "docs: add custom CEL functions and macros to expressions referenc
12071209

12081210
---
12091211

1210-
### Task 10: Create data transformations guide
1212+
## Task 10: Create data transformations guide
12111213

12121214
**Files:**
12131215
- Create: `site/src/content/docs/getting-started/data-transformations.md`
@@ -1243,7 +1245,7 @@ git commit -m "docs: add data transformation patterns guide (#14)"
12431245

12441246
---
12451247

1246-
### Task 11: Create example workflows
1248+
## Task 11: Create example workflows
12471249

12481250
**Files:**
12491251
- Create: `examples/data-transform-api-to-db.yaml`
@@ -1360,7 +1362,7 @@ git commit -m "feat: add data transformation and AI enrichment example workflows
13601362

13611363
---
13621364

1363-
### Task 12: Final validation
1365+
## Task 12: Final validation
13641366

13651367
- [ ] **Step 1: Run full test suite**
13661368

docs/superpowers/specs/2026-03-24-data-transformation-design.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Errors on odd number of args or non-string keys. Enables building maps for DB in
6969

7070
| Function | Example | Result |
7171
|----------|---------|--------|
72-
| `timestamp(string)` | `timestamp("2026-03-24T19:00:00Z")` | timestamp value |
72+
| `parseTimestamp(string)` | `parseTimestamp("2026-03-24T19:00:00Z")` | timestamp value |
7373
| `formatTimestamp(ts, layout)` | `formatTimestamp(ts, "2006-01-02")` | `"2026-03-24"` |
7474

7575
Uses Go time layout strings.
@@ -86,14 +86,15 @@ In `internal/cel/cel.go`, the `NewEvaluator` function passes function options to
8686

8787
```go
8888
func NewEvaluator() (*Evaluator, error) {
89-
env, err := cel.NewEnv(
89+
opts := []cel.EnvOption{
9090
cel.Variable("steps", cel.MapType(cel.StringType, cel.DynType)),
9191
cel.Variable("inputs", cel.MapType(cel.StringType, cel.DynType)),
9292
cel.Variable("env", cel.MapType(cel.StringType, cel.StringType)),
9393
cel.Variable("trigger", cel.MapType(cel.StringType, cel.DynType)),
94-
// Custom functions
95-
customFunctions()...,
96-
)
94+
}
95+
opts = append(opts, customFunctions()...)
96+
97+
env, err := cel.NewEnv(opts...)
9798
// ...
9899
}
99100
```
@@ -107,7 +108,7 @@ All errors surface through the existing `Eval` error path:
107108
- `obj()` with odd args → evaluation error
108109
- `obj()` with non-string keys → evaluation error
109110
- `jsonDecode()` with invalid JSON → evaluation error
110-
- `timestamp()` with unparseable string → evaluation error
111+
- `parseTimestamp()` with unparseable string → evaluation error
111112

112113
No new error types needed.
113114

examples/ai-data-enrichment.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ steps:
5858
query: >
5959
INSERT INTO urgent_tickets (priority, category, products, raw_body)
6060
VALUES ($1, $2, $3, $4)
61-
params:
61+
args:
6262
- "{{ steps.classify.output.json.priority }}"
6363
- "{{ steps.classify.output.json.category }}"
6464
- "{{ jsonEncode(steps.classify.output.json.products) }}"

examples/data-transform-api-to-db.yaml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ name: data-transform-api-to-db
22
description: >
33
Fetches a user from an API, transforms the record using CEL expressions
44
to match a database schema, and inserts the normalized data into Postgres.
5-
Demonstrates obj(), toLower(), and string functions without requiring
6-
an AI model.
5+
Demonstrates toLower() and string functions without requiring an AI model.
76
87
steps:
98
- name: fetch-user

internal/cel/functions.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ func (l *collectionLib) CompileOptions() []cel.EnvOption {
158158
cel.Overload("default_any_any",
159159
[]*cel.Type{cel.DynType, cel.DynType},
160160
cel.DynType,
161+
cel.OverloadIsNonStrict(),
161162
cel.BinaryBinding(func(lhs, rhs ref.Val) ref.Val {
162163
if types.IsError(lhs) || types.IsUnknown(lhs) || lhs == types.NullValue {
163164
return rhs
@@ -273,11 +274,13 @@ func (l *jsonLib) CompileOptions() []cel.EnvOption {
273274
cel.DynType,
274275
cel.UnaryBinding(func(val ref.Val) ref.Val {
275276
s := string(val.(types.String))
277+
dec := json.NewDecoder(strings.NewReader(s))
278+
dec.UseNumber()
276279
var result any
277-
if err := json.Unmarshal([]byte(s), &result); err != nil {
280+
if err := dec.Decode(&result); err != nil {
278281
return types.NewErr("jsonDecode: %v", err)
279282
}
280-
return types.DefaultTypeAdapter.NativeToValue(result)
283+
return types.DefaultTypeAdapter.NativeToValue(normalizeJSONNumbers(result))
281284
}),
282285
),
283286
),
@@ -338,3 +341,30 @@ func (l *timeLib) CompileOptions() []cel.EnvOption {
338341
func (l *timeLib) ProgramOptions() []cel.ProgramOption {
339342
return nil
340343
}
344+
345+
// normalizeJSONNumbers walks a decoded JSON structure and converts json.Number
346+
// values to int64 (if the number is an integer) or float64.
347+
func normalizeJSONNumbers(v any) any {
348+
switch val := v.(type) {
349+
case json.Number:
350+
if i, err := val.Int64(); err == nil {
351+
return i
352+
}
353+
if f, err := val.Float64(); err == nil {
354+
return f
355+
}
356+
return val.String()
357+
case map[string]any:
358+
for k, v := range val {
359+
val[k] = normalizeJSONNumbers(v)
360+
}
361+
return val
362+
case []any:
363+
for i, v := range val {
364+
val[i] = normalizeJSONNumbers(v)
365+
}
366+
return val
367+
default:
368+
return v
369+
}
370+
}

internal/cel/functions_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,12 @@ func TestFunc_Obj(t *testing.T) {
227227
expr: `obj("key")`,
228228
wantErr: true,
229229
},
230+
{
231+
"non_string_key",
232+
`obj(1, "value")`,
233+
nil,
234+
true,
235+
},
230236
}
231237
for _, tt := range tests {
232238
t.Run(tt.name, func(t *testing.T) {
@@ -341,7 +347,7 @@ func TestFunc_JsonDecode(t *testing.T) {
341347
m, ok := result.(map[string]any)
342348
require.True(t, ok)
343349
assert.Equal(t, "bob", m["name"])
344-
assert.Equal(t, float64(25), m["age"])
350+
assert.Equal(t, int64(25), m["age"])
345351
},
346352
},
347353
{

site/src/content/docs/concepts/expressions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ steps:
489489

490490
### `obj(key, value, ...)`
491491

492-
Builds a map from alternating key-value arguments. Accepts any number of key-value pairs.
492+
Builds a map from alternating key-value arguments. Supports up to 5 key-value pairs (10 arguments) due to cel-go's fixed-arity overload requirement — CEL does not support true variadic functions without macros. For maps with more than 5 pairs, use nested `obj()` calls or construct the value with `jsonDecode`.
493493

494494
```yaml
495495
steps:

site/src/content/docs/getting-started/data-transformations.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ steps:
4848
records: >
4949
{{ steps['fetch-users'].output.json.users.map(u,
5050
obj(
51-
'name', toLower(u.firstName + ' ' + u.lastName),
51+
'name', (u.firstName + ' ' + u.lastName).toLower(),
5252
'birth_date', u.dob
5353
)
5454
) }}
@@ -58,7 +58,7 @@ What the CEL expression does:
5858
5959
- `.map(u, ...)` -- iterates the `users` list, binding each element to `u`
6060
- `obj('name', ..., 'birth_date', ...)` -- constructs a new object with the renamed keys
61-
- `toLower(...)` -- normalizes the full name to lowercase
61+
- `.toLower()` -- normalizes the full name to lowercase (method call on the concatenated string)
6262
- String concatenation (`+`) joins first and last name with a space
6363

6464
The output of the `map()` call is a new list of objects ready for the batch insert. Nothing left the workflow engine.
@@ -174,7 +174,7 @@ steps:
174174
'id', r.reviewId,
175175
'rating', r.starRating,
176176
'reviewed_at', r.submittedAt,
177-
'text', trim(r.body)
177+
'text', r.body.trim()
178178
)
179179
) }}
180180
@@ -265,13 +265,13 @@ These are the Mantle CEL extensions available in workflow expressions. For full
265265

266266
**String**
267267

268-
| Function | Description |
269-
|---|---|
270-
| `toLower(s)` | Convert string to lowercase |
271-
| `toUpper(s)` | Convert string to uppercase |
272-
| `trim(s)` | Remove leading and trailing whitespace |
273-
| `replace(s, old, new)` | Replace all occurrences of `old` with `new` |
274-
| `split(s, sep)` | Split string into a list on separator |
268+
| Function | Example | Description |
269+
|---|---|---|
270+
| `s.toLower()` | `"HELLO".toLower()` | Convert string to lowercase |
271+
| `s.toUpper()` | `"hello".toUpper()` | Convert string to uppercase |
272+
| `s.trim()` | `" a ".trim()` | Remove leading and trailing whitespace |
273+
| `s.replace(old, new)` | `"a-b".replace("-", "_")` | Replace all occurrences of `old` with `new` |
274+
| `s.split(sep)` | `"a,b".split(",")` | Split string into a list on separator |
275275

276276
**Object construction**
277277

0 commit comments

Comments
 (0)