From 58bbc262194785f075d7e5651c439ebcb48e2247 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:45:07 +0100 Subject: [PATCH] G V-8 (TS), A-17, test: the TypeScript writer is generic over the contract's bodies and names its events; findings-asked is mesh-conditions' event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TypeScript writer holds no FindingBody of its own and works on the wire form (checkFinding, views reader findings laid out by viewFindingBody); both writers name findings-asked as the seat mesh-conditions' event. The view-waits test waits on the raise, not on a count of yields. Builds: design 47 G §686, §640, §687, A-3, A-13, A-17 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- go/views/stdio_writer.go | 13 +-- go/views/stdio_writer_test.go | 2 +- go/views/views_test.go | 9 +- src/views/index.ts | 46 ++++++++ src/views/writer.ts | 191 ++++++++++++++-------------------- test/sdk.test.ts | 34 ++++-- 6 files changed, 165 insertions(+), 130 deletions(-) diff --git a/go/views/stdio_writer.go b/go/views/stdio_writer.go index d8b2aa4c..e966ab88 100644 --- a/go/views/stdio_writer.go +++ b/go/views/stdio_writer.go @@ -10,9 +10,13 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" ) +// FindingsAskedEvent is the event `findings-asked`, always the seat mesh-conditions' event, named with that seat (design 47 G +// A-17); a stdio module's writer subscribes to it by this name, and no module supplies it. +const FindingsAskedEvent = "mesh-conditions.findings-asked" + // NewStdioWriter makes the finding writer of a stdio module (design 47 G A-16, §461, §485, §503): it fills `Publish` from // the module's own event publish (stdio.Emit, answered once the bus acknowledged it) and `Listen` from its subscription -// (stdio.SubscribeNew, only the asks published after it is made), so no module writes that adapter. A `Publish` or `Listen` +// (stdio.SubscribeNew of FindingsAskedEvent, only the asks published after it is made), so no module writes that adapter. A `Publish` or `Listen` // the config already holds is kept. The runtime publishes and subscribes by the event's name on the subject the module's // membership names for it, so each of the writer's four subjects (FindingSubjects) is told by its event. func NewStdioWriter[F, C, S, A any](cfg WriterConfig[F, C, S, A]) (*Writer[F, C, S, A], error) { @@ -32,11 +36,8 @@ func newStdioWriter[F, C, S, A any](cfg WriterConfig[F, C, S, A], } } if cfg.Listen == nil { - cfg.Listen = func(_ context.Context, subject string, handle func([]byte)) (func(), error) { - event, err := eventOfSubject(subject) - if err != nil { - return nil, err - } + cfg.Listen = func(_ context.Context, _ string, handle func([]byte)) (func(), error) { + event := FindingsAskedEvent var stopped atomic.Bool // the runtime keeps a subscription until the process ends: a stopped listener hands on nothing if err := subscribe(event, func(env stdio.Envelope) error { diff --git a/go/views/stdio_writer_test.go b/go/views/stdio_writer_test.go index c0262d9d..d1893bd0 100644 --- a/go/views/stdio_writer_test.go +++ b/go/views/stdio_writer_test.go @@ -85,7 +85,7 @@ func TestStdioWriterPublishesAndListensThroughTheModulesOwnPlumbing(t *testing.T } var heard []string stop, err := w.cfg.Listen(context.Background(), w.subj.Asked, func(b []byte) { heard = append(heard, string(b)) }) - if err != nil || len(subscribed) != 1 || subscribed[0] != "findings-asked" { + if err != nil || len(subscribed) != 1 || subscribed[0] != "mesh-conditions.findings-asked" { t.Fatalf("the listen: %v %v", subscribed, err) } _ = deliver(stdio.Envelope{Body: json.RawMessage(`{"ask":"x"}`)}) diff --git a/go/views/views_test.go b/go/views/views_test.go index b8101c3b..74cc5108 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -310,8 +310,9 @@ func TestViews(t *testing.T) { defer func() { waitBound = kept }() var raised, cleared, said []Status var mu sync.Mutex + raisedCh := make(chan struct{}, 4) // told after the part's raise, so the test reads it only once it is made f := &Follower{Owner: "o", Tool: "o.revision", State: BranchState("catalog", "trunk"), Now: now, - RaiseWaits: func(s Status) { mu.Lock(); raised = append(raised, s); mu.Unlock() }, + RaiseWaits: func(s Status) { mu.Lock(); raised = append(raised, s); mu.Unlock(); raisedCh <- struct{}{} }, ClearWaits: func(s Status) { mu.Lock(); cleared = append(cleared, s); mu.Unlock() }, Said: func(s Status) { mu.Lock(); said = append(said, s); mu.Unlock() }} f.Holds("trunk.11") @@ -321,8 +322,10 @@ func TestViews(t *testing.T) { t.Fatalf("status = %+v", s) } ended <- true - for i := 0; i < 1000 && !f.Status().WaitsRaised; i++ { - runtime.Gosched() + select { + case <-raisedCh: + case <-time.After(10 * time.Second): + t.Fatal("the bound ended and view-waits was not raised") } mu.Lock() if len(raised) != 1 || raised[0].WaitsFor != "trunk.12" { diff --git a/src/views/index.ts b/src/views/index.ts index 903b8bcc..c4feaea4 100644 --- a/src/views/index.ts +++ b/src/views/index.ts @@ -482,6 +482,52 @@ export function viewWaitsFinding(part: string, s: Status, at: Date): Finding { }; } +/** The kinds a views reader's findings written through the part's writer are of, and the one that is urgent (design 47 G §687). */ +const TOO_LARGE_KIND = "view-too-large"; + +/** A name as a key's part (design 47 G §73): a character outside letters, digits, `-`, `/` and `_` is `=` and its two lowercase hex digits per byte. */ +export function escapeName(name: string): string { + let out = ""; + for (const b of new TextEncoder().encode(name)) { + const c = String.fromCharCode(b); + out += /[A-Za-z0-9\-/_]/.test(c) ? c : `=${b.toString(16).padStart(2, "0")}`; + } + return out; +} + +/** + * A views reader's finding laid out in its wire form by mesh-sdk (design 47 G §687): `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 Go side's `views.WriterFindings`. + */ +export function viewFindingBody(f: Finding, module: string, node: string): Record { + return { + kind: f.kind, + scope: "module", + id: escapeName(f.id), + severity: f.kind === TOO_LARGE_KIND ? "urgent" : "warning", + source: "views", + subject: { scope: "module", id: module, ...(node ? { machine: node } : {}) }, + summary: f.summary, + ...(f.headline ? { headline: f.headline } : {}), + ...(f.at ? { at: f.at } : {}), + }; +} + +/** The part of a Writer a views reader's findings use, whatever the contract's body types. */ +export interface WireWriter { + write(finding: any): Promise; + clear(scope: string, id: string, kind: string, why: string): Promise; +} + +/** A views reader's findings written through the part's writer, as kinds of the reader's own (design 47 G §639, §687). */ +export function writerFindings(writer: WireWriter, module: string, node: string): { put(f: Finding): Promise; delete(f: Finding): Promise } { + return { + put: (f) => writer.write(viewFindingBody(f, module, node)), + delete: (f) => writer.clear("module", escapeName(f.id), f.kind, "the part no longer observes it"), + }; +} + /** A Follower's `said` that keeps its status at statusKey in the reader's state STATUS_STATE. */ export function keepStatus(st: State): (s: Status) => Promise { return async (s) => { diff --git a/src/views/writer.ts b/src/views/writer.ts index 0b9b2417..cfcb018f 100644 --- a/src/views/writer.ts +++ b/src/views/writer.ts @@ -1,74 +1,44 @@ // The finding writer, TypeScript side (design 47 G §461, §485, §503): the same acts as the Go `views` writer, with the -// same bodies (§439) and rules, for the observers written in TypeScript. It keeps an observer's findings in its own state -// `findings` and says them as events (§383, §384). It names no seat and no module: the observer hands in the subjects of -// `finding-written`, `finding-cleared`, `findings-stated` and `findings-asked`, which the seat's generated contract makes. +// same rules, for the observers written in TypeScript, generic over the contract's body types (plain structures) and +// working on their wire form, with no layout of a finding of its own (design 47 G A-3, §640). It keeps an observer's findings in its own state +// `findings` and says them as events (§383, §384). It names the four events itself (design 47 G A-13, A-17): it says +// `finding-written`, `finding-cleared` and `findings-stated` by their names, which the runtime publishes on the subject the +// module's membership names for each, and hears `findings-asked` as the seat mesh-conditions' event; a module passes none. // Its acts, `write`, `clear` and a statement, run one at a time in the order they are asked, on one chain of promises. import type { State } from "../state/index.js"; +/** The event `findings-asked` is always the seat mesh-conditions' (design 47 G A-17), named with that seat. */ +export const FINDINGS_ASKED_EVENT = "mesh-conditions.findings-asked"; +/** The events the writer says, by their names (design 47 G §383, A-13). */ +export const FINDING_WRITTEN_EVENT = "finding-written"; +export const FINDING_CLEARED_EVENT = "finding-cleared"; +export const FINDINGS_STATED_EVENT = "findings-stated"; + /** How long an event the bus did not acknowledge is sent again before it is logged as an error (§383). */ export const PUBLISH_RETRY_MS = 60 * 1000; /** How often a failing `observe` is called again until a set is said (§485). */ export const OBSERVE_RETRY_MS = 60 * 1000; -/** What a finding is about. */ -export interface FindingSubject { - scope: string; - id: string; - machine?: string; - also?: string[]; -} - -/** One observation, as it was said. */ -export interface FindingEvidence { - at: string; - said: string; -} - -/** One answer the operator may give from a notification. */ -export interface FindingAction { - label: string; - verb: string; - machine?: string; - arguments?: Record; - level?: "acknowledge" | "approve"; -} - /** - * One finding as an observer says it: the layout whole of §439's list. It is the body of `finding-written`, of each entry - * of `findings-stated`, and the value of its key `..` in the state `findings`. + * What the writer needs of a contract's finding body (design 47 G A-3, §640): the fields it reads and sets by their + * contract names. The body types are the seat generator's, plain structures; a body type lacking one of these fields + * does not compile as the writer's `F`, and a value lacking its `subject` is refused at its first write. */ -export interface FindingBody { +export interface FindingFields { kind: string; scope: string; id: string; + subject: object; at?: string; since?: string; - severity: "urgent" | "warning"; - summary: string; - headline?: string; - explanation?: string; - subject: FindingSubject; - evidence?: FindingEvidence[]; - source: string; observer?: string; owner?: string; "waiting-on"?: string; - actions?: FindingAction[]; - needs?: string; - resolver?: string; - ref?: string; - run?: string; } -/** The body of `finding-cleared`. */ -export interface FindingCleared { - kind: string; - scope: string; - id: string; - at: string; - why: string; -} +/** A body in its wire form (design 47 G A-3). */ +type Wire = Record; /** A key the observer cannot judge yet since its start: its `since` and its bound (a duration such as `5m`). */ export interface PendingKey { @@ -77,27 +47,9 @@ export interface PendingKey { bound: string; } -/** The body of `findings-stated`. */ -export interface FindingsStated { - at: string; - asked?: string; - /** The kinds this statement covers (design 47 G §580); none means every kind. */ - kinds?: string[]; - findings: FindingBody[]; - pending: PendingKey[]; -} - -/** The body of `findings-asked`. */ -export interface FindingsAsked { - ask: string; - at: string; - by: string; - module?: string; -} - -/** What the observer's own truth says now. */ -export interface Observed { - findings: FindingBody[]; +/** What the observer's own truth says now: its findings, in the contract's finding type, and its pending keys. */ +export interface Observed { + findings: F[]; pending: PendingKey[]; } @@ -109,8 +61,9 @@ const SEAT_FORM = /^[a-z][a-z0-9-]*$/; const AUTHOR_FORM = /^forge:[A-Za-z0-9._-]+$/; /** A finding's `owner` or `waiting-on` is a session (who@node), a seat's name or a commit's author (design 47 G §678). */ -function formOfWho(field: string, v: string | undefined): void { - if (!v || SESSION_FORM.test(v) || SEAT_FORM.test(v) || AUTHOR_FORM.test(v)) return; +function formOfWho(field: string, v: unknown): void { + if (v === undefined || v === null || v === "") return; + if (typeof v === "string" && (SESSION_FORM.test(v) || SEAT_FORM.test(v) || AUTHOR_FORM.test(v))) return; throw new Error(`the finding's ${field} "${v}" is none of a session (who@node), a seat's name or a commit's author (forge:)`); } @@ -119,21 +72,36 @@ export function findingKey(scope: string, id: string, kind: string): string { return `${scope}.${id}.${kind}`; } -export interface WriterOptions { +/** + * Refuse a finding the keeper could not make a condition of, naming the field of the first rule it breaks: the key's three + * parts (each given, the kind with no dot, since the kind is the key's last part), the subject, and the forms of `owner` + * and `waiting-on` (design 47 G §678, §689, A-3). It judges the finding's wire form: the contract's generated type is one. + */ +export function checkFinding(finding: object): void { + const m = finding as Wire; + const given = (f: string): boolean => typeof m[f] === "string" && (m[f] as string).trim() !== ""; + if (!given("scope")) throw new Error("the finding's scope is not given"); + if (!given("id")) throw new Error("the finding's id is not given"); + if (!given("kind")) throw new Error("the finding's kind is not given"); + if ((m.kind as string).includes(".")) throw new Error(`the finding's kind "${m.kind}" holds a dot, and the kind is the last part of its key`); + if (!m.subject || typeof m.subject !== "object") throw new Error("the finding's subject is not given, and the contract's finding names it"); + formOfWho("owner", m.owner); + formOfWho("waiting-on", m["waiting-on"]); +} + +export interface WriterOptions { /** The observer's module name: it fills the `observer` of a finding and tells an ask for another module. */ module: string; /** The observer's own state `findings`. */ - state: State; + state: State; /** The finding kinds this writer owns (design 47 G §580): a write or clear of another kind is refused, naming it, and a statement carries them as `kinds` and deletes only keys of them. None: every kind. The views reader's kinds are always its own besides. */ kinds?: string[]; - /** The subjects of the four events. */ - subjects: { written: string; cleared: string; stated: string; asked: string }; /** The findings the observer observes now and its pending keys. It is called inside the writer's one chain: it must not call `write` or `clear`. */ - observe(): Promise; - /** Say one message and resolve when the bus acknowledged it; `id` is the bus's de-duplication id. */ - publish(subject: string, body: unknown, id: string): Promise; - /** Make a consumer of `subject` that delivers only the messages published after it is made; resolves to its stop. */ - listen(subject: string, handle: (body: unknown) => void): Promise<() => void>; + observe(): Promise>; + /** Say one event, by its name, and resolve when the bus acknowledged it; `id` is the bus's de-duplication id. */ + publish(subject: string, body: F | C | S, id: string): Promise; + /** Make a consumer of the event `subject` names that delivers only the messages published after it is made; resolves to its stop. */ + listen(subject: string, handle: (body: A) => void): Promise<() => void>; /** Put a set larger than one bus message in the observer's sent store; resolves to what to publish instead. */ send?(label: string, body: unknown): Promise; log?(line: string): void; @@ -142,9 +110,9 @@ export interface WriterOptions { wait?(ms: number): Promise; } -/** The finding writer of an observer. */ -export class Writer { - private readonly o: WriterOptions; +/** The finding writer of an observer: `F`, `C`, `S` and `A` are the contract's bodies of `finding-written`, `finding-cleared`, `findings-stated` and `findings-asked`. */ +export class Writer { + private readonly o: WriterOptions; private chain: Promise = Promise.resolve(); private seq = 0; private stop?: () => void; @@ -154,7 +122,7 @@ export class Writer { private readonly closing: Promise; private close_!: () => void; - constructor(o: WriterOptions) { + constructor(o: WriterOptions) { this.o = o; this.closing = new Promise((r) => (this.close_ = r)); } @@ -206,7 +174,7 @@ export class Writer { try { await this.listen(); } catch (e) { - this.log(`error: the finding writer could not make its consumer of ${this.o.subjects.asked} again: ${e}`); + this.log(`error: the finding writer could not make its consumer of ${FINDINGS_ASKED_EVENT} again: ${e}`); } this.request(""); } @@ -220,13 +188,13 @@ export class Writer { } private async listen(): Promise { - const stop = await this.o.listen(this.o.subjects.asked, (body) => this.heard(body)); + const stop = await this.o.listen(FINDINGS_ASKED_EVENT, (body) => this.heard(body)); this.stop?.(); this.stop = stop; } private heard(body: unknown): void { - const a = body as FindingsAsked | null; + const a = body as Wire | null; if (!a || typeof a.ask !== "string") { this.log("an ask for findings could not be read"); return; @@ -263,12 +231,12 @@ export class Writer { } /** Keep the finding in its key and say `finding-written` only when what it says changed; the key's `since` stays. */ - write(f: FindingBody): Promise { + write(f: F): Promise { return this.act(async () => { const [full, old] = await this.complete(f); - await this.o.state.put(key(full), full); + await this.o.state.put(key(full), full as unknown as F); if (old && unsaid(old) === unsaid(full)) return; - await this.say(this.o.subjects.written, "finding-written", key(full), full); + await this.say(FINDING_WRITTEN_EVENT, "finding-written", key(full), full as unknown as F); }); } @@ -278,8 +246,8 @@ export class Writer { if (!this.owns(kind)) throw new Error(`the finding kind "${kind}" is not one this writer owns (${this.o.kinds})`); const k = findingKey(scope, id, kind); await this.o.state.delete(k); - const body: FindingCleared = { kind, scope, id, at: this.stamp(this.now()), why }; - await this.say(this.o.subjects.cleared, "finding-cleared", k, body); + const body = { kind, scope, id, at: this.stamp(this.now()), why }; + await this.say(FINDING_CLEARED_EVENT, "finding-cleared", k, body as unknown as C); }); } @@ -289,10 +257,10 @@ export class Writer { const obs = await this.o.observe(); const keep = new Set(); const kinds = this.o.kinds?.length ? [...new Set([...this.o.kinds, ...VIEW_KINDS])].sort() : undefined; - const body: FindingsStated = { at: this.stamp(this.now()), ...(asked ? { asked } : {}), ...(kinds ? { kinds } : {}), findings: [], pending: [] }; + const body = { at: this.stamp(this.now()), ...(asked ? { asked } : {}), ...(kinds ? { kinds } : {}), findings: [] as Wire[], pending: [] as PendingKey[] }; for (const f of obs.findings) { const [full] = await this.complete(f); - await this.o.state.put(key(full), full); + await this.o.state.put(key(full), full as unknown as F); keep.add(key(full)); body.findings.push(full); } @@ -307,38 +275,38 @@ export class Writer { const e = await this.o.state.get(k); if (e) { keep.add(k); - body.findings.push(e.value); + body.findings.push(e.value as unknown as Wire); } } if (!keep.has(k) && this.owns(kind)) await this.o.state.delete(k); } - let out: unknown = body; + let out = body as unknown as S; if (this.o.send) { try { - out = await this.o.send(`findings-stated.${this.o.module}`, body); + out = (await this.o.send(`findings-stated.${this.o.module}`, body)) as S; } catch (e) { this.log(`error: the findings-stated of ${this.o.module} could not be sent, the next statement covers it: ${e}`); return; } } - await this.say(this.o.subjects.stated, "findings-stated", this.o.module, out); + await this.say(FINDINGS_STATED_EVENT, "findings-stated", this.o.module, out); }); } - private async complete(f: FindingBody): Promise<[FindingBody, FindingBody | null]> { - if (!f.kind || !f.scope || !f.id) throw new Error(`a finding needs its kind, scope and id: ${f.scope}.${f.id}.${f.kind}`); - if (!this.owns(f.kind)) throw new Error(`the finding kind "${f.kind}" is not one this writer owns (${this.o.kinds})`); - formOfWho("owner", f.owner); - formOfWho("waiting-on", f["waiting-on"]); + private async complete(finding: F): Promise<[Wire, Wire | null]> { + const f: Wire = { ...(finding as object) }; + checkFinding(f); + const kind = f.kind as string; + if (!this.owns(kind)) throw new Error(`the finding kind "${kind}" is not one this writer owns (${this.o.kinds})`); const e = await this.o.state.get(key(f)); - const old = e ? e.value : null; - const at = f.at ?? this.stamp(this.now()); - const since = old?.since ?? f.since ?? at; + const old = e ? (e.value as unknown as Wire) : null; + const at = typeof f.at === "string" && f.at ? f.at : this.stamp(this.now()); + const since = (typeof old?.since === "string" && old.since) || (typeof f.since === "string" && f.since) || at; return [{ ...f, observer: f.observer || this.o.module, at, since }, old]; } /** Publish awaiting the acknowledgement, again for a minute when unacknowledged, then log the error naming what it said. */ - private async say(subject: string, event: string, what: string, body: unknown): Promise { + private async say(subject: string, event: string, what: string, body: F | C | S): Promise { const id = `${this.o.module}-${event}-${this.now().getTime()}-${++this.seq}`; const start = this.now().getTime(); for (let delay = 1000; ; delay = Math.min(delay * 2, 10000)) { @@ -355,12 +323,13 @@ export class Writer { } } -function key(f: FindingBody): string { - return findingKey(f.scope, f.id, f.kind); +function key(f: object): string { + const m = f as Wire; + return findingKey(m.scope as string, m.id as string, m.kind as string); } /** A finding as compared to tell whether what it says changed: not the time it was last seen, nor what saw it. */ -function unsaid(f: FindingBody): string { +function unsaid(f: Wire): string { const { at: _at, run: _run, ...rest } = f; return canonical(rest); } diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 28419900..f75271fe 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -861,7 +861,7 @@ test("the finding writer writes, clears and states findings as the Go writer doe const w = new Writer({ module: "m", state: st, - subjects: { written: "w", cleared: "c", stated: "s", asked: "a" }, + observe: async () => { if (++observes <= 2) throw new Error("not yet"); return { findings: [{ kind: "k", scope: "module", id: "a", severity: "warning", summary: "now", source: "p", subject: { scope: "module", id: "a" } }], pending: [{ key: "module.wait.k", since: "2026-11-11T11:00:00Z", bound: "5m" }] }; @@ -870,7 +870,7 @@ test("the finding writer writes, clears and states findings as the Go writer doe if (refuse > 0) { refuse--; throw new Error("no ack"); } sent.push({ subject, body }); }, - listen: async (_s, h) => { consumers++; ask = h; return () => {}; }, + listen: async (s, h) => { assert.equal(s, "mesh-conditions.findings-asked"); consumers++; ask = h; return () => {}; }, log: (l) => logs.push(l), now: () => new Date(clock), wait: async (ms) => { waits.push(ms); clock += ms; return true; }, @@ -881,11 +881,11 @@ test("the finding writer writes, clears and states findings as the Go writer doe clock += 3600_000; await w.write({ ...f, run: "other" }); // nothing it says changed await w.write({ ...f, summary: "two" }); - assert.deepEqual(sent.map((s) => s.subject), ["w", "w"]); + assert.deepEqual(sent.map((s) => s.subject), ["finding-written", "finding-written"]); assert.equal(sent[1].body.since, first); assert.equal(sent[1].body.observer, "m"); await w.clear("module", "never", "k", "held by the keeper"); - assert.equal(sent[2].subject, "c"); + assert.equal(sent[2].subject, "finding-cleared"); assert.equal(sent[2].body.why, "held by the keeper"); // an unacknowledged event is sent again for a minute, then logged naming the finding refuse = 1000; @@ -900,7 +900,7 @@ test("the finding writer writes, clears and states findings as the Go writer doe await w.start(); for (let i = 0; i < 50 && sent.length < 4; i++) await new Promise((r) => setImmediate(r)); const stated = sent[3]; - assert.equal(stated.subject, "s"); + assert.equal(stated.subject, "findings-stated"); assert.deepEqual(waits.slice(0, 2), [OBSERVE_RETRY_MS, OBSERVE_RETRY_MS]); assert.equal(stated.body.findings.length, 1); assert.equal(stated.body.findings[0].since, first); @@ -921,7 +921,7 @@ test("the finding writer writes, clears and states findings as the Go writer doe let release!: () => void; const gate = new Promise((r) => (release = r)); const w2 = new Writer({ - module: "m", state: st, subjects: { written: "w", cleared: "c", stated: "s", asked: "a" }, + module: "m", state: st, observe: async () => { await gate; return { findings: [], pending: [] }; }, publish: async () => {}, listen: async () => () => {}, }); @@ -935,7 +935,7 @@ test("the finding writer writes, clears and states findings as the Go writer doe }); test("the finding writer owns its kinds, keeps a views reader's findings in its statement and refuses an owner of no form", async () => { - const { Writer, VIEW_KINDS } = await import("../dist/views/index.js"); + const { Writer, VIEW_KINDS, checkFinding, escapeName, viewFindingBody, writerFindings, viewWaitsFinding } = await import("../dist/views/index.js"); const store = new Map(); const st: any = { get: async (k: string) => (store.has(k) ? { key: k, value: store.get(k), revision: 1 } : null), @@ -945,7 +945,7 @@ test("the finding writer owns its kinds, keeps a views reader's findings in its }; const sent: { subject: string; body: any }[] = []; const w = new Writer({ - module: "m", state: st, kinds: ["k"], subjects: { written: "w", cleared: "c", stated: "s", asked: "a" }, + module: "m", state: st, kinds: ["k"], observe: async () => ({ findings: [], pending: [] }), publish: async (subject, body) => void sent.push({ subject, body }), listen: async () => () => {}, @@ -954,6 +954,10 @@ test("the finding writer owns its kinds, keeps a views reader's findings in its await assert.rejects(w.write({ ...f, kind: "other" }), /"other"/); await assert.rejects(w.clear("module", "a", "other", "why"), /"other"/); await assert.rejects(w.write({ ...f, owner: "Not A Form" }), /owner/); + // a body whose wire form lacks a field the contract names fails at its first write, naming it (design 47 G A-3) + const { subject: _s, ...noSubject } = f; + await assert.rejects(w.write(noSubject as any), /subject/); + assert.throws(() => checkFinding({ ...f, kind: "a.b" }), /dot/); await assert.rejects(w.write({ ...f, "waiting-on": "x y" }), /waiting-on/); await w.write({ ...f, owner: "me@node-1" }); await w.write({ ...f, kind: VIEW_KINDS[0], id: "m.o.s" }); @@ -961,7 +965,19 @@ test("the finding writer owns its kinds, keeps a views reader's findings in its await w.state(); assert.deepEqual([...store.keys()].sort(), ["module.m.o.s.view-waits", "module.x.other"].sort()); const stated = sent.at(-1)!; - assert.equal(stated.subject, "s"); + assert.equal(stated.subject, "findings-stated"); assert.ok(stated.body.kinds.includes("k") && stated.body.kinds.includes("view-waits")); assert.equal(stated.body.findings.length, 1); + // a views reader's findings are laid out by the writer from the part's plain ones (design 47 G §687) + assert.equal(escapeName("a b.c"), "a=20b=2ec"); + const vf = viewWaitsFinding("m", { owner: "o", state: "s", "waiting-since": "2026-11-11T12:00:00Z", "waits-for": "r7", view: "" } as any, new Date("2026-11-11T12:06:00Z")); + const laid = viewFindingBody(vf, "m", "n1"); + assert.deepEqual([laid.kind, laid.scope, laid.id, laid.severity, laid.source], ["view-waits", "module", "m=2eo=2es", "warning", "views"]); + assert.deepEqual(laid.subject, { scope: "module", id: "m", machine: "n1" }); + assert.equal(viewFindingBody({ ...vf, kind: "view-too-large" }, "m", "n1").severity, "urgent"); + const vw = writerFindings(w, "m", "n1"); + await vw.put(vf); + assert.equal(store.has("module.m=2eo=2es.view-waits"), true); + await vw.delete(vf); + assert.equal(store.has("module.m=2eo=2es.view-waits"), false); });