@milehimikey/em 1.22.0 → 1.22.1

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 (62) hide show
  1. package/.claude/skills/event-modeling/SKILL.md +1 -1
  2. package/.claude/skills/event-modeling-conform/SKILL.md +1 -1
  3. package/.claude/skills/event-modeling-design/SKILL.md +5 -2
  4. package/.claude/skills/event-modeling-discover/SKILL.md +1 -1
  5. package/.claude/skills/event-modeling-engagement/SKILL.md +1 -1
  6. package/.claude/skills/event-modeling-implement/SKILL.md +1 -1
  7. package/.claude/skills/event-modeling-review/SKILL.md +1 -1
  8. package/.claude/skills/event-modeling-shared/reference/em-dsl.md +36 -0
  9. package/.claude-plugin/marketplace.json +2 -2
  10. package/dist/cli/api.d.ts +60 -17
  11. package/dist/cli/api.js +154 -34
  12. package/dist/cli/api.js.map +1 -1
  13. package/dist/cli/scopeInputs.js +2 -2
  14. package/dist/cli/scopeInputs.js.map +1 -1
  15. package/dist/emit/api.d.ts +18 -0
  16. package/dist/emit/api.js +117 -3
  17. package/dist/emit/api.js.map +1 -1
  18. package/dist/emit/apiSurfaceJson.d.ts +1 -1
  19. package/dist/emit/apiSurfaceJson.js +18 -1
  20. package/dist/emit/apiSurfaceJson.js.map +1 -1
  21. package/dist/emit/diffJson.d.ts +1 -1
  22. package/dist/emit/diffJson.js +20 -1
  23. package/dist/emit/diffJson.js.map +1 -1
  24. package/dist/emit/json.d.ts +42 -1
  25. package/dist/emit/json.js +65 -6
  26. package/dist/emit/json.js.map +1 -1
  27. package/dist/mcp/server.js +7 -6
  28. package/dist/mcp/server.js.map +1 -1
  29. package/dist/model/diff.d.ts +19 -1
  30. package/dist/model/diff.js +99 -0
  31. package/dist/model/diff.js.map +1 -1
  32. package/dist/model/model.d.ts +88 -4
  33. package/dist/model/model.js +98 -3
  34. package/dist/model/model.js.map +1 -1
  35. package/dist/model/refs.d.ts +4 -0
  36. package/dist/model/refs.js +35 -1
  37. package/dist/model/refs.js.map +1 -1
  38. package/dist/model/rules.d.ts +61 -0
  39. package/dist/model/rules.js +72 -0
  40. package/dist/model/rules.js.map +1 -1
  41. package/dist/model/validate.js +137 -4
  42. package/dist/model/validate.js.map +1 -1
  43. package/dist/parser/ast.d.ts +30 -0
  44. package/dist/parser/parser.js +67 -1
  45. package/dist/parser/parser.js.map +1 -1
  46. package/dist/render/sliceDiagram.js +6 -0
  47. package/dist/render/sliceDiagram.js.map +1 -1
  48. package/dist/render/storylineDiagram.js +6 -0
  49. package/dist/render/storylineDiagram.js.map +1 -1
  50. package/dist/system/adaptation.d.ts +5 -2
  51. package/dist/system/adaptation.js +5 -3
  52. package/dist/system/adaptation.js.map +1 -1
  53. package/package.json +2 -1
  54. package/plugin/.claude-plugin/plugin.json +2 -2
  55. package/plugin/shared/reference/em-dsl.md +36 -0
  56. package/plugin/skills/conform/SKILL.md +1 -1
  57. package/plugin/skills/design/SKILL.md +5 -2
  58. package/plugin/skills/discover/SKILL.md +1 -1
  59. package/plugin/skills/engagement/SKILL.md +1 -1
  60. package/plugin/skills/event-modeling/SKILL.md +1 -1
  61. package/plugin/skills/implement/SKILL.md +1 -1
  62. package/plugin/skills/review/SKILL.md +1 -1
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: event-modeling
3
- em-version: 1.22.0
3
+ em-version: 1.22.1
4
4
  description: >-
5
5
  Use when the user runs `/event-modeling` (with or without a phase name), or wants to resume an
6
6
  event-modeling session without specifying which part of the process applies. Reads the model's
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: event-modeling-conform
3
- em-version: 1.22.0
3
+ em-version: 1.22.1
4
4
  description: >-
5
5
  Use when checking a ratified event model (and its slice docs) for drift against the codebase
6
6
  that implements it — advisory only, never a gate, never an unprompted edit — or when walking
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: event-modeling-design
3
- em-version: 1.22.0
3
+ em-version: 1.22.1
4
4
  description: >-
5
5
  Use when structuring a draft event model into swimlanes and the four patterns (State Change,
6
6
  State View, Automation, Translation), evaluating a model's structural completeness, or writing
@@ -234,7 +234,10 @@ For each slice:
234
234
  1. Hold a Socratic deep-dive to fill every section of `../event-modeling-shared/templates/slice.md`:
235
235
  intent, trigger/actor,
236
236
  command fields (types & rules), event(s) + payload (mark immutable facts) — field names and
237
- types go into the `.em`, the doc's tables are generated from it — invariants
237
+ types go into the `.em`, the doc's tables are generated from it (on a `public` element, a
238
+ field's type must be the fixed scalar table, a declared `type`, a declared identity `scalar`,
239
+ or a declared `enum` — see `em-dsl.md`'s Strict public types/Identity scalars/Enums
240
+ sections) — invariants
238
241
  (give each a stable ID — see **Invariants live in the model** below), Given/When/Then scenarios (happy path + rule boundaries + edge cases),
239
242
  alternate/error flows (retries, idempotency, compensations), non-functional requirements
240
243
  (security/authz, PII/compliance, performance/SLA), read models affected, open questions. Park
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: event-modeling-discover
3
- em-version: 1.22.0
3
+ em-version: 1.22.1
4
4
  description: >-
5
5
  Use when starting a brand-new event model from scratch — greenfield discovery: brainstorming
6
6
  past-tense events, storyboarding the happy path, finding the commands and read models — or when
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: event-modeling-engagement
3
- em-version: 1.22.0
3
+ em-version: 1.22.1
4
4
  description: >-
5
5
  Use when leading an engagement: building a named set of ratified event-modeling slices
6
6
  (`em engagement`) into one independent PR per slice, each against main, by dispatching the
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: event-modeling-implement
3
- em-version: 1.22.0
3
+ em-version: 1.22.1
4
4
  description: >-
5
5
  Use when building a single ratified event-modeling slice (status: ready-to-implement) into
6
6
  merged, tested code — the one phase where the agent builds rather than facilitates. Gates on
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: event-modeling-review
3
- em-version: 1.22.0
3
+ em-version: 1.22.1
4
4
  description: >-
5
5
  Use when starting em's live browser viewer (`em watch --serve`) so a team can watch a model
6
6
  update in real time, or facilitating a SCHEDULED stakeholder walkthrough that steps through
@@ -352,6 +352,8 @@ slice "Name" [source "url"] { # one vertical time step (a column); source is
352
352
  arrow From Element -> To Element # explicit cross-slice edge (overrides inferred flow)
353
353
 
354
354
  type Name { field: Type, ... } # named structured type, reusable from any field (see Named types)
355
+ scalar Name: base # identity alias over a public scalar table entry (see Named types)
356
+ enum Name { member, ... } # closed named value set, reusable from any field (see Named types)
355
357
  ```
356
358
 
357
359
  ### Element kinds (8 keywords, nothing else)
@@ -620,6 +622,31 @@ how tree/recursive data (categories, org charts, comment threads, BOMs) gets exp
620
622
  fields and ordinary element fields alike) — `{ name, ref, array }` when resolved, `null`
621
623
  otherwise. `em diff` tracks types added/removed and field changes on surviving types.
622
624
 
625
+ **Identity scalars** (GH #477): `scalar Name: base` declares an identity alias over one public
626
+ scalar table entry (`base`, case-insensitive) — so a `public` field can carry a domain name on
627
+ the wire (`orderId: OrderId`) instead of degrading to the bare table type (`uuid`) to satisfy
628
+ [Strict public types](#strict-public-types). Reference it bare or `Name[]`, same as a declared
629
+ `type`; on a `public` element it satisfies the strict table directly, on an internal element it
630
+ resolves opportunistically. `em validate` errors on an unknown `base`, a redeclared name, or a
631
+ name colliding with a declared `type` (either direction). `em api generate` declares a used
632
+ scalar once, after the table scalars, extending the formatted table scalar
633
+ (`scalar OrderId extends Uuid;`) or the core type (`scalar Sku extends string;`) — a model with
634
+ no `scalar` emits unchanged. `em export` adds `model.scalars[]` and an additive `scalarRef` field
635
+ key, mutually exclusive with `typeRef`.
636
+
637
+ **Enums** (GH #478): `enum Name { member, … }` declares a closed, named value set — members are
638
+ bare identifiers or quoted strings, in declaration order. Reference it bare or `Name[]`, same
639
+ as a declared `type`/`scalar`; on a `public` element it satisfies the strict table directly, on
640
+ an internal element it resolves opportunistically. `em validate` errors on an empty enum, a
641
+ case-insensitive duplicate member, a redeclared name, or a name colliding with a declared
642
+ `type`/`scalar` (either direction). `em api generate` declares a used enum once, members in
643
+ declaration order (`enum OrderStatus { placed, shipped }`), after the scalar declarations and
644
+ before type blocks — a member that isn't a bare TypeSpec identifier emits as a quoted string
645
+ literal; a model with no `enum` emits unchanged. `em export` adds `model.enums[]` and an
646
+ additive `enumRef` field key, mutually exclusive with both `typeRef` and `scalarRef`. `em api
647
+ check --base` judges a surviving enum's member changes by role: removed/renamed is breaking on
648
+ an output, added is breaking on an input, additive on a pure output.
649
+
623
650
  ### Swimlane band order (top → bottom)
624
651
  Header row → **Automation** (only if used) → **persona** rows (in declared order) → **API**
625
652
  (commands + views share this lane) → **context** rows (in declared order).
@@ -950,11 +977,17 @@ not the prose above has caught up yet. `--slice-ready <key>`-only codes are excl
950
977
  | `doc-names-unfed-event` | warning | Doc names an event its view does not read | Add the event to the view's `from` in the model (if the edge is real), or fix the doc's Source column / scenario. |
951
978
  | `doc-omits-fed-event` | warning | Doc omits an event its view reads | Mention the event in the view's Source column (run `em slice sync`), or drop it from the view's `from`. |
952
979
  | `duplicate-element-ref` | warning | Duplicate element ref | Rename the element so its export ref is unique. |
980
+ | `duplicate-enum-ref` | warning | Duplicate enum ref | Rename the enum so its export ref is unique. |
953
981
  | `duplicate-model-key` | warning | Duplicate model key | Give each model a unique `model "Name"` so its key (and every cross-model ref) is stable. |
954
982
  | `duplicate-name` | warning | Duplicate name | Rename one of the duplicates. |
983
+ | `duplicate-scalar-ref` | warning | Duplicate scalar ref | Rename the scalar so its export ref is unique. |
955
984
  | `duplicate-slice-name` | warning | Duplicate slice name | Rename the slice so its export key is unique. |
956
985
  | `duplicate-type-name` | warning | Duplicate type name | Rename one of the duplicate `type` declarations. |
957
986
  | `duplicate-type-ref` | warning | Duplicate type ref | Rename the type so its export ref is unique. |
987
+ | `enum-duplicate-member` | error | Enum duplicate member | Remove or rename the duplicate member — members are compared case-insensitively. |
988
+ | `enum-duplicate-name` | error | Enum duplicate name | Declare the enum once; rename or remove the duplicate `enum` block. |
989
+ | `enum-empty` | error | Empty enum | Give the enum at least one member, or remove the declaration. |
990
+ | `enum-name-collision` | error | Enum/type/scalar name collision | Rename the `enum` or the colliding `type`/`scalar` so the names no longer collide — a name can't mean both at once. |
958
991
  | `fields-completeness/event-field-no-source` | warning | Event field not from a command | Add the field to a command that records the event (in any slice), mark it assigned if the handler sets it, or remove it from the event. |
959
992
  | `fields-completeness/view-field-no-source` | warning | View field with no source | Add the field to a source event, or remove it from the view. |
960
993
  | `frontmatter-coherence-implemented-without-link` | warning | Implemented without a link | Add `implementedIn` once the slice ships. |
@@ -987,6 +1020,9 @@ not the prose above has caught up yet. `--slice-ready <key>`-only codes are excl
987
1020
  | `reaction-from-future-view` | error | Backward timeline (reaction reads a future view) | Declare the view in or before the reaction's slice. |
988
1021
  | `reaction-from-unresolved` | error | Unknown read-model source | Project the event into a view first, or fix the `from` reference. |
989
1022
  | `retry-slice-of` | warning | Slice only retries another command | Re-fire the same reaction via `view … again from` the retry-scheduled event; a different actor's override is its own slice with its own command (docs/patterns.md#automation). |
1023
+ | `scalar-duplicate-name` | error | Scalar duplicate name | Declare the scalar once; rename or remove the duplicate `scalar` line. |
1024
+ | `scalar-type-name-collision` | error | Scalar/type name collision | Rename the `scalar` or the `type` so the two names no longer collide — a name can't mean both at once. |
1025
+ | `scalar-unknown-base` | error | Scalar unknown base | Point `base` at one of the public scalar table's names (string, text, int, long, decimal, boolean, uuid, date, datetime, duration, bytes), case-insensitive. |
990
1026
  | `scenario-then-not-outcome` | warning | Scenario's Then names no event or rejection | Make the Then bullets name the event(s) the command records, or the rejection/error it returns. |
991
1027
  | `scenario-when-not-command` | warning | Scenario's When does not name the slice's command | Make the When bullet name the slice's command (for a reaction slice: the command, the reaction element, or a view of the slice) — each scenario exercises exactly one command. For a Translation, naming the translation element satisfies it. |
992
1028
  | `seam-consumer-not-reaction` | error | Seam consumer is not a reaction | Point `to` at a translation/automation element (or a slice containing exactly one). |
@@ -1,9 +1,9 @@
1
1
  {
2
- "name": "em-1-22-0",
2
+ "name": "em-1-22-1",
3
3
  "owner": {
4
4
  "name": "Mike Key"
5
5
  },
6
- "description": "em v1.22.0: the Event Modeling skill bundle and MCP server as a Claude Code plugin.",
6
+ "description": "em v1.22.1: the Event Modeling skill bundle and MCP server as a Claude Code plugin.",
7
7
  "plugins": [
8
8
  {
9
9
  "name": "em",
package/dist/cli/api.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { NormalizedModel } from "../model/model.js";
2
2
  import { Diagnostic } from "../model/validate.js";
3
3
  import { compile } from "../pipeline.js";
4
- import { ElementExport, FieldExport, TypeExport } from "../emit/json.js";
4
+ import { ElementExport, EnumExport, FieldExport, TypeExport } from "../emit/json.js";
5
5
  import { RefsResult } from "../model/refs.js";
6
6
  import { GitRunner } from "./diff-inputs.js";
7
7
  /** `<modelDir>/contracts/<modelKey>.tsp` (R6), built from the model path as given. */
@@ -30,7 +30,7 @@ export interface ApiCheckChange {
30
30
  }
31
31
  type Role = "input" | "output";
32
32
  interface SurfaceEntry {
33
- /** command | event | view | type */
33
+ /** command | event | view | type | enum */
34
34
  kind: string;
35
35
  name: string;
36
36
  /** Normalized identity key: kind + normalizeName(name). */
@@ -38,11 +38,13 @@ interface SurfaceEntry {
38
38
  fields: FieldExport[];
39
39
  renamedFrom: string[];
40
40
  roles: Set<Role>;
41
- /** Model-declared invariants (MIL-265), declaration order; `[]` for a type. */
41
+ /** Model-declared invariants (MIL-265), declaration order; `[]` for a type/enum. */
42
42
  invariants: {
43
43
  id: string;
44
44
  rule: string | null;
45
45
  }[];
46
+ /** GH #478: an enum's own members, in declaration order — `[]` for every non-enum kind. */
47
+ members: string[];
46
48
  }
47
49
  interface Surface {
48
50
  entries: SurfaceEntry[];
@@ -51,14 +53,29 @@ interface Surface {
51
53
  allElements: Set<string>;
52
54
  }
53
55
  /** Build the public surface of one compiled model: public commands (input), events and views
54
- * (output), and every declared type reachable from them (carrying the roles that reach it). */
56
+ * (output), and every declared type/enum reachable from them (carrying the roles that reach
57
+ * it — GH #478 extends the existing type-reach walk to also track an `enumRef` field,
58
+ * directly or through a reachable type's own fields, the same way `reachableEnumRefs`
59
+ * (emit/api.ts) tracks it for `em api generate`). `enums` defaults to `[]` so every existing
60
+ * caller (and the one test helper that built a `Surface` before enums existed) keeps compiling
61
+ * without a required third argument at a call site that never has one. */
55
62
  export declare function surfaceOf(slices: {
56
63
  elements: ElementExport[];
57
- }[], types: TypeExport[]): Surface;
58
- /** A field's type as the contract sees it: a declared type's name or the raw string,
59
- * normalized, with its array arity. */
60
- export declare function typeLabel(f: Pick<FieldExport, "type" | "typeRef">): string;
61
- export declare function typeIdentity(f: Pick<FieldExport, "type" | "typeRef">): string;
64
+ }[], types: TypeExport[], enums?: EnumExport[]): Surface;
65
+ /** A field's type as the contract sees it: a declared type's, identity scalar's or enum's
66
+ * name, or the raw string, normalized, with its array arity. */
67
+ export declare function typeLabel(f: Pick<FieldExport, "type" | "typeRef" | "scalarRef" | "enumRef">): string;
68
+ /**
69
+ * GH #477: a `scalarRef` folds its `base` into the identity string too, not just `ref`/`array` —
70
+ * so a surviving scalar DECLARATION's base changing (`OrderId: uuid` -> `OrderId: string`)
71
+ * registers as a type change on every field that uses it, even though the scalar's own ref and
72
+ * the field's array arity are unchanged. This reuses `diffFields`' existing per-field
73
+ * `compare()` rather than adding a second comparison path — see its `scalarRef`-aware branch
74
+ * below for the breaking-change message this produces. GH #478: `enumRef` needs no equivalent
75
+ * fold — an enum's MEMBER changes are diffed on the enum's own `SurfaceEntry` (`diffEnumMembers`
76
+ * below), not per using field, so `ref`/`array` alone identify a field's enum reference here.
77
+ */
78
+ export declare function typeIdentity(f: Pick<FieldExport, "type" | "typeRef" | "scalarRef" | "enumRef">): string;
62
79
  /** Diff two public surfaces. `base === null` means the model did not exist at the base
63
80
  * revision: every HEAD element is an additive addition. */
64
81
  export declare function diffSurfaces(base: Surface | null, head: Surface): ApiCheckChange[];
@@ -96,13 +113,21 @@ export type ApiCheckOutcome = {
96
113
  export declare function runApiCheck(file: string, base: string | undefined, runGit?: GitRunner): ApiCheckOutcome;
97
114
  /** The text report: one status line, then one `additive:`/`breaking:` line per change. */
98
115
  export declare function formatApiCheckText(r: ApiCheckReport): string[];
99
- /** A public field's resolved type (R7's fixed table or a declared type), the same shape
100
- * `resolvePublicType` (model/model.ts) computes — scalar carries the matched table entry's
101
- * key and the TypeSpec core scalar `em api generate` emits for it; declared carries the
102
- * reachable type's own name (see `ApiSurfaceType` below for its full field shape). */
116
+ /** A public field's resolved type (R7's fixed table, a declared identity scalar, a declared
117
+ * enum, or a declared type), the same shape `resolvePublicType` (model/model.ts) computes —
118
+ * `scalar` carries the matched table entry's key and the TypeSpec core scalar `em api
119
+ * generate` emits for it; `declared` carries the reachable type's own name (see
120
+ * `ApiSurfaceType` below for its full field shape); `scalar-alias` (GH #477, schema 1.1)
121
+ * carries the declared identity scalar's own name in `tsType` (what actually appears in the
122
+ * generated contract, same convention `declared` already uses) and the table key it extends
123
+ * in `base` — `scalar`/`declared` leave `base` `null`; `enum` (GH #478, schema 1.2) carries
124
+ * the declared enum's own name in `tsType`, same convention — find its members under the
125
+ * report's own `enums` list. */
103
126
  export interface ApiSurfaceFieldType {
104
- kind: "scalar" | "declared";
127
+ kind: "scalar" | "declared" | "scalar-alias" | "enum";
105
128
  scalar: string | null;
129
+ /** The `PUBLIC_SCALAR_TYPES` key a `scalar-alias` extends; `null` for every other kind. */
130
+ base: string | null;
106
131
  tsType: string;
107
132
  array: boolean;
108
133
  }
@@ -133,6 +158,20 @@ export interface ApiSurfaceType {
133
158
  slug: string;
134
159
  fields: ApiSurfaceField[];
135
160
  }
161
+ /** One declared `scalar Name: base` identity alias reachable from a public command/event/view's
162
+ * fields (GH #477, schema 1.1) — `base` is the `PUBLIC_SCALAR_TYPES` key it extends. */
163
+ export interface ApiSurfaceScalar {
164
+ name: string;
165
+ slug: string;
166
+ base: string;
167
+ }
168
+ /** One declared `enum Name { member, … }` reachable from a public command/event/view's fields
169
+ * (GH #478, schema 1.2) — `members` in declaration order, raw (unquoted). */
170
+ export interface ApiSurfaceEnum {
171
+ name: string;
172
+ slug: string;
173
+ members: string[];
174
+ }
136
175
  export interface ApiSurfaceReport {
137
176
  file: string;
138
177
  modelKey: string;
@@ -140,6 +179,8 @@ export interface ApiSurfaceReport {
140
179
  events: ApiSurfaceElement[];
141
180
  views: ApiSurfaceElement[];
142
181
  types: ApiSurfaceType[];
182
+ scalars: ApiSurfaceScalar[];
183
+ enums: ApiSurfaceEnum[];
143
184
  }
144
185
  export type ApiSurfaceOutcome = {
145
186
  ok: true;
@@ -154,9 +195,11 @@ export type ApiSurfaceOutcome = {
154
195
  * Build the `em api surface` report for an already-validated (error-free) model: the `public`
155
196
  * commands/events/views (`publicSurfaceOf`, same as `em api generate`/`em api check`) plus every
156
197
  * declared type reachable from their fields (`reachableTypeRefs`, same walk `em api generate`'s
157
- * contract body uses), each field's type resolved through `resolvePublicType` — the one notion
158
- * of the public surface every em surface shares, so a companion tool never re-derives it from
159
- * `em export --json` and risks drifting from it.
198
+ * contract body uses), every declared identity scalar reachable the same way
199
+ * (`reachableScalarRefs`, GH #477), and every declared enum reachable the same way
200
+ * (`reachableEnumRefs`, GH #478), each field's type resolved through `resolvePublicType` — the
201
+ * one notion of the public surface every em surface shares, so a companion tool never re-derives
202
+ * it from `em export --json` and risks drifting from it.
160
203
  */
161
204
  export declare function buildApiSurfaceReport(model: NormalizedModel, refs: RefsResult, diagnostics: Diagnostic[], source: string, file: string): ApiSurfaceReport;
162
205
  /**
package/dist/cli/api.js CHANGED
@@ -14,7 +14,7 @@ import { hasErrors } from "../model/validate.js";
14
14
  import { compile, compileModelFile } from "../pipeline.js";
15
15
  import { ModelLoadError } from "../model/loadModel.js";
16
16
  import { ParseError } from "../parser/parser.js";
17
- import { buildApiContract, publicSurfaceOf, reachableTypeRefs } from "../emit/api.js";
17
+ import { buildApiContract, publicSurfaceOf, reachableEnumRefs, reachableScalarRefs, reachableTypeRefs } from "../emit/api.js";
18
18
  import { buildExportDoc } from "../emit/json.js";
19
19
  import { kebabSlug } from "../util/slug.js";
20
20
  import { realGit, resolveRevision } from "./diff-inputs.js";
@@ -36,14 +36,25 @@ export function generateContract(file, source, compiled, outPath) {
36
36
  }
37
37
  const keyOf = (kind, name) => `${kind}:${normalizeName(name)}`;
38
38
  /** Build the public surface of one compiled model: public commands (input), events and views
39
- * (output), and every declared type reachable from them (carrying the roles that reach it). */
40
- export function surfaceOf(slices, types) {
39
+ * (output), and every declared type/enum reachable from them (carrying the roles that reach
40
+ * it — GH #478 extends the existing type-reach walk to also track an `enumRef` field,
41
+ * directly or through a reachable type's own fields, the same way `reachableEnumRefs`
42
+ * (emit/api.ts) tracks it for `em api generate`). `enums` defaults to `[]` so every existing
43
+ * caller (and the one test helper that built a `Surface` before enums existed) keeps compiling
44
+ * without a required third argument at a call site that never has one. */
45
+ export function surfaceOf(slices, types, enums = []) {
41
46
  const s = publicSurfaceOf(slices);
42
47
  const entries = [];
43
48
  const typesByRef = new Map(types.map((t) => [t.ref, t]));
44
49
  const typeRoles = new Map();
50
+ const enumRoles = new Map();
45
51
  const reach = (fields, role) => {
46
52
  for (const f of fields ?? []) {
53
+ if (f.enumRef) {
54
+ const roles = enumRoles.get(f.enumRef.ref) ?? new Set();
55
+ roles.add(role);
56
+ enumRoles.set(f.enumRef.ref, roles);
57
+ }
47
58
  if (!f.typeRef)
48
59
  continue;
49
60
  const roles = typeRoles.get(f.typeRef.ref) ?? new Set();
@@ -63,6 +74,7 @@ export function surfaceOf(slices, types) {
63
74
  renamedFrom: el.renamedFrom ?? [],
64
75
  roles: new Set([role]),
65
76
  invariants: el.invariants ?? [],
77
+ members: [],
66
78
  });
67
79
  reach(el.fields, role);
68
80
  };
@@ -76,7 +88,13 @@ export function surfaceOf(slices, types) {
76
88
  const roles = typeRoles.get(t.ref);
77
89
  if (!roles)
78
90
  continue;
79
- entries.push({ kind: "type", name: t.name, key: keyOf("type", t.name), fields: t.fields, renamedFrom: [], roles, invariants: [] });
91
+ entries.push({ kind: "type", name: t.name, key: keyOf("type", t.name), fields: t.fields, renamedFrom: [], roles, invariants: [], members: [] });
92
+ }
93
+ for (const e of enums) {
94
+ const roles = enumRoles.get(e.ref);
95
+ if (!roles)
96
+ continue;
97
+ entries.push({ kind: "enum", name: e.name, key: keyOf("enum", e.name), fields: [], renamedFrom: [], roles, invariants: [], members: e.members });
80
98
  }
81
99
  const allElements = new Set();
82
100
  for (const slice of slices)
@@ -84,18 +102,38 @@ export function surfaceOf(slices, types) {
84
102
  allElements.add(keyOf(el.kind, el.name));
85
103
  for (const t of types)
86
104
  allElements.add(keyOf("type", t.name));
105
+ for (const e of enums)
106
+ allElements.add(keyOf("enum", e.name));
87
107
  return { entries, allElements };
88
108
  }
89
- /** A field's type as the contract sees it: a declared type's name or the raw string,
90
- * normalized, with its array arity. */
109
+ /** A field's type as the contract sees it: a declared type's, identity scalar's or enum's
110
+ * name, or the raw string, normalized, with its array arity. */
91
111
  export function typeLabel(f) {
92
112
  if (f.typeRef)
93
113
  return `${f.typeRef.name}${f.typeRef.array ? "[]" : ""}`;
114
+ if (f.scalarRef)
115
+ return `${f.scalarRef.name}${f.scalarRef.array ? "[]" : ""}`;
116
+ if (f.enumRef)
117
+ return `${f.enumRef.name}${f.enumRef.array ? "[]" : ""}`;
94
118
  return f.type ?? "(none)";
95
119
  }
120
+ /**
121
+ * GH #477: a `scalarRef` folds its `base` into the identity string too, not just `ref`/`array` —
122
+ * so a surviving scalar DECLARATION's base changing (`OrderId: uuid` -> `OrderId: string`)
123
+ * registers as a type change on every field that uses it, even though the scalar's own ref and
124
+ * the field's array arity are unchanged. This reuses `diffFields`' existing per-field
125
+ * `compare()` rather than adding a second comparison path — see its `scalarRef`-aware branch
126
+ * below for the breaking-change message this produces. GH #478: `enumRef` needs no equivalent
127
+ * fold — an enum's MEMBER changes are diffed on the enum's own `SurfaceEntry` (`diffEnumMembers`
128
+ * below), not per using field, so `ref`/`array` alone identify a field's enum reference here.
129
+ */
96
130
  export function typeIdentity(f) {
97
131
  if (f.typeRef)
98
132
  return `ref:${f.typeRef.ref}:${f.typeRef.array}`;
133
+ if (f.scalarRef)
134
+ return `ref:${f.scalarRef.ref}:${f.scalarRef.array}:${f.scalarRef.base}`;
135
+ if (f.enumRef)
136
+ return `enum-ref:${f.enumRef.ref}:${f.enumRef.array}`;
99
137
  return `raw:${(f.type ?? "").replace(/\s+/g, "").toLowerCase()}`;
100
138
  }
101
139
  function diffFields(base, head, element, out) {
@@ -120,7 +158,25 @@ function diffFields(base, head, element, out) {
120
158
  }
121
159
  const compare = (b, h) => {
122
160
  if (typeIdentity(b) !== typeIdentity(h)) {
123
- out.push({ kind: "breaking", element, field: h.name, what: `type changed ${typeLabel(b)} → ${typeLabel(h)}` });
161
+ // GH #477: same scalar declaration, same array arity, different `base` — the field's own
162
+ // reference didn't change, but what it resolves to on the wire did (every consumer
163
+ // deserializing this field as the old base now gets the new one). Phrased distinctly from
164
+ // an ordinary type change so the report doesn't read as "OrderId -> OrderId" (no visible
165
+ // difference) when the real story is the scalar's declaration.
166
+ if (b.scalarRef && h.scalarRef &&
167
+ b.scalarRef.ref === h.scalarRef.ref &&
168
+ b.scalarRef.array === h.scalarRef.array &&
169
+ b.scalarRef.base !== h.scalarRef.base) {
170
+ out.push({
171
+ kind: "breaking",
172
+ element,
173
+ field: h.name,
174
+ what: `scalar "${h.scalarRef.name}" base changed ${b.scalarRef.base} → ${h.scalarRef.base}`,
175
+ });
176
+ }
177
+ else {
178
+ out.push({ kind: "breaking", element, field: h.name, what: `type changed ${typeLabel(b)} → ${typeLabel(h)}` });
179
+ }
124
180
  }
125
181
  if (b.optional !== h.optional) {
126
182
  if (h.optional) {
@@ -160,6 +216,37 @@ function diffFields(base, head, element, out) {
160
216
  out.push({ kind, element, field: h.name, what: `added (${required ? "required" : "optional"})` });
161
217
  }
162
218
  }
219
+ /**
220
+ * GH #478: an enum's own member set, base vs HEAD, diffed by role (the same `isInput`/
221
+ * `isOutput` union `diffFields` computes above) — the issue's pinned severity matrix:
222
+ * - member REMOVED (or, since there's no rename clause, the removal half of what reads as a
223
+ * rename) is breaking wherever the enum reaches an OUTPUT (an existing receiver may still
224
+ * expect that known value) — not conditioned on input reach at all.
225
+ * - member ADDED is breaking wherever the enum reaches an INPUT (existing senders are
226
+ * unaffected; a strict receiver built against the old member set rejects the new value) —
227
+ * additive on a PURE output (`isInput` false): a receiver tolerates an unknown value.
228
+ * A rename is never detected as such (same "no rename detection" posture `em diff` already
229
+ * takes for a `type`/element rename, model/diff.ts) — it reads as remove+add, each half scored
230
+ * independently by the rules above. Base order first (removals), then HEAD-only (additions),
231
+ * same convention `diffFields` uses.
232
+ */
233
+ function diffEnumMembers(base, head, element, out) {
234
+ const roles = new Set([...base.roles, ...head.roles]);
235
+ const isInput = roles.has("input");
236
+ const isOutput = roles.has("output");
237
+ const baseKeys = new Set(base.members.map(normalizeName));
238
+ const headKeys = new Set(head.members.map(normalizeName));
239
+ for (const b of base.members) {
240
+ if (headKeys.has(normalizeName(b)))
241
+ continue;
242
+ out.push({ kind: isOutput ? "breaking" : "additive", element, field: null, what: `member "${b}" removed` });
243
+ }
244
+ for (const h of head.members) {
245
+ if (baseKeys.has(normalizeName(h)))
246
+ continue;
247
+ out.push({ kind: isInput ? "breaking" : "additive", element, field: null, what: `member "${h}" added` });
248
+ }
249
+ }
163
250
  /** MIL-265: a public element's invariants, base vs HEAD, matched by ID. Added or rule text
164
251
  * changed = breaking (the contract got tighter); removed = additive (looser). HEAD order for
165
252
  * additions/changes, then base order for removals. */
@@ -195,7 +282,10 @@ export function diffSurfaces(base, head) {
195
282
  const same = baseByKey.get(h.key);
196
283
  if (same) {
197
284
  matchedBase.add(same.key);
198
- diffFields(same, h, element, out);
285
+ if (h.kind === "enum")
286
+ diffEnumMembers(same, h, element, out);
287
+ else
288
+ diffFields(same, h, element, out);
199
289
  diffInvariants(same, h, element, out);
200
290
  continue;
201
291
  }
@@ -205,11 +295,14 @@ export function diffSurfaces(base, head) {
205
295
  if (renamedFrom) {
206
296
  matchedBase.add(renamedFrom.key);
207
297
  out.push({ kind: "breaking", element, field: null, what: `renamed from "${renamedFrom.name}"` });
208
- diffFields(renamedFrom, h, element, out);
298
+ if (h.kind === "enum")
299
+ diffEnumMembers(renamedFrom, h, element, out);
300
+ else
301
+ diffFields(renamedFrom, h, element, out);
209
302
  diffInvariants(renamedFrom, h, element, out);
210
303
  continue;
211
304
  }
212
- if (h.kind !== "type" && base.allElements.has(h.key)) {
305
+ if (h.kind !== "type" && h.kind !== "enum" && base.allElements.has(h.key)) {
213
306
  out.push({ kind: "additive", element, field: null, what: "marked public" });
214
307
  }
215
308
  else {
@@ -220,7 +313,7 @@ export function diffSurfaces(base, head) {
220
313
  if (matchedBase.has(b.key))
221
314
  continue;
222
315
  const element = elementId(b);
223
- if (b.kind !== "type" && head.allElements.has(b.key)) {
316
+ if (b.kind !== "type" && b.kind !== "enum" && head.allElements.has(b.key)) {
224
317
  out.push({ kind: "breaking", element, field: null, what: "`public` dropped" });
225
318
  }
226
319
  else {
@@ -274,7 +367,7 @@ export function runApiCheck(file, base, runGit = realGit) {
274
367
  return { ok: false, message: `em api check: unknown revision "${base}" (needed for --base)`, diagnostics: head.diagnostics };
275
368
  }
276
369
  const headDoc = buildExportDoc(head.model, head.refs, head.diagnostics, head.source, file).doc;
277
- const headSurface = surfaceOf(headDoc.model.slices, headDoc.model.types);
370
+ const headSurface = surfaceOf(headDoc.model.slices, headDoc.model.types, headDoc.model.enums);
278
371
  const atBase = resolveRevision(file, base, runGit);
279
372
  let baseSurface = null;
280
373
  if (atBase.ok) {
@@ -301,7 +394,7 @@ export function runApiCheck(file, base, runGit = realGit) {
301
394
  // Base-side validate errors (e.g. a pre-1.14 free-text public type) do not block the
302
395
  // structural diff — the diff reads shapes, not strictness.
303
396
  const baseDoc = buildExportDoc(baseCompiled.model, baseCompiled.refs, baseCompiled.diagnostics, atBase.content, file).doc;
304
- baseSurface = surfaceOf(baseDoc.model.slices, baseDoc.model.types);
397
+ baseSurface = surfaceOf(baseDoc.model.slices, baseDoc.model.types, baseDoc.model.enums);
305
398
  }
306
399
  else if (!/is not tracked by git/.test(atBase.message)) {
307
400
  return { ok: false, message: atBase.message.replace(/^em diff:/, "em api check:"), diagnostics: head.diagnostics };
@@ -334,35 +427,41 @@ export function formatApiCheckText(r) {
334
427
  return lines;
335
428
  }
336
429
  /** Resolve one field's public type exactly as `em api generate`'s own `fieldType()` does
337
- * (emit/api.ts) — declared types win (same precedence `resolvePublicType` already gives), else
338
- * the fixed scalar table. A model with errors never reaches here (both CLI callers refuse
339
- * first), so an unresolved type is a programming error, not a user-facing case — same posture
340
- * as `buildApiContract`'s own throw. */
341
- function surfaceFieldType(f, typesByName, owner) {
342
- const r = resolvePublicType(f.type ?? undefined, typesByName);
430
+ * (emit/api.ts) — declared types win (same precedence `resolvePublicType` already gives), then
431
+ * a declared identity scalar (GH #477), then a declared enum (GH #478), else the fixed scalar
432
+ * table. A model with errors never reaches here (both CLI callers refuse first), so an
433
+ * unresolved type is a programming error, not a user-facing case — same posture as
434
+ * `buildApiContract`'s own throw. */
435
+ function surfaceFieldType(f, typesByName, scalarsByName, enumsByName, owner) {
436
+ const r = resolvePublicType(f.type ?? undefined, typesByName, scalarsByName, enumsByName);
343
437
  if (!r) {
344
438
  throw new Error(`em api surface: ${owner} field "${f.name}" has no public type — em validate's ` +
345
439
  "public-field-type-unresolved should have refused this model");
346
440
  }
347
- return r.kind === "scalar"
348
- ? { kind: "scalar", scalar: r.scalar, tsType: r.tsType, array: r.array }
349
- : { kind: "declared", scalar: null, tsType: r.typeDecl.name, array: r.array };
441
+ if (r.kind === "scalar")
442
+ return { kind: "scalar", scalar: r.scalar, base: null, tsType: r.tsType, array: r.array };
443
+ if (r.kind === "identity") {
444
+ return { kind: "scalar-alias", scalar: null, base: r.base, tsType: r.scalarDecl.name, array: r.array };
445
+ }
446
+ if (r.kind === "enum")
447
+ return { kind: "enum", scalar: null, base: null, tsType: r.enumDecl.name, array: r.array };
448
+ return { kind: "declared", scalar: null, base: null, tsType: r.typeDecl.name, array: r.array };
350
449
  }
351
- function surfaceField(f, typesByName, owner) {
450
+ function surfaceField(f, typesByName, scalarsByName, enumsByName, owner) {
352
451
  return {
353
452
  name: f.name,
354
- type: surfaceFieldType(f, typesByName, owner),
453
+ type: surfaceFieldType(f, typesByName, scalarsByName, enumsByName, owner),
355
454
  optional: f.optional,
356
455
  tag: f.tag,
357
456
  assigned: f.assigned,
358
457
  renamedFrom: f.renamedFrom,
359
458
  };
360
459
  }
361
- function surfaceElement(el, typesByName) {
460
+ function surfaceElement(el, typesByName, scalarsByName, enumsByName) {
362
461
  return {
363
462
  name: el.name,
364
463
  slug: kebabSlug(el.name),
365
- fields: (el.fields ?? []).map((f) => surfaceField(f, typesByName, `${el.kind} "${el.name}"`)),
464
+ fields: (el.fields ?? []).map((f) => surfaceField(f, typesByName, scalarsByName, enumsByName, `${el.kind} "${el.name}"`)),
366
465
  invariants: el.invariants ?? null,
367
466
  };
368
467
  }
@@ -370,28 +469,38 @@ function surfaceElement(el, typesByName) {
370
469
  * Build the `em api surface` report for an already-validated (error-free) model: the `public`
371
470
  * commands/events/views (`publicSurfaceOf`, same as `em api generate`/`em api check`) plus every
372
471
  * declared type reachable from their fields (`reachableTypeRefs`, same walk `em api generate`'s
373
- * contract body uses), each field's type resolved through `resolvePublicType` — the one notion
374
- * of the public surface every em surface shares, so a companion tool never re-derives it from
375
- * `em export --json` and risks drifting from it.
472
+ * contract body uses), every declared identity scalar reachable the same way
473
+ * (`reachableScalarRefs`, GH #477), and every declared enum reachable the same way
474
+ * (`reachableEnumRefs`, GH #478), each field's type resolved through `resolvePublicType` — the
475
+ * one notion of the public surface every em surface shares, so a companion tool never re-derives
476
+ * it from `em export --json` and risks drifting from it.
376
477
  */
377
478
  export function buildApiSurfaceReport(model, refs, diagnostics, source, file) {
378
479
  const { doc } = buildExportDoc(model, refs, diagnostics, source, file);
379
480
  const surface = publicSurfaceOf(doc.model.slices);
380
481
  const typesByRef = new Map(doc.model.types.map((t) => [t.ref, t]));
381
482
  const reachable = reachableTypeRefs([...surface.commands, ...surface.events, ...surface.views], typesByRef);
483
+ const reachableScalars = reachableScalarRefs([...surface.commands, ...surface.events, ...surface.views], typesByRef);
484
+ const reachableEnums = reachableEnumRefs([...surface.commands, ...surface.events, ...surface.views], typesByRef);
382
485
  return {
383
486
  file,
384
487
  modelKey: doc.model.key,
385
- commands: surface.commands.map((el) => surfaceElement(el, model.typesByName)),
386
- events: surface.events.map((el) => surfaceElement(el, model.typesByName)),
387
- views: surface.views.map((el) => surfaceElement(el, model.typesByName)),
488
+ commands: surface.commands.map((el) => surfaceElement(el, model.typesByName, model.scalarsByName, model.enumsByName)),
489
+ events: surface.events.map((el) => surfaceElement(el, model.typesByName, model.scalarsByName, model.enumsByName)),
490
+ views: surface.views.map((el) => surfaceElement(el, model.typesByName, model.scalarsByName, model.enumsByName)),
388
491
  types: doc.model.types
389
492
  .filter((t) => reachable.has(t.ref))
390
493
  .map((t) => ({
391
494
  name: t.name,
392
495
  slug: kebabSlug(t.name),
393
- fields: t.fields.map((f) => surfaceField(f, model.typesByName, `type "${t.name}"`)),
496
+ fields: t.fields.map((f) => surfaceField(f, model.typesByName, model.scalarsByName, model.enumsByName, `type "${t.name}"`)),
394
497
  })),
498
+ scalars: doc.model.scalars
499
+ .filter((s) => reachableScalars.has(s.ref))
500
+ .map((s) => ({ name: s.name, slug: kebabSlug(s.name), base: s.base })),
501
+ enums: doc.model.enums
502
+ .filter((e) => reachableEnums.has(e.ref))
503
+ .map((e) => ({ name: e.name, slug: kebabSlug(e.name), members: e.members })),
395
504
  };
396
505
  }
397
506
  /**
@@ -441,7 +550,18 @@ export function formatApiSurfaceText(r) {
441
550
  section("events", r.events);
442
551
  section("views", r.views);
443
552
  section("types", r.types);
444
- if (r.commands.length === 0 && r.events.length === 0 && r.views.length === 0 && r.types.length === 0) {
553
+ if (r.scalars.length > 0) {
554
+ lines.push("scalars:");
555
+ for (const s of r.scalars)
556
+ lines.push(` ${s.slug} extends ${s.base}`);
557
+ }
558
+ if (r.enums.length > 0) {
559
+ lines.push("enums:");
560
+ for (const e of r.enums)
561
+ lines.push(` ${e.slug} { ${e.members.join(", ")} }`);
562
+ }
563
+ if (r.commands.length === 0 && r.events.length === 0 && r.views.length === 0 &&
564
+ r.types.length === 0 && r.scalars.length === 0 && r.enums.length === 0) {
445
565
  lines.push("(no public surface)");
446
566
  }
447
567
  return lines;