diff --git a/src/views/writer.ts b/src/views/writer.ts index ba0efb90..0b9b2417 100644 --- a/src/views/writer.ts +++ b/src/views/writer.ts @@ -81,6 +81,8 @@ export interface PendingKey { 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[]; } @@ -99,6 +101,19 @@ export interface Observed { pending: PendingKey[]; } +/** The finding kinds a views reader writes through the part's writer (design 47 G §639). */ +export const VIEW_KINDS = ["view-waits", "first-view-unanswered", "view-unsaid", "view-too-large"]; + +const SESSION_FORM = /^[a-z0-9][a-z0-9._-]*@[a-z0-9][a-z0-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; + 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:)`); +} + /** The key of a finding in the state `findings`: `..`. */ export function findingKey(scope: string, id: string, kind: string): string { return `${scope}.${id}.${kind}`; @@ -109,6 +124,8 @@ export interface WriterOptions { module: string; /** The observer's own state `findings`. */ 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`. */ @@ -142,6 +159,10 @@ export class Writer { this.closing = new Promise((r) => (this.close_ = r)); } + private owns(kind: string): boolean { + return !this.o.kinds?.length || this.o.kinds.includes(kind) || VIEW_KINDS.includes(kind); + } + private log(line: string): void { this.o.log?.(line); } @@ -254,6 +275,7 @@ export class Writer { /** Delete the key and say `finding-cleared`, also of a key that does not exist, so a keeper holding it clears it. */ clear(scope: string, id: string, kind: string, why: string): Promise { return this.act(async () => { + 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 }; @@ -266,7 +288,8 @@ export class Writer { return this.act(async () => { const obs = await this.o.observe(); const keep = new Set(); - const body: FindingsStated = { at: this.stamp(this.now()), ...(asked ? { asked } : {}), findings: [], pending: [] }; + 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: [] }; for (const f of obs.findings) { const [full] = await this.complete(f); await this.o.state.put(key(full), full); @@ -278,7 +301,16 @@ export class Writer { body.pending.push(p); } for (const k of await this.o.state.keys()) { - if (!keep.has(k)) await this.o.state.delete(k); + const kind = k.slice(k.lastIndexOf(".") + 1); + // a views reader's open findings are stated beside observe's (design 47 G §639) + if (!keep.has(k) && VIEW_KINDS.includes(kind)) { + const e = await this.o.state.get(k); + if (e) { + keep.add(k); + body.findings.push(e.value); + } + } + if (!keep.has(k) && this.owns(kind)) await this.o.state.delete(k); } let out: unknown = body; if (this.o.send) { @@ -295,6 +327,9 @@ export class Writer { 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"]); const e = await this.o.state.get(key(f)); const old = e ? e.value : null; const at = f.at ?? this.stamp(this.now()); diff --git a/test/sdk.test.ts b/test/sdk.test.ts index f1fd618c..28419900 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -933,3 +933,35 @@ test("the finding writer writes, clears and states findings as the Go writer doe await Promise.all([stating, writing]); assert.equal(store.has("module.during.k"), true); }); + +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 store = new Map(); + const st: any = { + get: async (k: string) => (store.has(k) ? { key: k, value: store.get(k), revision: 1 } : null), + put: async (k: string, v: any) => (store.set(k, structuredClone(v)), 1), + delete: async (k: string) => void store.delete(k), + keys: async () => [...store.keys()].sort(), + }; + 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" }, + observe: async () => ({ findings: [], pending: [] }), + publish: async (subject, body) => void sent.push({ subject, body }), + listen: async () => () => {}, + }); + const f = { kind: "k", scope: "module", id: "a", severity: "warning" as const, summary: "one", source: "p", subject: { scope: "module", id: "a" } }; + 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/); + 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" }); + store.set("module.x.other", f); + 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.ok(stated.body.kinds.includes("k") && stated.body.kinds.includes("view-waits")); + assert.equal(stated.body.findings.length, 1); +});