From ab69ff0e16620b5df0ddcf1434a53e172f8bb548 Mon Sep 17 00:00:00 2001 From: intern0 Date: Thu, 11 Jun 2026 18:23:03 +0200 Subject: [PATCH] log/views: render external types via generic blueprint fallback Objects of external types (blueprint-backed astral.RuntimeObject) had no compiled-in view, so fmt.ViewFor fell through to %v and printed the raw Go carrier (pointer hex, internal field/index state) instead of a readable value. Add a fallback hook to the view lookup: fmt.SetFallbackView installs a builder consulted on a per-type registry miss, before the Stringify path. mod/log/views registers RuntimeObjectView there, rendering Type{Field: value, ...} for struct kind and Type(value) for alias kind, delegating each field to fmt.ViewFor so nested primitives and nested external types render through the same machinery. Runtime container carriers (RuntimeSlice/RuntimeArray/RuntimeMap) expose a constant ObjectType, so they register per-type via fmt.SetView; the map view sorts keys for deterministic log output. A new RuntimeObject.Blueprint() accessor exposes the schema the view iterates. Fixes #337. Co-Authored-By: Claude Opus 4.8 (1M context) --- .ai/knowledge/modules/log.md | 3 +- astral/fmt/printf.go | 22 ++++++ astral/runtime_object.go | 5 ++ mod/log/views/runtime_array_view.go | 36 +++++++++ mod/log/views/runtime_map_view.go | 64 +++++++++++++++ mod/log/views/runtime_object_view.go | 54 +++++++++++++ mod/log/views/runtime_object_view_test.go | 96 +++++++++++++++++++++++ mod/log/views/runtime_slice_view.go | 33 ++++++++ 8 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 mod/log/views/runtime_array_view.go create mode 100644 mod/log/views/runtime_map_view.go create mode 100644 mod/log/views/runtime_object_view.go create mode 100644 mod/log/views/runtime_object_view_test.go create mode 100644 mod/log/views/runtime_slice_view.go diff --git a/.ai/knowledge/modules/log.md b/.ai/knowledge/modules/log.md index 2c649df3d..d51368779 100644 --- a/.ai/knowledge/modules/log.md +++ b/.ai/knowledge/modules/log.md @@ -19,6 +19,7 @@ Centralizes node logging by filtering `astral/log` output, writing log entries t - File sink: `CreateLogFile` creates `~/.config/astrald/logs/astrald.log.` when the home directory is available -> `LogFile.LogEntry` serializes writes through a mutex and sends entries through a channel wrapper. - Live stream: `log.listen` accepts the raw query stream -> creates a channel with optional input and output formats -> adds `logForwarder` -> waits until the client closes by reading from the channel -> deferred removal stops forwarding. - Rendering: package init and loader-installed view functions render primitives, identities, queries, and entries with module theme colors; identity rendering falls back to fingerprint when no resolver is available. +- External-type rendering: blueprint-backed `astral.RuntimeObject` values have no compile-time view; `RuntimeObjectView` registers via `fmt.SetFallbackView` and renders `Type{Field: value, ...}` (struct) or `Type(value)` (alias), delegating each field to `fmt.ViewFor`. Runtime container carriers (`RuntimeSlice`/`RuntimeArray`/`RuntimeMap`) register per-type under their constant `ObjectType` (`slice`/`array`/`map`). Replaces the prior raw `%v` Go dump. See issue #337. ## Source @@ -27,7 +28,7 @@ Centralizes node logging by filtering `astral/log` output, writing log entries t - `mod/log/src/module.go`, `mod/log/src/config.go`, `mod/log/src/deps.go` - module state, dynamic log-level filter, config binding, and dependency wiring. - `mod/log/src/log_file.go` - per-run log file creation and serialized entry writes. - `mod/log/src/op_listen.go` - `log.listen` handler and live forwarding logger. -- `mod/log/views/` - terminal renderers and opt-in view registration functions. +- `mod/log/views/` - terminal renderers and opt-in view registration functions; `runtime_object_view.go` (external-type fallback) and `runtime_{slice,array,map}_view.go` (container carriers). - `mod/log/styles/`, `mod/log/theme/` - reusable color, gradient, and style helpers for views. ## Surface diff --git a/astral/fmt/printf.go b/astral/fmt/printf.go index 9d26b6573..f652108b7 100644 --- a/astral/fmt/printf.go +++ b/astral/fmt/printf.go @@ -5,6 +5,7 @@ import ( "io" "os" "reflect" + "sync/atomic" "github.com/cryptopunkscc/astrald/astral" "github.com/cryptopunkscc/astrald/sig" @@ -16,8 +17,22 @@ type Printer struct { var viewBuilders sig.Map[string, ViewBuilder] +// fallbackView builds a View for an astral.Object whose ObjectType has no entry in +// viewBuilders. Blueprint-backed objects (RuntimeObject) carry a runtime-only type name the +// per-type registry can never match, so they route here instead of the %v Stringify dump. +// nil until SetFallbackView installs one. ViewBuilder is not comparable, so the slot is an +// atomic.Pointer rather than a sig.Value. +var fallbackView atomic.Pointer[ViewBuilder] + type ViewBuilder func(astral.Object) View +// SetFallbackView installs the builder ViewFor consults after a per-type registry miss, before +// the Stringify path. A builder returning nil declines, leaving Stringify to handle the object. +// Replaces any prior fallback. +func SetFallbackView(fn ViewBuilder) { + fallbackView.Store(&fn) +} + func NewPrinter(writer io.Writer) *Printer { return &Printer{Writer: writer} } @@ -257,6 +272,13 @@ func ViewFor(a any) (view View) { if builder, found := viewBuilders.Get(o.ObjectType()); found { return builder(o) } + // why: external types carry a runtime-only name viewBuilders can't key on; route them + // through the fallback (generic blueprint render) before the raw %v Stringify dump. + if fn := fallbackView.Load(); fn != nil { + if v := (*fn)(o); v != nil { + return v + } + } } return stringView(astral.Stringify(a)) diff --git a/astral/runtime_object.go b/astral/runtime_object.go index 37bd34179..91278f923 100644 --- a/astral/runtime_object.go +++ b/astral/runtime_object.go @@ -120,6 +120,11 @@ func (ro *RuntimeObject) Underlying() Object { return ro.value } +// Blueprint returns the schema backing this RuntimeObject, or nil if unbound. Callers reflect +// on the result — Fields for struct kind, Underlying for alias kind — to render or inspect a +// type the binary was not built with. +func (ro *RuntimeObject) Blueprint() *Blueprint { return ro.bp } + // GetRuntimeObject returns a fresh RuntimeObject backed by this Blueprint. Spec-zeros are // resolved through defaultBlueprints; route through Blueprints.New for a custom registry. func (bp *Blueprint) GetRuntimeObject() (*RuntimeObject, error) { return NewRuntimeObject(bp) } diff --git a/mod/log/views/runtime_array_view.go b/mod/log/views/runtime_array_view.go new file mode 100644 index 000000000..499984aaa --- /dev/null +++ b/mod/log/views/runtime_array_view.go @@ -0,0 +1,36 @@ +package views + +import ( + "strconv" + + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/fmt" + "github.com/cryptopunkscc/astrald/mod/log/styles" +) + +// RuntimeArrayView renders a blueprint-backed fixed-length array as [N]{elem, elem, ...}, +// delegating each element to fmt.ViewFor. The carrier's ObjectType is the constant "array", so +// a per-type builder matches it directly. See issue #337. +type RuntimeArrayView struct { + *astral.RuntimeArray +} + +func (v RuntimeArrayView) Render() (out string) { + out += styles.Highlight.Render("["+strconv.Itoa(v.Len())+"]") + + styles.Highlight.Render("{") + for i := 0; i < v.Len(); i++ { + if i > 0 { + out += ", " + } + out += fmt.ViewFor(v.At(i)).Render() + } + out += styles.Highlight.Render("}") + + return +} + +func init() { + fmt.SetView(func(o *astral.RuntimeArray) fmt.View { + return RuntimeArrayView{RuntimeArray: o} + }) +} diff --git a/mod/log/views/runtime_map_view.go b/mod/log/views/runtime_map_view.go new file mode 100644 index 000000000..434797c42 --- /dev/null +++ b/mod/log/views/runtime_map_view.go @@ -0,0 +1,64 @@ +package views + +import ( + "sort" + + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/fmt" + "github.com/cryptopunkscc/astrald/mod/log/styles" +) + +// RuntimeMapView renders a blueprint-backed map as map[key: value, key: value], delegating both +// keys and values to fmt.ViewFor. The carrier's ObjectType is the constant "map", so a per-type +// builder matches it directly. See issue #337. +type RuntimeMapView struct { + *astral.RuntimeMap +} + +type mapEntry struct { + key any + value astral.Object +} + +func (v RuntimeMapView) Render() (out string) { + // why: RuntimeMap.Each iterates in unspecified order; collect and sort so a given map always + // logs identically. Keys are string or uint64 (homogeneous per map); less() orders both. + var entries []mapEntry + _ = v.Each(func(key any, value astral.Object) error { + entries = append(entries, mapEntry{key: key, value: value}) + return nil + }) + sort.Slice(entries, func(i, j int) bool { + return lessMapKey(entries[i].key, entries[j].key) + }) + + out += styles.Highlight.Render("map[") + for i, e := range entries { + if i > 0 { + out += ", " + } + out += fmt.ViewFor(e.key).Render() + + styles.Highlight.Render(": ") + + fmt.ViewFor(e.value).Render() + } + out += styles.Highlight.Render("]") + + return +} + +// lessMapKey orders two RuntimeMap keys. uint64 keys (uintN-keyed maps) compare numerically; +// anything else compares by its astral string form. +func lessMapKey(a, b any) bool { + if ua, ok := a.(uint64); ok { + if ub, ok := b.(uint64); ok { + return ua < ub + } + } + return astral.Stringify(a) < astral.Stringify(b) +} + +func init() { + fmt.SetView(func(o *astral.RuntimeMap) fmt.View { + return RuntimeMapView{RuntimeMap: o} + }) +} diff --git a/mod/log/views/runtime_object_view.go b/mod/log/views/runtime_object_view.go new file mode 100644 index 000000000..aa287f001 --- /dev/null +++ b/mod/log/views/runtime_object_view.go @@ -0,0 +1,54 @@ +package views + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/fmt" + "github.com/cryptopunkscc/astrald/mod/log/styles" + "github.com/cryptopunkscc/astrald/mod/log/theme" +) + +// RuntimeObjectView renders a blueprint-backed object whose type has no compiled-in view. It is +// the generic fallback for external types: struct kind renders Type{Field: value, ...}, alias +// kind renders Type(value). Each field value delegates to fmt.ViewFor, so nested primitives and +// nested external types render through the same machinery. See issue #337. +type RuntimeObjectView struct { + *astral.RuntimeObject +} + +func (v RuntimeObjectView) Render() (out string) { + bp := v.Blueprint() + out += theme.Type.Bri(theme.Less).Render(v.ObjectType()) + + // alias kind: Type(value) + if bp.Kind() == astral.BlueprintKindAlias { + out += styles.Highlight.Render("(") + + fmt.ViewFor(v.Underlying()).Render() + + styles.Highlight.Render(")") + return + } + + // struct kind: Type{Field: value, Field: value} + out += styles.Highlight.Render("{") + for i, f := range bp.Fields { + if i > 0 { + out += ", " + } + name := f.Name.String() + out += theme.Normal.Render(name) + ": " + fmt.ViewFor(v.Get(name)).Render() + } + out += styles.Highlight.Render("}") + + return +} + +func init() { + // why: external type names are runtime-only, so register as the fmt fallback rather than a + // per-type builder. Decline non-RuntimeObject and unbound carriers — they fall to Stringify. + fmt.SetFallbackView(func(o astral.Object) fmt.View { + ro, ok := o.(*astral.RuntimeObject) + if !ok || ro.Blueprint() == nil { + return nil + } + return RuntimeObjectView{RuntimeObject: ro} + }) +} diff --git a/mod/log/views/runtime_object_view_test.go b/mod/log/views/runtime_object_view_test.go new file mode 100644 index 000000000..972bde11a --- /dev/null +++ b/mod/log/views/runtime_object_view_test.go @@ -0,0 +1,96 @@ +package views + +import ( + "regexp" + "strings" + "testing" + + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/fmt" +) + +// ansi strips lipgloss color escapes so assertions compare on rendered text alone. +var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func render(o astral.Object) string { + return ansi.ReplaceAllString(fmt.ViewFor(o).Render(), "") +} + +func mustRO(t *testing.T, bp *astral.Blueprint) *astral.RuntimeObject { + t.Helper() + ro, err := astral.NewRuntimeObject(bp) + if err != nil { + t.Fatal(err) + } + return ro +} + +func TestRuntimeObjectView_Struct(t *testing.T) { + bp := astral.NewBlueprint("test.song", + astral.Field{Name: "title", Spec: &astral.PrimitiveSpec{PrimitiveType: "string16"}}, + astral.Field{Name: "track", Spec: &astral.PrimitiveSpec{PrimitiveType: "uint32"}}, + ) + ro := mustRO(t, bp) + if err := ro.Set("title", "My Song"); err != nil { + t.Fatal(err) + } + if err := ro.Set("track", uint32(7)); err != nil { + t.Fatal(err) + } + + got := render(ro) + + // why: a raw go dump leaks pointer hex and the &{ struct prefix; the generic view must not. + if strings.Contains(got, "0x") || strings.HasPrefix(got, "&{") { + t.Fatalf("render leaked a go dump: %q", got) + } + for _, want := range []string{"test.song", "title: ", "My Song", "track: ", "7"} { + if !strings.Contains(got, want) { + t.Fatalf("render %q missing %q", got, want) + } + } + // declared field order is preserved + if strings.Index(got, "title") > strings.Index(got, "track") { + t.Fatalf("fields out of declared order: %q", got) + } +} + +func TestRuntimeObjectView_Alias(t *testing.T) { + bp := astral.NewBlueprintAlias("test.level", "uint8") + ro := mustRO(t, bp) + if err := ro.UnmarshalJSON([]byte("9")); err != nil { + t.Fatal(err) + } + + got := render(ro) + + if !strings.Contains(got, "test.level") || !strings.Contains(got, "9") { + t.Fatalf("alias render %q missing type or value", got) + } + if !strings.Contains(got, "(") || !strings.Contains(got, ")") { + t.Fatalf("alias render %q not in Type(value) form", got) + } +} + +func TestRuntimeSliceView(t *testing.T) { + rs, err := astral.NewRuntimeSlice("uint32") + if err != nil { + t.Fatal(err) + } + for _, v := range []uint32{1, 2, 3} { + if err := rs.Append(astral.NewUint32(v)); err != nil { + t.Fatal(err) + } + } + + got := render(rs) + + if strings.Contains(got, "0x") { + t.Fatalf("slice render leaked a go dump: %q", got) + } + for _, want := range []string{"[", "1", "2", "3", "]"} { + if !strings.Contains(got, want) { + t.Fatalf("slice render %q missing %q", got, want) + } + } +} diff --git a/mod/log/views/runtime_slice_view.go b/mod/log/views/runtime_slice_view.go new file mode 100644 index 000000000..5c0ddae55 --- /dev/null +++ b/mod/log/views/runtime_slice_view.go @@ -0,0 +1,33 @@ +package views + +import ( + "github.com/cryptopunkscc/astrald/astral" + "github.com/cryptopunkscc/astrald/astral/fmt" + "github.com/cryptopunkscc/astrald/mod/log/styles" +) + +// RuntimeSliceView renders a blueprint-backed slice as [elem, elem, ...], delegating each +// element to fmt.ViewFor. The carrier's ObjectType is the constant "slice", so a per-type +// builder matches it directly. See issue #337. +type RuntimeSliceView struct { + *astral.RuntimeSlice +} + +func (v RuntimeSliceView) Render() (out string) { + out += styles.Highlight.Render("[") + for i := 0; i < v.Len(); i++ { + if i > 0 { + out += ", " + } + out += fmt.ViewFor(v.At(i)).Render() + } + out += styles.Highlight.Render("]") + + return +} + +func init() { + fmt.SetView(func(o *astral.RuntimeSlice) fmt.View { + return RuntimeSliceView{RuntimeSlice: o} + }) +}