@company-semantics/contracts 58.0.0 → 58.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/resource-keys.test.ts +30 -0
  3. package/src/api/generated-spec-hash.ts +2 -2
  4. package/src/api/generated.ts +33 -1
  5. package/src/chat/README.md +15 -4
  6. package/src/chat/__tests__/proactive-kind.test.ts +51 -0
  7. package/src/chat/index.ts +9 -0
  8. package/src/chat/proactive-kind.ts +51 -0
  9. package/src/chat/schemas.ts +92 -1
  10. package/src/chat/types.ts +19 -1
  11. package/src/index.ts +110 -0
  12. package/src/message-parts/README.md +5 -0
  13. package/src/message-parts/__tests__/suggested-replies.test.ts +52 -0
  14. package/src/message-parts/__tests__/wire.test.ts +48 -0
  15. package/src/message-parts/index.ts +8 -0
  16. package/src/message-parts/suggested-replies.ts +48 -0
  17. package/src/message-parts/types.ts +7 -1
  18. package/src/message-parts/wire.ts +26 -0
  19. package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +1 -0
  20. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +1 -0
  21. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +207 -0
  22. package/src/notifications/__tests__/fixtures.ts +9 -0
  23. package/src/notifications/__tests__/org-invite.test.ts +75 -0
  24. package/src/notifications/__tests__/render-snapshot.test.ts +8 -0
  25. package/src/notifications/kinds/org-invite.ts +27 -12
  26. package/src/notifications/payloads.ts +7 -0
  27. package/src/org/README.md +38 -0
  28. package/src/org/__tests__/canonical-facts.test.ts +118 -0
  29. package/src/org/__tests__/structure-inference.test.ts +392 -0
  30. package/src/org/__tests__/structure-provenance.test.ts +187 -0
  31. package/src/org/canonical-facts.ts +94 -1
  32. package/src/org/index.ts +54 -0
  33. package/src/org/schemas.ts +23 -0
  34. package/src/org/structure-inference.ts +521 -0
  35. package/src/proactive/README.md +125 -0
  36. package/src/proactive/__tests__/README.md +56 -0
  37. package/src/proactive/__tests__/chat-templates.test.ts +167 -0
  38. package/src/proactive/__tests__/compile-fixtures.ts +110 -0
  39. package/src/proactive/__tests__/vocabulary.test.ts +279 -0
  40. package/src/proactive/classes.ts +125 -0
  41. package/src/proactive/composer.ts +104 -0
  42. package/src/proactive/facts.ts +87 -0
  43. package/src/proactive/index.ts +52 -0
  44. package/src/proactive/kinds.ts +127 -0
  45. package/src/proactive/plan.ts +79 -0
  46. package/src/proactive/registry.ts +71 -0
  47. package/src/proactive/surfaces.ts +59 -0
  48. package/src/proactive/templates/README.md +58 -0
  49. package/src/proactive/templates/index.ts +32 -0
  50. package/src/proactive/templates/morning-brief.ts +77 -0
  51. package/src/proactive/templates/org-became-shared.ts +54 -0
  52. package/src/resource-key-types.ts +9 -0
  53. package/src/resource-keys.ts +2 -0
  54. package/src/user-notifications/README.md +10 -0
  55. package/src/user-notifications/kinds.ts +28 -0
@@ -0,0 +1,56 @@
1
+ # proactive/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Locks the claims `../README.md` and ADR-CONTRACTS-142 make that the compiler
6
+ cannot.
7
+
8
+ - `vocabulary.test.ts` — the load-bearing one is
9
+ `plan surfaces agree with the class table`, the ONE consistency the type
10
+ system cannot express (the ids PRESENT on a kind's plan agree with the
11
+ surfaces `CLASS_SURFACES[plan.class]` DECLARES, and `inboxKind` is always
12
+ there). Also: `CLASS_SURFACES` is total
13
+ over `PROACTIVE_PRESENTATION_CLASSES` and every badge names a surface its
14
+ class has; `bornRead` matches the doctrine table row for row;
15
+ `PROACTIVE_EVENT_KINDS` is total over `PROACTIVE_EVENT_KIND_IDS`; every
16
+ `inboxKind` is a real `USER_NOTIFICATION_KINDS` member in the `proactive.`
17
+ domain and never the kind string itself; every `chatTemplate` a kind names
18
+ reaches a composer; no kind sets `emailKind`; and composers are pure (same
19
+ facts, byte-identical output across two calls).
20
+ - `compile-fixtures.ts` — the negative typecheck fixtures. A COMPILED module,
21
+ not a `*.test.ts`: `src/tsconfig.json` excludes test files and vitest does
22
+ not typecheck, so a `@ts-expect-error` written in a test would never be
23
+ checked. Here `tsc -b` checks each directive, and an unused one (a claim that
24
+ stopped being true) fails `pnpm typecheck` with TS2578. The four claims: a
25
+ `notice` naming a `bannerType`, an `announcement` omitting one, an
26
+ `explained` (and, by the same union member, a `briefing`) naming one, and a
27
+ registry that is not total (a kind with no plan, and a plan for a non-kind). `vocabulary.test.ts` imports the fixtures
28
+ and shows the runtime class-table check rejects the same plans.
29
+ - `chat-templates.test.ts` — the pushed-chat composer registry in practice:
30
+ every template in `PROACTIVE_CHAT_TEMPLATES` has a composer whose `template`
31
+ equals its key (the type proves totality; this proves no entry is an
32
+ accidental `undefined`), the org-became-shared prose NAMES THE RELOCATION and
33
+ every one of the five sections that leave My settings for Org settings, the
34
+ morning brief PERSONALISES THROUGH ITS FACTS (two recipients of one
35
+ occurrence get different prose from one composer), chips stay within
36
+ `PROACTIVE_CHAT_MAX_REPLIES`, and composers emit content — no markup, no
37
+ hrefs.
38
+
39
+ ## Invariants
40
+
41
+ - These assert VOCABULARY and SHAPE, never behaviour. Anything needing a
42
+ database, a clock or an authority gate belongs in backend's
43
+ `src/proactive/__tests__/`.
44
+ - Tests iterate the REAL exported arrays and registries rather than restating
45
+ them. A hand-copied list drifts and starts passing vacuously.
46
+ - A composer is exercised through the registry (`PROACTIVE_CHAT_COMPOSERS`),
47
+ not by importing its module directly, so a template that is written but not
48
+ registered fails here rather than in the backend.
49
+
50
+ ## Public API
51
+
52
+ None — test-only.
53
+
54
+ ## Dependencies
55
+
56
+ `vitest` and the sibling modules under test.
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { PROACTIVE_CHAT_MAX_REPLIES } from "../composer";
3
+ import type { ProactiveChatMessage } from "../composer";
4
+ import type { ProactiveChatFacts } from "../facts";
5
+ import { PROACTIVE_CHAT_TEMPLATES } from "../surfaces";
6
+ import type { ProactiveChatTemplate } from "../surfaces";
7
+ import { PROACTIVE_CHAT_COMPOSERS } from "../templates/index";
8
+
9
+ /**
10
+ * One representative facts fixture per template. Total over
11
+ * `ProactiveChatTemplate`, so a template added without a fixture fails to
12
+ * compile here — the registry-wide tests below then cover it for free.
13
+ */
14
+ const FACTS: { readonly [T in ProactiveChatTemplate]: ProactiveChatFacts[T] } =
15
+ {
16
+ orgBecameShared: { orgName: "Acme", joinerDisplayName: "Ada" },
17
+ morningBrief: {
18
+ recipientDisplayName: "Ada",
19
+ orgName: "Acme",
20
+ orgLocalDateLabel: "Tuesday 25 August",
21
+ pendingActionItemCount: 2,
22
+ unreadNotificationCount: 5,
23
+ },
24
+ };
25
+
26
+ /**
27
+ * Compose a template with its fixture. The registry is a union of composers
28
+ * whose `compose` parameters differ per template, and TypeScript cannot
29
+ * correlate `PROACTIVE_CHAT_COMPOSERS[t]` with `FACTS[t]` through a variable
30
+ * `t` — the cast is the one place the correlation is asserted, and the
31
+ * fixture record above is what keeps it honest.
32
+ */
33
+ function composeFixture(template: ProactiveChatTemplate): ProactiveChatMessage {
34
+ const compose = PROACTIVE_CHAT_COMPOSERS[template].compose as (
35
+ facts: ProactiveChatFacts[ProactiveChatTemplate],
36
+ ) => ProactiveChatMessage;
37
+ return compose(FACTS[template]);
38
+ }
39
+
40
+ /**
41
+ * The pushed-chat templates: what the composer registry promises in practice
42
+ * and not just in type, and the one editorial claim the org-became-shared
43
+ * prose exists to make.
44
+ */
45
+ describe("proactive chat templates", () => {
46
+ it("registry is total over PROACTIVE_CHAT_TEMPLATES in practice", () => {
47
+ // The type check proves totality at compile time; this proves no entry is
48
+ // an accidental `undefined` and that every registered composer names the
49
+ // key it sits under — the runtime half of "kind matches registry key".
50
+ for (const template of PROACTIVE_CHAT_TEMPLATES) {
51
+ const composer = PROACTIVE_CHAT_COMPOSERS[template];
52
+ expect(composer, template).toBeDefined();
53
+ expect(composer.template).toBe(template);
54
+ }
55
+ expect(Object.keys(PROACTIVE_CHAT_COMPOSERS).sort()).toEqual(
56
+ [...PROACTIVE_CHAT_TEMPLATES].sort(),
57
+ );
58
+ });
59
+
60
+ it("org became shared template names the relocation", () => {
61
+ const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
62
+ orgName: "Acme",
63
+ joinerDisplayName: "Ada",
64
+ });
65
+ expect(msg.text).toContain("Org settings");
66
+ expect(msg.text).toContain("relocation");
67
+ expect(msg.replies.length).toBeLessThanOrEqual(PROACTIVE_CHAT_MAX_REPLIES);
68
+ });
69
+
70
+ it("org became shared prose names every relocated section", () => {
71
+ // The five sections the app ADR `nav-posture-opens-on-invite-send` lists
72
+ // as leaving My settings. Naming some and not others is the "things going
73
+ // missing" reading this chat exists to prevent.
74
+ const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
75
+ orgName: "Acme",
76
+ joinerDisplayName: "Ada",
77
+ });
78
+ for (const section of [
79
+ "Integrations",
80
+ "Invite team members",
81
+ "AI Usage",
82
+ "Budget",
83
+ "Billing",
84
+ "My settings",
85
+ ]) {
86
+ expect(msg.text, section).toContain(section);
87
+ }
88
+ });
89
+
90
+ it("org became shared interpolates only the facts it was handed", () => {
91
+ const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
92
+ orgName: "Acme",
93
+ joinerDisplayName: "Ada",
94
+ });
95
+ expect(msg.title).toBe("Your workspace is now shared");
96
+ expect(msg.text).toContain("Ada accepted your invite");
97
+ expect(msg.text).toContain("Acme");
98
+ });
99
+
100
+ it("chips carry a label and, when they differ, a full prompt", () => {
101
+ const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
102
+ orgName: "Acme",
103
+ joinerDisplayName: "Ada",
104
+ });
105
+ const ids = msg.replies.map((reply) => reply.id);
106
+ expect(new Set(ids).size).toBe(ids.length);
107
+ for (const reply of msg.replies) {
108
+ expect(reply.label.length).toBeGreaterThan(0);
109
+ if (reply.prompt !== undefined) {
110
+ expect(reply.prompt).not.toBe(reply.label);
111
+ }
112
+ }
113
+ });
114
+
115
+ it("morning brief personalises through the facts, not the occurrence", () => {
116
+ // Two people in one org on one morning share an occurrence and receive
117
+ // different prose because they were handed different facts. The composer
118
+ // knows nothing about who it is composing for — the difference is entirely
119
+ // in what it was given.
120
+ const shared = { orgName: "Acme", orgLocalDateLabel: "Tuesday 25 August" };
121
+ const ada = PROACTIVE_CHAT_COMPOSERS.morningBrief.compose({
122
+ ...shared,
123
+ recipientDisplayName: "Ada",
124
+ pendingActionItemCount: 1,
125
+ unreadNotificationCount: 3,
126
+ });
127
+ const grace = PROACTIVE_CHAT_COMPOSERS.morningBrief.compose({
128
+ ...shared,
129
+ recipientDisplayName: "Grace",
130
+ pendingActionItemCount: 0,
131
+ unreadNotificationCount: 0,
132
+ });
133
+ expect(ada.title).toBe(grace.title);
134
+ expect(ada.text).toContain("Good morning, Ada.");
135
+ expect(ada.text).toContain("1 decision waiting on you");
136
+ expect(ada.text).toContain("3 unread notifications");
137
+ expect(grace.text).toContain("Good morning, Grace.");
138
+ expect(grace.text).toContain("Nothing is waiting on you");
139
+ expect(grace.text).not.toContain("0 ");
140
+ expect(ada.text).not.toBe(grace.text);
141
+ });
142
+
143
+ it("composers emit content, never markup", () => {
144
+ for (const template of PROACTIVE_CHAT_TEMPLATES) {
145
+ const msg = composeFixture(template);
146
+ for (const value of [msg.title, msg.text]) {
147
+ expect(value, template).not.toMatch(/<[a-z!/]/i);
148
+ expect(value, template).not.toMatch(/https?:\/\//);
149
+ }
150
+ for (const reply of msg.replies) {
151
+ expect(reply.label, template).not.toMatch(/<[a-z!/]/i);
152
+ expect(reply.prompt ?? "", template).not.toMatch(/https?:\/\//);
153
+ }
154
+ }
155
+ });
156
+
157
+ it("every composer stays within the chip budget", () => {
158
+ for (const template of PROACTIVE_CHAT_TEMPLATES) {
159
+ const msg = composeFixture(template);
160
+ expect(msg.replies.length, template).toBeLessThanOrEqual(
161
+ PROACTIVE_CHAT_MAX_REPLIES,
162
+ );
163
+ expect(msg.title.length, template).toBeGreaterThan(0);
164
+ expect(msg.text.length, template).toBeGreaterThan(0);
165
+ }
166
+ });
167
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Negative typecheck fixtures — the compile-error claims the proactive
3
+ * vocabulary makes (ADR-CONTRACTS-142), each pinned with `@ts-expect-error`.
4
+ *
5
+ * The directive is the proof. If a claim ever stops being true — say the plan
6
+ * union in `../plan` loosens `bannerType?: never` to a plain optional — the
7
+ * directive goes UNUSED and `pnpm typecheck` fails with TS2578. A claim that is
8
+ * only stated in a doc comment cannot fail that way.
9
+ *
10
+ * WHY A COMPILED MODULE AND NOT `vocabulary.test.ts`. `src/tsconfig.json`
11
+ * excludes every `*.test.ts` file and vitest does not typecheck, so a directive
12
+ * written inside a test file is never checked at all: an unused one would prove
13
+ * nothing and fail nothing. This file is not a `*.test.ts`, so `tsc -b`
14
+ * compiles it (the same placement as `../../notifications/__tests__/fixtures.ts`),
15
+ * and `vocabulary.test.ts` imports the fixtures to prove the RUNTIME half of
16
+ * each claim — that the class-table check rejects the same plans the compiler
17
+ * rejects.
18
+ *
19
+ * Each directive sits IMMEDIATELY above the line the compiler reports on: a
20
+ * wrongly-typed or excess property is reported at that property, a missing
21
+ * property at the initializer.
22
+ */
23
+
24
+ import type { ProactiveEventKind } from "../kinds";
25
+ import type { ProactiveEventDefinition } from "../plan";
26
+ import { PROACTIVE_EVENT_KINDS } from "../registry";
27
+
28
+ /** The registry's declared shape, so the totality fixtures fit on one line. */
29
+ type ProactiveRegistry = Record<ProactiveEventKind, ProactiveEventDefinition>;
30
+
31
+ // =============================================================================
32
+ // Plans the class table forbids
33
+ // =============================================================================
34
+
35
+ /** CLAIM 1 — a `notice` has no banner, so naming a `bannerType` is an error. */
36
+ const noticeNamingBanner: ProactiveEventDefinition = {
37
+ class: "notice",
38
+ inboxKind: "proactive.org_became_shared",
39
+ // @ts-expect-error a notice has no banner; its `bannerType` is typed `never`
40
+ bannerType: "first_member_joined",
41
+ };
42
+
43
+ /** CLAIM 2 — an `announcement` claims a banner, so omitting one is an error. */
44
+ // @ts-expect-error an announcement MUST name the banner its class earns
45
+ const announcementOmittingBanner: ProactiveEventDefinition = {
46
+ class: "announcement",
47
+ chatTemplate: "orgBecameShared",
48
+ inboxKind: "proactive.org_became_shared",
49
+ };
50
+
51
+ /** CLAIM 3 — `explained` has no banner either; naming one is an error. */
52
+ const explainedNamingBanner: ProactiveEventDefinition = {
53
+ class: "explained",
54
+ chatTemplate: "orgBecameShared",
55
+ inboxKind: "proactive.org_became_shared",
56
+ // @ts-expect-error explained has no banner; its `bannerType` is typed `never`
57
+ bannerType: "first_member_joined",
58
+ };
59
+
60
+ /**
61
+ * CLAIM 3, for the class the second real kind uses — `briefing` shares the
62
+ * `explained` member, so `brief.morning` could not name a banner even if it
63
+ * tried. This is the compile-time proof that adding it needed no machinery.
64
+ */
65
+ const briefingNamingBanner: ProactiveEventDefinition = {
66
+ class: "briefing",
67
+ chatTemplate: "morningBrief",
68
+ inboxKind: "proactive.brief_morning",
69
+ // @ts-expect-error a briefing has no banner; its `bannerType` is typed `never`
70
+ bannerType: "first_member_joined",
71
+ };
72
+
73
+ // =============================================================================
74
+ // Registries that are not total
75
+ // =============================================================================
76
+
77
+ /** CLAIM 4 — a kind with no plan: the registry is not total, so it is an error. */
78
+ // @ts-expect-error a registry missing a kind's plan does not satisfy the Record
79
+ const registryMissingKind = {} as const satisfies ProactiveRegistry;
80
+
81
+ /** CLAIM 4, other direction — a plan for a string that is not a kind. */
82
+ const registryNamingNonKind = {
83
+ ...PROACTIVE_EVENT_KINDS,
84
+ // @ts-expect-error a plan for a non-kind is an excess property
85
+ "made.up": PROACTIVE_EVENT_KINDS["org.became_shared"],
86
+ } as const satisfies ProactiveRegistry;
87
+
88
+ // =============================================================================
89
+ // Exports — the runtime half lives in vocabulary.test.ts
90
+ // =============================================================================
91
+
92
+ /**
93
+ * The plans above, typed as the union they failed to satisfy. At runtime they
94
+ * are ordinary objects, which is what lets the vocabulary test show that the
95
+ * class-table check rejects every one of them.
96
+ */
97
+ export const PLAN_FIXTURES_THE_COMPILER_REJECTS: Readonly<
98
+ Record<string, ProactiveEventDefinition>
99
+ > = {
100
+ noticeNamingBanner,
101
+ announcementOmittingBanner,
102
+ explainedNamingBanner,
103
+ briefingNamingBanner,
104
+ };
105
+
106
+ /** The two ways a registry stops being total. */
107
+ export const REGISTRY_FIXTURES_THE_COMPILER_REJECTS = {
108
+ registryMissingKind,
109
+ registryNamingNonKind,
110
+ } as const;
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The proactive vocabulary's invariants, as tests rather than prose
3
+ * (ADR-CONTRACTS-142).
4
+ *
5
+ * THE load-bearing test is `plan surfaces agree with the class table`. A kind
6
+ * carries ids and its surfaces come from its class, and nothing in the type
7
+ * system can check that the ids PRESENT on a plan match the surfaces its class
8
+ * DECLARES in `CLASS_SURFACES`. The union in `../plan` fixes that per member;
9
+ * this file checks the members against the table itself.
10
+ *
11
+ * The negative typecheck fixtures — the `@ts-expect-error` directives proving
12
+ * the four compile-error claims — live in `./compile-fixtures.ts`, a COMPILED
13
+ * module: `src/tsconfig.json` excludes `*.test.ts`, so a directive written here
14
+ * would never be checked, and an unused one would fail nothing. They are
15
+ * imported below so the runtime check is shown to reject exactly the plans the
16
+ * compiler rejects.
17
+ */
18
+ import { describe, it, expect } from "vitest";
19
+ import {
20
+ CLASS_SURFACES,
21
+ PROACTIVE_PRESENTATION_CLASSES,
22
+ bornRead,
23
+ } from "../classes";
24
+ import type { ClassSurfacePlan, ProactivePresentationClass } from "../classes";
25
+ import type { ProactiveChatMessage } from "../composer";
26
+ import type { ProactiveChatFacts } from "../facts";
27
+ import { PROACTIVE_EVENT_KIND_IDS } from "../kinds";
28
+ import type { ProactiveEventDefinition } from "../plan";
29
+ import { PROACTIVE_EVENT_KINDS } from "../registry";
30
+ import { PROACTIVE_CHAT_TEMPLATES } from "../surfaces";
31
+ import type { ProactiveChatTemplate } from "../surfaces";
32
+ import { PROACTIVE_CHAT_COMPOSERS } from "../templates/index";
33
+ import { USER_NOTIFICATION_KINDS } from "../../user-notifications/kinds";
34
+ import {
35
+ PLAN_FIXTURES_THE_COMPILER_REJECTS,
36
+ REGISTRY_FIXTURES_THE_COMPILER_REJECTS,
37
+ } from "./compile-fixtures";
38
+
39
+ /**
40
+ * The registry, read as the union it satisfies rather than as its literal
41
+ * shape. Every member of `ProactiveEventDefinition` carries every id field
42
+ * (the absent ones as optional `never`), so a plan can be asked for an id it
43
+ * does not have and answer `undefined` — which is what the checks below need.
44
+ */
45
+ const REGISTRY_ENTRIES: ReadonlyArray<
46
+ readonly [kind: string, plan: ProactiveEventDefinition]
47
+ > = Object.entries(PROACTIVE_EVENT_KINDS);
48
+
49
+ /**
50
+ * The ONE check the compiler cannot do, as a predicate: the ids present on a
51
+ * plan agree with the surfaces its class earns, and the inbox id is always
52
+ * there. Applied positively to the real registry and negatively to the plans
53
+ * the compiler rejects, so the two halves are shown to agree.
54
+ */
55
+ function planAgreesWithClassTable(plan: ProactiveEventDefinition): boolean {
56
+ const surfaces: ClassSurfacePlan = CLASS_SURFACES[plan.class];
57
+ return (
58
+ (plan.bannerType !== undefined) === surfaces.banner &&
59
+ (plan.chatTemplate !== undefined) === surfaces.chat &&
60
+ plan.inboxKind !== undefined &&
61
+ surfaces.inbox
62
+ );
63
+ }
64
+
65
+ /**
66
+ * One facts factory per template, total over `ProactiveChatTemplate`. A
67
+ * FACTORY rather than a value so the purity test can hand a composer two
68
+ * structurally-equal but distinct objects — a composer that keyed anything on
69
+ * object identity would show up as a difference.
70
+ */
71
+ const FRESH_FACTS: {
72
+ readonly [T in ProactiveChatTemplate]: () => ProactiveChatFacts[T];
73
+ } = {
74
+ orgBecameShared: () => ({ orgName: "Acme", joinerDisplayName: "Ada" }),
75
+ morningBrief: () => ({
76
+ recipientDisplayName: "Ada",
77
+ orgName: "Acme",
78
+ orgLocalDateLabel: "Tuesday 25 August",
79
+ pendingActionItemCount: 2,
80
+ unreadNotificationCount: 5,
81
+ }),
82
+ };
83
+
84
+ /**
85
+ * Compose a template with fresh facts. The registry is a union of composers
86
+ * whose `compose` parameters differ per template, and TypeScript cannot
87
+ * correlate `PROACTIVE_CHAT_COMPOSERS[t]` with `FRESH_FACTS[t]` through a
88
+ * variable `t` — the cast is the one place the correlation is asserted, and
89
+ * the total factory record above is what keeps it honest.
90
+ */
91
+ function composeFresh(template: ProactiveChatTemplate): ProactiveChatMessage {
92
+ const compose = PROACTIVE_CHAT_COMPOSERS[template].compose as (
93
+ facts: ProactiveChatFacts[ProactiveChatTemplate],
94
+ ) => ProactiveChatMessage;
95
+ return compose(FRESH_FACTS[template]());
96
+ }
97
+
98
+ describe("the class table", () => {
99
+ it("is total over PROACTIVE_PRESENTATION_CLASSES in practice", () => {
100
+ // `satisfies Record<...>` proves totality at compile time; this proves no
101
+ // entry is an accidental `undefined` and that no key is a non-class.
102
+ for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
103
+ expect(CLASS_SURFACES[cls], cls).toBeDefined();
104
+ }
105
+ expect(Object.keys(CLASS_SURFACES).sort()).toEqual(
106
+ [...PROACTIVE_PRESENTATION_CLASSES].sort(),
107
+ );
108
+ });
109
+
110
+ it("every badge names a surface its class actually has", () => {
111
+ for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
112
+ const surfaces: ClassSurfacePlan = CLASS_SURFACES[cls];
113
+ // Every class writes a durable row — `inbox` is literal true throughout.
114
+ expect(surfaces.inbox, `${cls} inbox`).toBe(true);
115
+ // The badge carrier must be a surface the class earns; the union makes
116
+ // the wrong pairing a compile error, and this is the same claim in
117
+ // practice.
118
+ expect(surfaces[surfaces.badge], `${cls} badge=${surfaces.badge}`).toBe(
119
+ true,
120
+ );
121
+ }
122
+ });
123
+
124
+ it("states the born-read rule once, and it matches the doctrine table", () => {
125
+ // The table in `../classes`: the badge carrier is the attention carrier,
126
+ // every other surface the class HAS is the paper trail. Pinned as the
127
+ // concrete rows the doctrine names rather than as a restatement of the
128
+ // helper's body, so a rewrite of `bornRead` that changed a row fails here.
129
+ // A surface the class does not earn has no row to be born into — `chat`
130
+ // is absent for `notice` — so it is not asked.
131
+ const expected: Record<
132
+ ProactivePresentationClass,
133
+ { readonly chat?: boolean; readonly inbox: boolean }
134
+ > = {
135
+ announcement: { chat: true, inbox: true },
136
+ explained: { chat: false, inbox: true },
137
+ briefing: { chat: false, inbox: true },
138
+ notice: { inbox: false },
139
+ };
140
+ for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
141
+ const surfaces: ClassSurfacePlan = CLASS_SURFACES[cls];
142
+ const row = expected[cls];
143
+ expect(surfaces.chat, `${cls} has a chat`).toBe(row.chat !== undefined);
144
+ if (row.chat !== undefined) {
145
+ expect(bornRead(surfaces, "chat"), `${cls} chat`).toBe(row.chat);
146
+ }
147
+ expect(bornRead(surfaces, "inbox"), `${cls} inbox`).toBe(row.inbox);
148
+ }
149
+ });
150
+ });
151
+
152
+ describe("the kind registry", () => {
153
+ it("is total over PROACTIVE_EVENT_KIND_IDS in practice", () => {
154
+ for (const kind of PROACTIVE_EVENT_KIND_IDS) {
155
+ expect(PROACTIVE_EVENT_KINDS[kind], kind).toBeDefined();
156
+ }
157
+ expect(Object.keys(PROACTIVE_EVENT_KINDS).sort()).toEqual(
158
+ [...PROACTIVE_EVENT_KIND_IDS].sort(),
159
+ );
160
+ });
161
+
162
+ /**
163
+ * THE load-bearing test. A kind carries ids; its surfaces come from its
164
+ * class. Nothing in the type system can check that the ids present match the
165
+ * surfaces the class declares, so it is checked here.
166
+ */
167
+ it("plan surfaces agree with the class table", () => {
168
+ for (const [kind, plan] of REGISTRY_ENTRIES) {
169
+ const surfaces: ClassSurfacePlan = CLASS_SURFACES[plan.class];
170
+ expect(plan.bannerType !== undefined, `${kind} bannerType`).toBe(
171
+ surfaces.banner,
172
+ );
173
+ expect(plan.chatTemplate !== undefined, `${kind} chatTemplate`).toBe(
174
+ surfaces.chat,
175
+ );
176
+ expect(plan.inboxKind, `${kind} inboxKind`).toBeDefined();
177
+ expect(planAgreesWithClassTable(plan), kind).toBe(true);
178
+ }
179
+ });
180
+
181
+ it("every inbox kind is a real user notification kind", () => {
182
+ // Every class writes a durable row, so every plan names a row kind — and
183
+ // it must be one the inbox vocabulary actually has, or the backend writes
184
+ // a row no renderer owns.
185
+ for (const [kind, plan] of REGISTRY_ENTRIES) {
186
+ expect(USER_NOTIFICATION_KINDS, kind).toContain(plan.inboxKind);
187
+ }
188
+ });
189
+
190
+ it("names its inbox row in the proactive domain, never by the kind string", () => {
191
+ // Identical names across two unions is how one quietly becomes derived
192
+ // from the other (ADR-CONT-108). The producing domain is `proactive` —
193
+ // `src/proactive/` in the backend owns the renderer — matching the
194
+ // `companyMd.*` / `comment.*` convention.
195
+ for (const [kind, plan] of REGISTRY_ENTRIES) {
196
+ expect(plan.inboxKind, kind).not.toBe(kind);
197
+ expect(plan.inboxKind, kind).toMatch(/^proactive\./);
198
+ }
199
+ });
200
+
201
+ it("names kinds in {domain}.{type} form, matching the three unions", () => {
202
+ for (const kind of PROACTIVE_EVENT_KIND_IDS) {
203
+ expect(kind).toMatch(/^[a-z][a-zA-Z0-9]*\.[a-z][a-z0-9_]*$/);
204
+ }
205
+ });
206
+
207
+ it("every chat template has a composer", () => {
208
+ // The composer registry is total over PROACTIVE_CHAT_TEMPLATES at compile
209
+ // time; this proves the id a KIND names reaches a real composer whose
210
+ // `template` is that id, so the backend cannot write an empty message.
211
+ for (const [kind, plan] of REGISTRY_ENTRIES) {
212
+ if (plan.chatTemplate === undefined) continue;
213
+ const composer = PROACTIVE_CHAT_COMPOSERS[plan.chatTemplate];
214
+ expect(composer, kind).toBeDefined();
215
+ expect(composer.template, kind).toBe(plan.chatTemplate);
216
+ }
217
+ });
218
+
219
+ it("sets no emailKind in this wave", () => {
220
+ // notify/'s INV-1: every notification needs an explicit trigger; nothing
221
+ // sends autonomously. A kind that also emailed needs INV-PROACTIVE-TRIGGER
222
+ // reconciled with INV-1 in an ADR — lifting this assertion IS that ADR's
223
+ // job, not a tidy-up.
224
+ for (const [kind, plan] of REGISTRY_ENTRIES) {
225
+ expect(plan.emailKind, kind).toBeUndefined();
226
+ }
227
+ });
228
+ });
229
+
230
+ describe("the chat composers", () => {
231
+ it("composers are pure", () => {
232
+ // Same facts in, byte-identical message out — across two calls handed two
233
+ // distinct-but-equal facts objects. A clock, an env read, a counter or an
234
+ // identity-keyed cache would all surface as a difference here.
235
+ for (const template of PROACTIVE_CHAT_TEMPLATES) {
236
+ const first = composeFresh(template);
237
+ const second = composeFresh(template);
238
+ expect(JSON.stringify(first), template).toBe(JSON.stringify(second));
239
+ expect(first, template).toEqual(second);
240
+ }
241
+ });
242
+ });
243
+
244
+ describe("what the compiler rejects, the class-table check rejects too", () => {
245
+ it("rejects inconsistent plans at compile time", () => {
246
+ // The `@ts-expect-error` directives sit in ./compile-fixtures.ts, where
247
+ // `tsc -b` checks them. Here the same plans go through the runtime check:
248
+ // a notice naming a bannerType, an announcement omitting one, and an
249
+ // explained or a briefing naming one must all disagree with the class
250
+ // table.
251
+ const names = Object.keys(PLAN_FIXTURES_THE_COMPILER_REJECTS);
252
+ expect(names).toEqual([
253
+ "noticeNamingBanner",
254
+ "announcementOmittingBanner",
255
+ "explainedNamingBanner",
256
+ "briefingNamingBanner",
257
+ ]);
258
+ for (const [name, plan] of Object.entries(
259
+ PLAN_FIXTURES_THE_COMPILER_REJECTS,
260
+ )) {
261
+ expect(planAgreesWithClassTable(plan), name).toBe(false);
262
+ }
263
+ });
264
+
265
+ it("rejects a registry that is not total at compile time", () => {
266
+ // The fourth claim, both directions: a kind absent from the registry, and
267
+ // a plan for a string that is not a kind. The real registry passes the
268
+ // same two checks in `is total over PROACTIVE_EVENT_KIND_IDS in practice`.
269
+ const { registryMissingKind, registryNamingNonKind } =
270
+ REGISTRY_FIXTURES_THE_COMPILER_REJECTS;
271
+ const ids = new Set<string>(PROACTIVE_EVENT_KIND_IDS);
272
+ expect(
273
+ PROACTIVE_EVENT_KIND_IDS.some((kind) => !(kind in registryMissingKind)),
274
+ ).toBe(true);
275
+ expect(
276
+ Object.keys(registryNamingNonKind).some((key) => !ids.has(key)),
277
+ ).toBe(true);
278
+ });
279
+ });