From 3a438786e34c93fb5a21725ad7c7c2b6e45a4204 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:33:23 +0100 Subject: [PATCH] G V-8: the finding writer takes the generated finding type through views.FindingBody and exports CheckFinding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit views.FindingBody becomes the interface of the accessors the seat generator emits (Kind/SetKind per field, one Subject/SetSubject pair); the writer is given the contract's constructors of the finding, finding-cleared, findings-stated and findings-asked bodies and holds none of their layouts. WriterFindings takes only the constructor and lays the views reader's four findings out itself (scope module, id escaped, urgent for view-too-large, source views, subject the module on its node). views.CheckFinding(b) error is the one check of the key's parts, the kind and the owner and waiting-on forms, run before every write. Builds: design 47 G §686, §687, §689, §640, §688, A-1 (.work/47-G/dev2-ledger.md) Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- go/views/contract_fake_test.go | 126 +++++++++++++ go/views/reader.go | 38 +++- go/views/writer.go | 265 ++++++++++++++++------------ go/views/writer_integration_test.go | 7 +- go/views/writer_test.go | 94 +++++++--- 5 files changed, 383 insertions(+), 147 deletions(-) create mode 100644 go/views/contract_fake_test.go diff --git a/go/views/contract_fake_test.go b/go/views/contract_fake_test.go new file mode 100644 index 00000000..85fcae11 --- /dev/null +++ b/go/views/contract_fake_test.go @@ -0,0 +1,126 @@ +package views_test + +import ( + "time" + + "git.novox.be/novox/mesh-sdk/go/views" +) + +// The test's own stand-in for the types the seat generator emits for the seat `mesh-conditions` (design 47 G §686, +// §640): mesh-sdk keeps no such layout, so the writer's tests bring one, with the accessors the generator emits. + +type testSubject struct { + Also []string `json:"also,omitempty"` + ID string `json:"id"` + Machine string `json:"machine,omitempty"` + Scope string `json:"scope"` +} + +type testFinding struct { + AtVal time.Time `json:"at"` + Explanation string `json:"explanation,omitempty"` + HeadlineVal string `json:"headline,omitempty"` + IDField string `json:"id"` + KindField string `json:"kind"` + ObserverVal string `json:"observer"` + Owner string `json:"owner,omitempty"` + Run string `json:"run,omitempty"` + ScopeField string `json:"scope"` + SeverityVal string `json:"severity"` + SinceVal time.Time `json:"since"` + SourceVal string `json:"source"` + SubjectVal testSubject `json:"subject"` + SummaryVal string `json:"summary"` + WaitingOn string `json:"waiting-on,omitempty"` +} + +var _ views.FindingBody = (*testFinding)(nil) + +func (f *testFinding) Kind() string { return f.KindField } +func (f *testFinding) SetKind(v string) { f.KindField = v } +func (f *testFinding) Scope() string { return f.ScopeField } +func (f *testFinding) SetScope(v string) { f.ScopeField = v } +func (f *testFinding) ID() string { return f.IDField } +func (f *testFinding) SetID(v string) { f.IDField = v } +func (f *testFinding) At() time.Time { return f.AtVal } +func (f *testFinding) Since() time.Time { return f.SinceVal } +func (f *testFinding) SetSince(v time.Time) { f.SinceVal = v } +func (f *testFinding) Severity() string { return f.SeverityVal } +func (f *testFinding) SetSeverity(v string) { f.SeverityVal = v } +func (f *testFinding) Summary() string { return f.SummaryVal } +func (f *testFinding) SetSummary(v string) { f.SummaryVal = v } +func (f *testFinding) Headline() string { return f.HeadlineVal } +func (f *testFinding) SetHeadline(v string) { f.HeadlineVal = v } +func (f *testFinding) Source() string { return f.SourceVal } +func (f *testFinding) SetSource(v string) { f.SourceVal = v } +func (f *testFinding) Observer() string { return f.ObserverVal } +func (f *testFinding) SetObserver(v string) { f.ObserverVal = v } +func (f *testFinding) SetAt(v time.Time) { f.AtVal = v } +func (f *testFinding) SetSubject(a []string, id, m, s string) { + f.SubjectVal = testSubject{Also: a, ID: id, Machine: m, Scope: s} +} +func (f *testFinding) Subject() ([]string, string, string, string) { + return f.SubjectVal.Also, f.SubjectVal.ID, f.SubjectVal.Machine, f.SubjectVal.Scope +} + +type testCleared struct { + At time.Time `json:"at"` + ID string `json:"id"` + Kind string `json:"kind"` + Scope string `json:"scope"` + Why string `json:"why"` +} + +func (c *testCleared) SetKind(v string) { c.Kind = v } +func (c *testCleared) SetScope(v string) { c.Scope = v } +func (c *testCleared) SetID(v string) { c.ID = v } +func (c *testCleared) SetAt(v time.Time) { c.At = v } +func (c *testCleared) SetWhy(v string) { c.Why = v } + +type testPending struct { + Bound string `json:"bound"` + Key string `json:"key"` + Since time.Time `json:"since"` +} + +type testStated struct { + Asked string `json:"asked,omitempty"` + At time.Time `json:"at"` + Findings []testFinding `json:"findings"` + Kinds []string `json:"kinds,omitempty"` + Pending []testPending `json:"pending"` +} + +func newTestStated() *testStated { + return &testStated{Findings: []testFinding{}, Pending: []testPending{}} +} + +func (s *testStated) SetAt(v time.Time) { s.At = v } +func (s *testStated) SetAsked(v string) { s.Asked = v } +func (s *testStated) SetKinds(v []string) { s.Kinds = v } +func (s *testStated) AddFinding(b views.FindingBody) { + s.Findings = append(s.Findings, *b.(*testFinding)) +} +func (s *testStated) AddPending(k string, since time.Time, bound string) { + s.Pending = append(s.Pending, testPending{Bound: bound, Key: k, Since: since}) +} + +type testAsked struct { + AskID string `json:"ask"` + Mod string `json:"module,omitempty"` +} + +func (a *testAsked) Ask() string { return a.AskID } +func (a *testAsked) Module() string { return a.Mod } + +var bodies = struct { + NewFinding func() views.FindingBody + NewCleared func() views.ClearedBody + NewStated func() views.StatedBody + NewAsked func() views.AskedBody +}{ + func() views.FindingBody { return &testFinding{} }, + func() views.ClearedBody { return &testCleared{} }, + func() views.StatedBody { return newTestStated() }, + func() views.AskedBody { return &testAsked{} }, +} diff --git a/go/views/reader.go b/go/views/reader.go index 784b1fc2..6b06f410 100644 --- a/go/views/reader.go +++ b/go/views/reader.go @@ -616,22 +616,46 @@ func (r *Reader) Tools(prefix string) []stdio.Tool { } } +// FindingScope is the scope of a views reader's findings: they are findings of the part's module (design 47 G §687). +const FindingScope = "module" + // WriterFindings writes a views reader's findings through the part's mesh-sdk writer, as kinds of the reader's own -// (design 47 G §639, §580): the Findings of a reader, never a writer of its own. Body is the part's: it lays a Finding -// out as the finding of the seat `mesh-conditions` (its scope, severity, source and subject are the seat's contract's, -// not this package's); the writer's statement covers the reader's open findings beside its observe's. +// (design 47 G §639, §580, §687): the Findings of a reader, never a writer of its own. mesh-sdk lays each finding out +// through the body's setters, and the part supplies only New, the generated contract's constructor of a finding body: +// `kind` the finding's, `scope` module, `id` the finding's id escaped as a key's name is, `severity` warning but urgent +// for view-too-large, `source` views, `subject` the part's module on its node, `summary` and `headline` the finding's +// texts. The writer's statement covers the reader's open findings beside its observe's. type WriterFindings struct { Writer *Writer - Body func(Finding) FindingBody + New func() FindingBody +} + +func (o WriterFindings) body(f Finding) FindingBody { + b := o.New() + b.SetKind(f.Kind) + b.SetScope(FindingScope) + b.SetID(keys.EscapeName(f.ID)) + b.SetSeverity("warning") + if f.Kind == TooLargeKind { + b.SetSeverity("urgent") + } + b.SetSource("views") + b.SetSubject(nil, o.Writer.cfg.Module, o.Writer.cfg.Node, FindingScope) + b.SetSummary(f.Summary) + b.SetHeadline(f.Headline) + if !f.At.IsZero() { + b.SetAt(f.At) + } + return b } // Put writes one finding. func (o WriterFindings) Put(f Finding) error { - return o.Writer.Write(context.Background(), o.Body(f)) + return o.Writer.Write(context.Background(), o.body(f)) } // Delete clears one finding. func (o WriterFindings) Delete(f Finding) error { - b := o.Body(f) - return o.Writer.Clear(context.Background(), b.Scope, b.ID, b.Kind, "the part no longer observes it") + b := o.body(f) + return o.Writer.Clear(context.Background(), b.Scope(), b.ID(), b.Kind(), "the part no longer observes it") } diff --git a/go/views/writer.go b/go/views/writer.go index 0d9436a6..7c01cb43 100644 --- a/go/views/writer.go +++ b/go/views/writer.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "regexp" "slices" "strings" @@ -30,56 +31,67 @@ const ( ObserveRetryEvery = time.Minute ) -// FindingSubject is what a finding is about (the `Subject` of design 47 G §439's finding). -type FindingSubject struct { - Scope string `json:"scope"` - ID string `json:"id"` - Machine string `json:"machine,omitempty"` - Also []string `json:"also,omitempty"` +// FindingBody is one finding as the seat `mesh-conditions` lays it out (design 47 G §686, §640): the writer keeps no copy +// of that layout, it takes the type the seat generator emits through these accessors. A getter and a setter for each field +// the writer reads or fills; the subject is one pair, its fields as plain values in the generated struct's order (design 47 +// G A-1). A body also marshals as JSON to its layout, which is how it is kept and said. +type FindingBody interface { + Kind() string + SetKind(string) + Scope() string + SetScope(string) + ID() string + SetID(string) + At() time.Time + SetAt(time.Time) + Since() time.Time + SetSince(time.Time) + Severity() string + SetSeverity(string) + Summary() string + SetSummary(string) + Headline() string + SetHeadline(string) + Source() string + SetSource(string) + Subject() (also []string, id, machine, scope string) + SetSubject(also []string, id, machine, scope string) + Observer() string + SetObserver(string) } -// FindingEvidence is one observation, as it was said. -type FindingEvidence struct { - At time.Time `json:"at"` - Said string `json:"said"` +// ClearedBody is the body of `finding-cleared`, as the generated contract's type takes its fields (design 47 G A-1). +type ClearedBody interface { + SetKind(string) + SetScope(string) + SetID(string) + SetAt(time.Time) + SetWhy(string) } -// FindingAction is one answer the operator may give from a notification. -type FindingAction struct { - Label string `json:"label"` - Verb string `json:"verb"` - Machine string `json:"machine,omitempty"` - Arguments map[string]string `json:"arguments,omitempty"` - Level string `json:"level,omitempty"` +// StatedBody is the body of `findings-stated`, as the generated contract's type takes its fields (design 47 G A-1): +// every finding the observer observes now, and each key it cannot judge yet, with its since and its bound (a duration +// such as 5m). +type StatedBody interface { + SetAt(time.Time) + SetAsked(string) + // SetKinds are the kinds the statement covers (design 47 G §580); none means every kind. + SetKinds([]string) + AddFinding(FindingBody) + AddPending(key string, since time.Time, bound string) } -// FindingBody is one finding as an observer says it: the layout whole of design 47 G §439's list, the body of -// `finding-written`, of each entry of `findings-stated` and the value of its key `..` in the state `findings`. -type FindingBody struct { - Kind string `json:"kind"` - Scope string `json:"scope"` - ID string `json:"id"` - At time.Time `json:"at"` - Since time.Time `json:"since"` - Severity string `json:"severity"` - Summary string `json:"summary"` - Headline string `json:"headline,omitempty"` - Explanation string `json:"explanation,omitempty"` - Subject FindingSubject `json:"subject"` - Evidence []FindingEvidence `json:"evidence,omitempty"` - Source string `json:"source"` - Observer string `json:"observer"` - Owner string `json:"owner,omitempty"` - WaitingOn string `json:"waiting-on,omitempty"` - Actions []FindingAction `json:"actions,omitempty"` - Needs string `json:"needs,omitempty"` - Resolver string `json:"resolver,omitempty"` - Ref string `json:"ref,omitempty"` - Run string `json:"run,omitempty"` +// AskedBody is the body of `findings-asked` the writer reads: the ask's id and the module asked, none asking every +// observer (design 47 G A-1). +type AskedBody interface { + Ask() string + Module() string } -// Key is the finding's key in the state `findings`: `..` (novox/hq ADR 0564 §2). -func (f FindingBody) Key() string { return FindingKey(f.Scope, f.ID, f.Kind) } +// FindingKey is the key of the finding of scope, id and kind: `..` (novox/hq ADR 0564 §2). +func FindingKey(scope, id, kind string) string { return scope + "." + id + "." + kind } + +func keyOf(b FindingBody) string { return FindingKey(b.Scope(), b.ID(), b.Kind()) } // ViewKinds are the finding kinds a views reader writes through the part's writer (design 47 G §639). var ViewKinds = []string{ViewWaitsKind, UnansweredKind, UnsaidKind, TooLargeKind} @@ -99,18 +111,6 @@ func formOfWho(field, v string) error { return fmt.Errorf("the finding's %s %q is none of a session (who@node), a seat's name or a commit's author (forge:)", field, v) } -// FindingKey is the key of the finding of scope, id and kind. -func FindingKey(scope, id, kind string) string { return scope + "." + id + "." + kind } - -// FindingCleared is the body of `finding-cleared`: an observer no longer observes the finding. -type FindingCleared struct { - Kind string `json:"kind"` - Scope string `json:"scope"` - ID string `json:"id"` - At time.Time `json:"at"` - Why string `json:"why"` -} - // PendingKey is a key the observer cannot judge yet since its start: its `since` and its bound (a duration such as 5m). type PendingKey struct { Key string `json:"key"` @@ -118,24 +118,6 @@ type PendingKey struct { Bound string `json:"bound"` } -// FindingsStated is the body of `findings-stated`: every finding the observer observes now, and what it cannot judge yet. -type FindingsStated struct { - At time.Time `json:"at"` - Asked string `json:"asked,omitempty"` - // Kinds are the kinds this statement covers (design 47 G §580); none means every kind. - Kinds []string `json:"kinds,omitempty"` - Findings []FindingBody `json:"findings"` - Pending []PendingKey `json:"pending"` -} - -// FindingsAsked is the body of `findings-asked`: the keeper asks every observer, or the one named, to state its findings. -type FindingsAsked struct { - Ask string `json:"ask"` - At time.Time `json:"at"` - By string `json:"by"` - Module string `json:"module,omitempty"` -} - // Observed is what an observer's own truth says now: the findings it observes and the keys it cannot judge yet. type Observed struct { Findings []FindingBody @@ -171,6 +153,15 @@ type WriterConfig struct { Conn *nats.Conn // Subjects are the four events' subjects. Subjects WriterSubjects + // NewFinding, NewCleared, NewStated and NewAsked make an empty body of the generated contract's types (design 47 G + // §686, §640, A-1): the writer holds no layout of its own. All four are needed. + NewFinding func() FindingBody + NewCleared func() ClearedBody + NewStated func() StatedBody + NewAsked func() AskedBody + // Node is the machine the module runs on, which a views reader's findings name in their subject (design 47 G §687); + // none given, MESH_NODE. + Node string // Observe answers the findings the observer observes now and its pending keys, or an error. It is called under the // writer's one lock, so it must not call Write or Clear. Observe func(ctx context.Context) (Observed, error) @@ -214,9 +205,14 @@ func NewWriter(cfg WriterConfig) (*Writer, error) { return nil, errors.New("a finding writer needs the observe function") case cfg.Subjects.Written == "" || cfg.Subjects.Cleared == "" || cfg.Subjects.Stated == "" || cfg.Subjects.Asked == "": return nil, errors.New("a finding writer needs the subjects of finding-written, finding-cleared, findings-stated and findings-asked") + case cfg.NewFinding == nil || cfg.NewCleared == nil || cfg.NewStated == nil || cfg.NewAsked == nil: + return nil, errors.New("a finding writer needs the generated contract's constructors of the finding, finding-cleared, findings-stated and findings-asked bodies") case cfg.JS == nil && (cfg.Publish == nil || cfg.Listen == nil): return nil, errors.New("a finding writer needs the bus connection's JetStream") } + if cfg.Node == "" { + cfg.Node = os.Getenv("MESH_NODE") + } if cfg.Now == nil { cfg.Now = func() time.Time { return time.Now().UTC() } } @@ -354,16 +350,16 @@ func (w *Writer) signal() { // heard is an ask: answered when it names no module or this one. func (w *Writer) heard(data []byte) { - var a FindingsAsked - if err := json.Unmarshal(data, &a); err != nil { + a := w.cfg.NewAsked() + if err := json.Unmarshal(data, a); err != nil { w.cfg.Logf("an ask for findings could not be read: %v", err) return } - if a.Module != "" && a.Module != w.cfg.Module { + if a.Module() != "" && a.Module() != w.cfg.Module { return } w.ctl.Lock() - w.asks, w.want = append(w.asks, a.Ask), true + w.asks, w.want = append(w.asks, a.Ask()), true w.ctl.Unlock() w.signal() } @@ -428,12 +424,16 @@ func (w *Writer) now() time.Time { return w.cfg.Now().UTC() } // unsaid is a finding as compared to say whether what it says changed: not the time it was last seen, nor what saw it. func unsaid(f FindingBody) string { - f.At, f.Run = time.Time{}, "" raw, _ := json.Marshal(f) + var m map[string]any + _ = json.Unmarshal(raw, &m) + delete(m, "at") + delete(m, "run") + raw, _ = json.Marshal(m) return string(raw) } -func (w *Writer) read(key string) (*FindingBody, error) { +func (w *Writer) read(key string) (FindingBody, error) { e, err := w.cfg.State.Get(key) if err != nil { return nil, err @@ -441,45 +441,73 @@ func (w *Writer) read(key string) (*FindingBody, error) { if e == nil { return nil, nil } - var old FindingBody - if err := json.Unmarshal(e.Value, &old); err != nil { + old := w.cfg.NewFinding() + if err := json.Unmarshal(e.Value, old); err != nil { return nil, fmt.Errorf("the finding %s in the state findings: %w", key, err) } - return &old, nil + return old, nil +} + +// CheckFinding refuses a finding the keeper could not make a condition of, naming the field of the first rule it breaks: +// the key's three parts (`scope`, `id` and `kind`, each given, the kind with no dot, since the kind is the key's last +// part), and the forms of `owner` and `waiting-on` (design 47 G §678, §689). The writer refuses such a finding before it +// writes it, and the keeper judges the findings others write with it. +func CheckFinding(b FindingBody) error { + switch { + case strings.TrimSpace(b.Scope()) == "": + return errors.New("the finding's scope is not given") + case strings.TrimSpace(b.ID()) == "": + return errors.New("the finding's id is not given") + case strings.TrimSpace(b.Kind()) == "": + return errors.New("the finding's kind is not given") + case strings.Contains(b.Kind(), "."): + return fmt.Errorf("the finding's kind %q holds a dot, and the kind is the last part of its key", b.Kind()) + } + raw, err := json.Marshal(b) + if err != nil { + return fmt.Errorf("the finding could not be read: %w", err) + } + var who struct { + Owner string `json:"owner"` + WaitingOn string `json:"waiting-on"` + } + if err := json.Unmarshal(raw, &who); err != nil { + return fmt.Errorf("the finding could not be read: %w", err) + } + if err := formOfWho("owner", who.Owner); err != nil { + return err + } + return formOfWho("waiting-on", who.WaitingOn) } // complete fills what the observer leaves to the writer and keeps the key's `since` where the key exists (design 47 G §384). -func (w *Writer) complete(f FindingBody) (FindingBody, *FindingBody, error) { - if f.Kind == "" || f.Scope == "" || f.ID == "" { - return f, nil, fmt.Errorf("a finding needs its kind, scope and id: %q.%q.%q", f.Scope, f.ID, f.Kind) - } - if !w.owns(f.Kind) { - return f, nil, fmt.Errorf("the finding kind %q is not one this writer owns (%v)", f.Kind, w.cfg.Kinds) - } - if err := formOfWho("owner", f.Owner); err != nil { +func (w *Writer) complete(f FindingBody) (FindingBody, FindingBody, error) { + if err := CheckFinding(f); err != nil { return f, nil, err } - if err := formOfWho("waiting-on", f.WaitingOn); err != nil { - return f, nil, err + if !w.owns(f.Kind()) { + return f, nil, fmt.Errorf("the finding kind %q is not one this writer owns (%v)", f.Kind(), w.cfg.Kinds) } - old, err := w.read(f.Key()) + old, err := w.read(keyOf(f)) if err != nil { return f, nil, err } - if f.Observer == "" { - f.Observer = w.cfg.Module + if f.Observer() == "" { + f.SetObserver(w.cfg.Module) } - if f.At.IsZero() { - f.At = w.now() + at := f.At() + if at.IsZero() { + at = w.now() } - f.At = f.At.UTC() + f.SetAt(at.UTC()) + since := f.Since() switch { - case old != nil && !old.Since.IsZero(): - f.Since = old.Since - case f.Since.IsZero(): - f.Since = f.At + case old != nil && !old.Since().IsZero(): + since = old.Since() + case since.IsZero(): + since = f.At() } - f.Since = f.Since.UTC() + f.SetSince(since.UTC()) return f, old, nil } @@ -492,14 +520,14 @@ func (w *Writer) Write(ctx context.Context, f FindingBody) error { if err != nil { return err } - if _, err := w.cfg.State.Put(f.Key(), f); err != nil { - return fmt.Errorf("writing the finding %s: %w", f.Key(), err) + if _, err := w.cfg.State.Put(keyOf(f), f); err != nil { + return fmt.Errorf("writing the finding %s: %w", keyOf(f), err) } - if old != nil && unsaid(*old) == unsaid(f) { + if old != nil && unsaid(old) == unsaid(f) { return nil } raw, _ := json.Marshal(f) - w.say(ctx, w.cfg.Subjects.Written, "finding-written", f.Key(), raw) + w.say(ctx, w.cfg.Subjects.Written, "finding-written", keyOf(f), raw) return nil } @@ -515,7 +543,13 @@ func (w *Writer) Clear(ctx context.Context, scope, id, kind, why string) error { if err := w.cfg.State.Delete(key); err != nil { return fmt.Errorf("clearing the finding %s: %w", key, err) } - raw, _ := json.Marshal(FindingCleared{Kind: kind, Scope: scope, ID: id, At: w.now(), Why: why}) + cleared := w.cfg.NewCleared() + cleared.SetKind(kind) + cleared.SetScope(scope) + cleared.SetID(id) + cleared.SetAt(w.now()) + cleared.SetWhy(why) + raw, _ := json.Marshal(cleared) w.say(ctx, w.cfg.Subjects.Cleared, "finding-cleared", key, raw) return nil } @@ -531,21 +565,24 @@ func (w *Writer) State(ctx context.Context, asked string) error { return fmt.Errorf("observe: %w", err) } keep := map[string]bool{} - body := FindingsStated{At: w.now(), Asked: asked, Kinds: w.statedKinds(), Findings: []FindingBody{}, Pending: []PendingKey{}} + body := w.cfg.NewStated() + body.SetAt(w.now()) + body.SetAsked(asked) + body.SetKinds(w.statedKinds()) for _, f := range obs.Findings { f, _, err := w.complete(f) if err != nil { return err } - if _, err := w.cfg.State.Put(f.Key(), f); err != nil { - return fmt.Errorf("writing the finding %s: %w", f.Key(), err) + if _, err := w.cfg.State.Put(keyOf(f), f); err != nil { + return fmt.Errorf("writing the finding %s: %w", keyOf(f), err) } - keep[f.Key()] = true - body.Findings = append(body.Findings, f) + keep[keyOf(f)] = true + body.AddFinding(f) } for _, p := range obs.Pending { keep[p.Key] = true - body.Pending = append(body.Pending, p) + body.AddPending(p.Key, p.Since, p.Bound) } keys, err := w.cfg.State.Keys() if err != nil { @@ -557,7 +594,7 @@ func (w *Writer) State(ctx context.Context, asked string) error { if i := strings.LastIndex(k, "."); !keep[k] && i >= 0 && slices.Contains(ViewKinds, k[i+1:]) { if old, err := w.read(k); err == nil && old != nil { keep[k] = true - body.Findings = append(body.Findings, *old) + body.AddFinding(old) } } if !keep[k] && w.ownsKey(k) { diff --git a/go/views/writer_integration_test.go b/go/views/writer_integration_test.go index 41c743bb..c1d54e5d 100644 --- a/go/views/writer_integration_test.go +++ b/go/views/writer_integration_test.go @@ -43,7 +43,8 @@ func TestWriterSaysItsEventsAndHearsAsksOnTheBus(t *testing.T) { t.Fatal(err) } state := &fakeState{m: map[string][]byte{}} - w, err := views.NewWriter(views.WriterConfig{Module: "m", State: state, JS: js, Conn: nc, + w, err := views.NewWriter(views.WriterConfig{Module: "m", State: state, JS: js, Conn: nc, Node: "n1", + NewFinding: bodies.NewFinding, NewCleared: bodies.NewCleared, NewStated: bodies.NewStated, NewAsked: bodies.NewAsked, Subjects: views.WriterSubjects{Written: "obs.event.written", Cleared: "obs.event.cleared", Stated: "obs.event.stated", Asked: "obs.ask"}, Observe: func(context.Context) (views.Observed, error) { return views.Observed{Findings: []views.FindingBody{finding("a", "now")}}, nil @@ -55,7 +56,7 @@ func TestWriterSaysItsEventsAndHearsAsksOnTheBus(t *testing.T) { t.Fatal(err) } defer w.Close() - next := func(want string) views.FindingsStated { + next := func(want string) testStated { t.Helper() m, err := listen.Next(jetstream.FetchMaxWait(10 * time.Second)) if err != nil { @@ -64,7 +65,7 @@ func TestWriterSaysItsEventsAndHearsAsksOnTheBus(t *testing.T) { if m.Subject() != want { t.Fatalf("said %s, want %s", m.Subject(), want) } - var st views.FindingsStated + var st testStated if want == "obs.event.stated" { if err := json.Unmarshal(m.Data(), &st); err != nil { t.Fatal(err) diff --git a/go/views/writer_test.go b/go/views/writer_test.go index 11af93a0..1815f1fa 100644 --- a/go/views/writer_test.go +++ b/go/views/writer_test.go @@ -86,7 +86,8 @@ func newRigKinds(t *testing.T, kinds ...string) *rig { r := &rig{t: t, state: &fakeState{m: map[string][]byte{}}, now: time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC), ch: make(chan struct{}, 100)} r.observe = func(context.Context) (views.Observed, error) { return views.Observed{}, nil } w, err := views.NewWriter(views.WriterConfig{ - Module: "m", State: r.state, Subjects: subjects, Kinds: kinds, + Module: "m", State: r.state, Subjects: subjects, Kinds: kinds, Node: "n1", + NewFinding: bodies.NewFinding, NewCleared: bodies.NewCleared, NewStated: bodies.NewStated, NewAsked: bodies.NewAsked, Observe: func(ctx context.Context) (views.Observed, error) { return r.observe(ctx) }, Logf: func(f string, a ...any) { r.mu.Lock() @@ -136,9 +137,9 @@ func (r *rig) events() []said { return slices.Clone(r.sent) } -func finding(id, summary string) views.FindingBody { - return views.FindingBody{Kind: "k", Scope: "module", ID: id, Severity: "warning", Summary: summary, Source: "probe", - Subject: views.FindingSubject{Scope: "module", ID: id}} +func finding(id, summary string) *testFinding { + return &testFinding{KindField: "k", ScopeField: "module", IDField: id, SeverityVal: "warning", SummaryVal: summary, SourceVal: "probe", + SubjectVal: testSubject{Scope: "module", ID: id}} } // A finding written says its event with the layout whole after the key, keeps the key's since, and says again only when @@ -163,11 +164,11 @@ func TestWriterWritesAndClearsFindingsAsEvents(t *testing.T) { if len(ev) != 2 || ev[0].subject != subjects.Written || ev[1].subject != subjects.Written { t.Fatalf("events: %+v", ev) } - var got views.FindingBody + var got testFinding if err := json.Unmarshal(ev[1].data, &got); err != nil { t.Fatal(err) } - if got.Summary != "two" || !got.Since.Equal(first) || got.Observer != "m" || got.Key() != "module.a.k" || !got.At.Equal(r.now) { + if got.SummaryVal != "two" || !got.SinceVal.Equal(first) || got.ObserverVal != "m" || views.FindingKey(got.ScopeField, got.IDField, got.KindField) != "module.a.k" || !got.AtVal.Equal(r.now) { t.Fatalf("body: %+v", got) } if e, _ := r.state.Get("module.a.k"); e == nil { @@ -180,7 +181,7 @@ func TestWriterWritesAndClearsFindingsAsEvents(t *testing.T) { t.Fatal(err) } ev = r.events() - var cl views.FindingCleared + var cl testCleared if len(ev) != 4 || ev[3].subject != subjects.Cleared || json.Unmarshal(ev[3].data, &cl) != nil || cl.ID != "never" || cl.Why != "held by the keeper" { t.Fatalf("clear events: %+v", ev) } @@ -221,7 +222,7 @@ func TestWriterStatesAtStartAndOnAsksAndRecovers(t *testing.T) { r.state.Put("module.old.k", finding("old", "x")) r.state.Put("module.wait.k", finding("wait", "x")) first := r.now - r.state.Put("module.a.k", func() views.FindingBody { f := finding("a", "before"); f.Since = first.Add(-time.Hour); return f }()) + r.state.Put("module.a.k", func() *testFinding { f := finding("a", "before"); f.SinceVal = first.Add(-time.Hour); return f }()) calls := 0 r.observe = func(context.Context) (views.Observed, error) { calls++ @@ -239,12 +240,12 @@ func TestWriterStatesAtStartAndOnAsksAndRecovers(t *testing.T) { if calls != 3 || len(r.waits) != 2 || r.waits[0] != views.ObserveRetryEvery { t.Fatalf("observe called %d times, waits %v", calls, r.waits) } - var st views.FindingsStated + var st testStated if err := json.Unmarshal(stated.data, &st); err != nil { t.Fatal(err) } - if stated.subject != subjects.Stated || st.Asked != "" || len(st.Findings) != 1 || st.Findings[0].Summary != "now" || - !st.Findings[0].Since.Equal(first.Add(-time.Hour)) || len(st.Pending) != 1 || st.Pending[0].Key != "module.wait.k" { + if stated.subject != subjects.Stated || st.Asked != "" || len(st.Findings) != 1 || st.Findings[0].SummaryVal != "now" || + !st.Findings[0].SinceVal.Equal(first.Add(-time.Hour)) || len(st.Pending) != 1 || st.Pending[0].Key != "module.wait.k" { t.Fatalf("statement: %s", stated.data) } keys, _ := r.state.Keys() @@ -265,8 +266,8 @@ func TestWriterStatesAtStartAndOnAsksAndRecovers(t *testing.T) { } } -func FindingsStatedOf(t *testing.T, e said) views.FindingsStated { - var st views.FindingsStated +func FindingsStatedOf(t *testing.T, e said) testStated { + var st testStated if err := json.Unmarshal(e.data, &st); err != nil { t.Fatal(err) } @@ -322,15 +323,16 @@ func TestWriterKindsAndReaderFindings(t *testing.T) { ctx := context.Background() r := newRigKinds(t, "k") other := finding("x", "another bundle's") - other.Kind = "other" - r.state.m[other.Key()] = []byte(`{}`) + other.KindField = "other" + otherKey := views.FindingKey(other.ScopeField, other.IDField, other.KindField) + r.state.m[otherKey] = []byte(`{}`) if err := r.w.Write(ctx, other); err == nil || !strings.Contains(err.Error(), `"other"`) { t.Errorf("a write of another kind: %v", err) } if err := r.w.Clear(ctx, "module", "x", "other", "why"); err == nil || !strings.Contains(err.Error(), `"other"`) { t.Errorf("a clear of another kind: %v", err) } - for field, f := range map[string]views.FindingBody{"owner": {Owner: "Not A Form"}, "waiting-on": {WaitingOn: "x y"}} { + for field, f := range map[string]testFinding{"owner": {Owner: "Not A Form"}, "waiting-on": {WaitingOn: "x y"}} { b := finding("o", "s") b.Owner, b.WaitingOn = f.Owner, f.WaitingOn if err := r.w.Write(ctx, b); err == nil || !strings.Contains(err.Error(), field) { @@ -345,9 +347,7 @@ func TestWriterKindsAndReaderFindings(t *testing.T) { } } // a views reader writes through the part's writer, and the statement keeps its open findings - rf := views.WriterFindings{Writer: r.w, Body: func(f views.Finding) views.FindingBody { - return views.FindingBody{Kind: f.Kind, Scope: "module", ID: f.ID, Severity: "warning", Summary: f.Summary, Source: "views"} - }} + rf := views.WriterFindings{Writer: r.w, New: bodies.NewFinding} if err := rf.Put(views.Finding{Kind: views.ViewWaitsKind, ID: "m.o.s", Summary: "waits"}); err != nil { t.Fatal(err) } @@ -357,16 +357,16 @@ func TestWriterKindsAndReaderFindings(t *testing.T) { if err := r.w.State(ctx, ""); err != nil { t.Fatal(err) } - if _, ok := r.state.m["module.m.o.s.view-waits"]; !ok { + if _, ok := r.state.m["module.m=2eo=2es.view-waits"]; !ok { t.Errorf("the statement deleted the reader's finding: %v", r.state.m) } - if _, ok := r.state.m[other.Key()]; !ok { + if _, ok := r.state.m[otherKey]; !ok { t.Errorf("the statement deleted another bundle's key") } if _, ok := r.state.m["module.ok.k"]; ok { t.Errorf("a key of its own kind neither stated nor pending stays") } - var stated views.FindingsStated + var stated testStated for _, e := range r.events() { if e.subject == subjects.Stated { _ = json.Unmarshal(e.data, &stated) @@ -378,7 +378,55 @@ func TestWriterKindsAndReaderFindings(t *testing.T) { if err := rf.Delete(views.Finding{Kind: views.ViewWaitsKind, ID: "m.o.s"}); err != nil { t.Fatal(err) } - if _, ok := r.state.m["module.m.o.s.view-waits"]; ok { + if _, ok := r.state.m["module.m=2eo=2es.view-waits"]; ok { t.Errorf("the reader's finding was not cleared") } } + +// A views reader's four findings are laid out by mesh-sdk through the body's setters (design 47 G §687): the finding's +// kind, the scope module, the id escaped as a key's name is, a warning but urgent for view-too-large, the source views, the +// subject the module on its node; and CheckFinding refuses a finding breaking a rule before it is written, naming the field +// (§689). +func TestReaderFindingsAreLaidOutAndCheckFindingRefuses(t *testing.T) { + r := newRig(t) + rf := views.WriterFindings{Writer: r.w, New: bodies.NewFinding} + for _, kind := range views.ViewKinds { + if err := rf.Put(views.Finding{Kind: kind, ID: "o.s.main", Summary: "sum " + kind, Headline: "head"}); err != nil { + t.Fatal(err) + } + e, _ := r.state.Get("module.o=2es=2emain." + kind) + var got testFinding + if e == nil || json.Unmarshal(e.Value, &got) != nil { + t.Fatalf("%s not kept: %v", kind, r.state.m) + } + sev := "warning" + if kind == views.TooLargeKind { + sev = "urgent" + } + if got.KindField != kind || got.ScopeField != "module" || got.IDField != "o=2es=2emain" || got.SeverityVal != sev || got.SourceVal != "views" || + got.SubjectVal.Scope != "module" || got.SubjectVal.ID != "m" || got.SubjectVal.Machine != "n1" || got.SummaryVal != "sum "+kind || got.HeadlineVal != "head" || got.ObserverVal != "m" { + t.Errorf("%s laid out as %+v", kind, got) + } + } + for name, c := range map[string]struct { + f *testFinding + want string + }{ + "scope": {&testFinding{IDField: "a", KindField: "k"}, "scope"}, + "id": {&testFinding{ScopeField: "module", KindField: "k"}, "id"}, + "kind": {&testFinding{ScopeField: "module", IDField: "a"}, "kind"}, + "dotted": {&testFinding{ScopeField: "module", IDField: "a", KindField: "k.x"}, "kind"}, + "owner": {&testFinding{ScopeField: "module", IDField: "a", KindField: "k", Owner: "Not A Form"}, "owner"}, + "waiting-on": {&testFinding{ScopeField: "module", IDField: "a", KindField: "k", WaitingOn: "x y"}, "waiting-on"}, + } { + if err := views.CheckFinding(c.f); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v", name, err) + } + if err := r.w.Write(context.Background(), c.f); err == nil { + t.Errorf("%s: written", name) + } + } + if err := views.CheckFinding(finding("a", "s")); err != nil { + t.Error(err) + } +}