From 1b1b93b35f0d5047649fde0b84b8290b34cc468e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 2 Sep 2026 18:23:03 +0200 Subject: [PATCH] Reject weight 0, a never-numeric calc operand and a repeat product past the cap; the CLI classifies ErrNoData --- README.md | 13 ++++++++----- builtins.go | 7 ++++--- calc.go | 30 +++++++++++++++++++++++++++++- cmd/fejkdata/main.go | 6 +++--- data.go | 3 +++ fejkdata.go | 6 +++++- node.go | 11 +++++++---- reference.go | 30 ++++++++++++++++++++++++++++++ 8 files changed, 89 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index ecb68b2..85857ff 100644 --- a/README.md +++ b/README.md @@ -167,8 +167,8 @@ An item of a choice may carry a `weight` (default `1`) to skew its odds: Renders `070-412 38 91` ten times as often as `08-…`. A string item is weighted by writing it as `{ "format": "AB", "weight": 3 }`. Rejected at load: a weight that -is negative, non-numeric, `1` (the default) or outside a choice, a set summing to -zero, and a repeated item — `["a", "a", "b"]` is `[{ "format": "a", "weight": 2 }, "b"]`, +is negative, non-numeric, `0` (never drawn — remove the item), `1` (the default) +or outside a choice, and a repeated item — `["a", "a", "b"]` is `[{ "format": "a", "weight": 2 }, "b"]`, and the error says so. ### Repeat @@ -180,7 +180,9 @@ many times — each an independent draw — joined by `separator` (default `""`) { "format": "{word}", "repeat": 3, "separator": " ", "word": ["foo", "bar", "baz"] } ``` -Renders e.g. `bar foo baz`. A `separator` without a `repeat` is rejected. +Renders e.g. `bar foo baz`. A `separator` without a `repeat` is rejected, and so +is a `repeat` that multiplies to more than 1 048 576 renders along any path of +nested repeats. ### Options and fields @@ -240,8 +242,9 @@ hyphenated field can't be an operand. { "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] } ``` -Renders e.g. `19.99 x 3 = 59.97`. A non-numeric operand yields `NaN` and a -division by zero `Inf`; both print rather than fail. +Renders e.g. `19.99 x 3 = 59.97`. An operand that can never be a number (`"abc"`, +or a choice of such) is rejected at load; one that sometimes is not yields `NaN`, +and a division by zero `Inf` — both print rather than fail. ### Transforms diff --git a/builtins.go b/builtins.go index d264f00..ae025ac 100644 --- a/builtins.go +++ b/builtins.go @@ -9,9 +9,10 @@ import ( "unicode" ) -// maxLen caps sample output lengths (hex, nanoid, base64) and maxDecimals caps -// float/calc decimal places, so a fat-fingered or overflowing argument fails at -// New instead of trying to allocate gigabytes — or panicking — at render. +// maxLen caps sample output lengths (hex, nanoid, base64) and the renders a repeat +// multiplies to along any path; maxDecimals caps float/calc decimal places. So a +// fat-fingered or overflowing argument fails at New instead of trying to allocate +// gigabytes — or panicking — at render. const ( maxLen = 1 << 20 // 1,048,576 chars/bytes maxDecimals = 1024 diff --git a/calc.go b/calc.go index 4d068e4..a9a8653 100644 --- a/calc.go +++ b/calc.go @@ -70,9 +70,13 @@ func checkCalc(fields map[string]node, args []string) error { return fmt.Errorf("calc(%q): %w", args[0], err) } for _, name := range calcVars(expr) { - if _, ok := fields[name]; !ok { + operand, ok := fields[name] + if !ok { return fmt.Errorf("calc(%q): no field %q", args[0], name) } + if text, never := neverNumeric(operand); never { + return fmt.Errorf("calc(%q): operand %q is never a number: it renders %q", args[0], name, text) + } } if len(args) == 2 { if dp, err := strconv.Atoi(args[1]); err != nil || dp < 0 || dp > maxDecimals { @@ -82,6 +86,30 @@ func checkCalc(fields map[string]node, args []string) error { return nil } +// neverNumeric reports a node no render of which is a number: fixed text that does +// not parse, or a choice of only such items. text is one such render. +func neverNumeric(n node) (text string, never bool) { + switch n := n.(type) { + case *template: + if !n.fixed || n.repeat > 1 { + return "", false + } + if _, err := strconv.ParseFloat(strings.TrimSpace(n.lit), 64); err != nil { + return n.lit, true + } + case *choice: + for _, it := range n.items { + t, itemNever := neverNumeric(it) + if !itemNever { + return "", false + } + text = t + } + return text, true + } + return "", false +} + // calcPrep parses the expression and decimals once, at compile time, and places each // operand name at the position expand will read it into. checkCalc proved both args // valid, so no step here can fail; dp -1 prints the minimal form. diff --git a/cmd/fejkdata/main.go b/cmd/fejkdata/main.go index 9f35f96..0b335a1 100644 --- a/cmd/fejkdata/main.go +++ b/cmd/fejkdata/main.go @@ -185,9 +185,6 @@ func parseArgs(argv []string) (invocation, error) { // check rejects a flag combination that cannot run. func (in invocation) check() error { - if in.noShipped && len(in.dirs) == 0 { - return errors.New("--no-shipped-data needs at least one --data-path") - } if in.list && len(in.paths) > 0 { return errors.New("--list takes no path") } @@ -244,6 +241,9 @@ func run(args []string, stdout, stderr io.Writer) int { return misuse(stderr, err) } f, err := fejkdata.New(in.options()...) + if errors.Is(err, fejkdata.ErrNoData) { + return misuse(stderr, errors.New("--no-shipped-data needs at least one --data-path")) + } if err != nil { fmt.Fprintln(stderr, err) return 1 diff --git a/data.go b/data.go index 0be4a02..bfc4a40 100644 --- a/data.go +++ b/data.go @@ -64,6 +64,9 @@ func loadData(sources []dataSource) (map[string]node, error) { if err := checkNoCycles(root); err != nil { return nil, err } + if err := checkRepeatReach(root); err != nil { + return nil, err + } if err := checkBoundLevelsHeld(root); err != nil { return nil, err } diff --git a/fejkdata.go b/fejkdata.go index adff4aa..ff7c052 100644 --- a/fejkdata.go +++ b/fejkdata.go @@ -16,6 +16,7 @@ import ( crand "crypto/rand" "embed" "encoding/binary" + "errors" "fmt" "io/fs" "math/rand/v2" @@ -27,6 +28,9 @@ import ( //go:embed data var shippedFS embed.FS +// ErrNoData is returned by New when no source is loaded at all. +var ErrNoData = errors.New("no data: WithoutShippedData needs at least one WithDataPath or WithDataFS") + // Generator generates fake data from a loaded namespace tree. Create one with [New]. // It is safe for concurrent use; a seeded sequence is reproducible only when drawn // from one goroutine. @@ -99,7 +103,7 @@ func New(opts ...Option) (*Generator, error) { } sources = append(sources, c.sources...) if len(sources) == 0 { - return nil, fmt.Errorf("fejkdata: no data: WithoutShippedData needs at least one WithDataPath or WithDataFS") + return nil, fmt.Errorf("fejkdata: %w", ErrNoData) } cats, err := loadData(sources) if err != nil { diff --git a/node.go b/node.go index 61566c1..d6fa84f 100644 --- a/node.go +++ b/node.go @@ -144,8 +144,8 @@ func compileChoice(items []any) (node, error) { c.items[i] = n } if weighted { // uniform choices skip the weight table and pick in O(1) - if total <= 0 || math.IsInf(total, 1) { - return nil, fmt.Errorf("choice weights must sum to a finite positive number, got %v", total) + if math.IsInf(total, 1) { + return nil, fmt.Errorf("choice weights must sum to a finite number, got %v", total) } c.cum = cum } @@ -293,7 +293,7 @@ func repeatOf(m map[string]any) (int, error) { } // weightOf reads a node's "weight" (default 1) from its raw JSON form. Only -// template objects carry weight; a present one must be finite and non-negative. +// template objects carry weight; a present one must be finite and positive. func weightOf(raw any) (float64, error) { m, ok := raw.(map[string]any) if !ok { @@ -308,7 +308,10 @@ func weightOf(raw any) (float64, error) { return 0, fmt.Errorf("weight must be a number, got %T", wv) } if w < 0 || math.IsNaN(w) || math.IsInf(w, 0) { - return 0, fmt.Errorf("weight must be finite and non-negative, got %v", w) + return 0, fmt.Errorf("weight must be finite and positive, got %v", w) + } + if w == 0 { + return 0, fmt.Errorf("weight 0 means the item is never drawn; remove the item instead") } if w == 1 { return 0, fmt.Errorf("weight 1 is the default, so it has no effect; drop it") diff --git a/reference.go b/reference.go index 0089f23..b240665 100644 --- a/reference.go +++ b/reference.go @@ -412,6 +412,36 @@ func pathLeaves(n node, tail []string) []node { return pathLeaves(child(n, tail[0]), tail[1:]) } +// checkRepeatReach bounds the renders a repeat multiplies to along any root-to-leaf +// path, so nested repeats cannot build what one repeat may not. It runs after +// checkNoCycles, whose guarantee is what lets the walk terminate. +func checkRepeatReach(root map[string]node) error { + reach := map[node]int{} + var of func(n node) int + of = func(n node) int { + if r, done := reach[n]; done { + return r + } + r := 1 + for _, e := range renderEdges(n) { + if c := of(e.to); c > r { + r = c + } + } + if t, ok := n.(*template); ok { + r *= t.repeat + } + reach[n] = r + return r + } + return walkNodes(root, func(path string, n node) error { + if t, ok := n.(*template); ok && t.repeat > 1 && of(n) > maxLen { + return fmt.Errorf("%s: repeat %d multiplies to %d renders along one path, above the maximum %d", path, t.repeat, of(n), maxLen) + } + return nil + }) +} + // checkNoCycles rejects a reference cycle: a node whose rendering can reach itself // — directly, mutually, or through a chain — never terminates, so it must fail at // New rather than stack-overflow at render. It is a depth-first walk of the render