@milehimikey/em 1.21.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 (102) hide show
  1. package/.claude/agents/em-implementer.md +10 -0
  2. package/.claude/agents/em-validator.md +1 -0
  3. package/.claude/skills/event-modeling/SKILL.md +1 -1
  4. package/.claude/skills/event-modeling-conform/SKILL.md +7 -4
  5. package/.claude/skills/event-modeling-conform/reference/conform.md +82 -4
  6. package/.claude/skills/event-modeling-design/SKILL.md +5 -2
  7. package/.claude/skills/event-modeling-discover/SKILL.md +1 -1
  8. package/.claude/skills/event-modeling-engagement/SKILL.md +1 -1
  9. package/.claude/skills/event-modeling-engagement/reference/engagement.md +1 -0
  10. package/.claude/skills/event-modeling-implement/SKILL.md +5 -3
  11. package/.claude/skills/event-modeling-implement/reference/implement.md +14 -0
  12. package/.claude/skills/event-modeling-review/SKILL.md +1 -1
  13. package/.claude/skills/event-modeling-shared/reference/em-dsl.md +55 -6
  14. package/.claude/skills/event-modeling-shared/reference/operating-principles.md +26 -15
  15. package/.claude-plugin/marketplace.json +2 -2
  16. package/dist/cli/api.d.ts +136 -9
  17. package/dist/cli/api.js +278 -27
  18. package/dist/cli/api.js.map +1 -1
  19. package/dist/cli/bindings.d.ts +152 -0
  20. package/dist/cli/bindings.js +462 -0
  21. package/dist/cli/bindings.js.map +1 -0
  22. package/dist/cli/conformScope.d.ts +32 -0
  23. package/dist/cli/conformScope.js +68 -5
  24. package/dist/cli/conformScope.js.map +1 -1
  25. package/dist/cli/engagement.d.ts +24 -1
  26. package/dist/cli/engagement.js +50 -6
  27. package/dist/cli/engagement.js.map +1 -1
  28. package/dist/cli/engagementBrief.d.ts +127 -0
  29. package/dist/cli/engagementBrief.js +313 -0
  30. package/dist/cli/engagementBrief.js.map +1 -0
  31. package/dist/cli/engagementFile.d.ts +14 -0
  32. package/dist/cli/engagementFile.js +54 -0
  33. package/dist/cli/engagementFile.js.map +1 -1
  34. package/dist/cli/scopeInputs.js +2 -2
  35. package/dist/cli/scopeInputs.js.map +1 -1
  36. package/dist/cli/status.d.ts +5 -0
  37. package/dist/cli/status.js +1 -0
  38. package/dist/cli/status.js.map +1 -1
  39. package/dist/cli/upgrade.js +9 -10
  40. package/dist/cli/upgrade.js.map +1 -1
  41. package/dist/cli.js +143 -11
  42. package/dist/cli.js.map +1 -1
  43. package/dist/emit/api.d.ts +26 -1
  44. package/dist/emit/api.js +183 -25
  45. package/dist/emit/api.js.map +1 -1
  46. package/dist/emit/apiSurfaceJson.d.ts +3 -0
  47. package/dist/emit/apiSurfaceJson.js +43 -0
  48. package/dist/emit/apiSurfaceJson.js.map +1 -0
  49. package/dist/emit/diffJson.d.ts +1 -1
  50. package/dist/emit/diffJson.js +20 -1
  51. package/dist/emit/diffJson.js.map +1 -1
  52. package/dist/emit/engagementJson.d.ts +8 -3
  53. package/dist/emit/engagementJson.js +31 -4
  54. package/dist/emit/engagementJson.js.map +1 -1
  55. package/dist/emit/json.d.ts +42 -1
  56. package/dist/emit/json.js +65 -6
  57. package/dist/emit/json.js.map +1 -1
  58. package/dist/mcp/server.js +92 -3
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/model/diff.d.ts +19 -1
  61. package/dist/model/diff.js +99 -0
  62. package/dist/model/diff.js.map +1 -1
  63. package/dist/model/model.d.ts +105 -9
  64. package/dist/model/model.js +116 -20
  65. package/dist/model/model.js.map +1 -1
  66. package/dist/model/refs.d.ts +4 -0
  67. package/dist/model/refs.js +35 -1
  68. package/dist/model/refs.js.map +1 -1
  69. package/dist/model/rules.d.ts +61 -0
  70. package/dist/model/rules.js +72 -0
  71. package/dist/model/rules.js.map +1 -1
  72. package/dist/model/validate.js +137 -4
  73. package/dist/model/validate.js.map +1 -1
  74. package/dist/parser/ast.d.ts +30 -0
  75. package/dist/parser/parser.js +67 -1
  76. package/dist/parser/parser.js.map +1 -1
  77. package/dist/render/sliceDiagram.js +6 -0
  78. package/dist/render/sliceDiagram.js.map +1 -1
  79. package/dist/render/storylineDiagram.js +6 -0
  80. package/dist/render/storylineDiagram.js.map +1 -1
  81. package/dist/system/adaptation.d.ts +5 -2
  82. package/dist/system/adaptation.js +5 -3
  83. package/dist/system/adaptation.js.map +1 -1
  84. package/dist/templates.d.ts +4 -0
  85. package/dist/templates.js +29 -0
  86. package/dist/templates.js.map +1 -1
  87. package/package.json +2 -1
  88. package/plugin/.claude-plugin/plugin.json +2 -2
  89. package/plugin/agents/em-implementer.md +10 -0
  90. package/plugin/agents/em-validator.md +1 -0
  91. package/plugin/shared/reference/em-dsl.md +55 -6
  92. package/plugin/shared/reference/operating-principles.md +26 -15
  93. package/plugin/skills/conform/SKILL.md +7 -4
  94. package/plugin/skills/conform/reference/conform.md +82 -4
  95. package/plugin/skills/design/SKILL.md +5 -2
  96. package/plugin/skills/discover/SKILL.md +1 -1
  97. package/plugin/skills/engagement/SKILL.md +1 -1
  98. package/plugin/skills/engagement/reference/engagement.md +1 -0
  99. package/plugin/skills/event-modeling/SKILL.md +1 -1
  100. package/plugin/skills/implement/SKILL.md +5 -3
  101. package/plugin/skills/implement/reference/implement.md +14 -0
  102. package/plugin/skills/review/SKILL.md +1 -1
package/dist/cli/api.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { NormalizedModel } from "../model/model.js";
1
2
  import { Diagnostic } from "../model/validate.js";
2
3
  import { compile } from "../pipeline.js";
3
- import { ElementExport, FieldExport, TypeExport } from "../emit/json.js";
4
+ import { ElementExport, EnumExport, FieldExport, TypeExport } from "../emit/json.js";
5
+ import { RefsResult } from "../model/refs.js";
4
6
  import { GitRunner } from "./diff-inputs.js";
5
7
  /** `<modelDir>/contracts/<modelKey>.tsp` (R6), built from the model path as given. */
6
8
  export declare function contractPathFor(modelFile: string, modelKey: string): string;
@@ -28,7 +30,7 @@ export interface ApiCheckChange {
28
30
  }
29
31
  type Role = "input" | "output";
30
32
  interface SurfaceEntry {
31
- /** command | event | view | type */
33
+ /** command | event | view | type | enum */
32
34
  kind: string;
33
35
  name: string;
34
36
  /** Normalized identity key: kind + normalizeName(name). */
@@ -36,11 +38,13 @@ interface SurfaceEntry {
36
38
  fields: FieldExport[];
37
39
  renamedFrom: string[];
38
40
  roles: Set<Role>;
39
- /** Model-declared invariants (MIL-265), declaration order; `[]` for a type. */
41
+ /** Model-declared invariants (MIL-265), declaration order; `[]` for a type/enum. */
40
42
  invariants: {
41
43
  id: string;
42
44
  rule: string | null;
43
45
  }[];
46
+ /** GH #478: an enum's own members, in declaration order — `[]` for every non-enum kind. */
47
+ members: string[];
44
48
  }
45
49
  interface Surface {
46
50
  entries: SurfaceEntry[];
@@ -49,14 +53,29 @@ interface Surface {
49
53
  allElements: Set<string>;
50
54
  }
51
55
  /** Build the public surface of one compiled model: public commands (input), events and views
52
- * (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. */
53
62
  export declare function surfaceOf(slices: {
54
63
  elements: ElementExport[];
55
- }[], types: TypeExport[]): Surface;
56
- /** A field's type as the contract sees it: a declared type's name or the raw string,
57
- * normalized, with its array arity. */
58
- export declare function typeLabel(f: Pick<FieldExport, "type" | "typeRef">): string;
59
- 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;
60
79
  /** Diff two public surfaces. `base === null` means the model did not exist at the base
61
80
  * revision: every HEAD element is an additive addition. */
62
81
  export declare function diffSurfaces(base: Surface | null, head: Surface): ApiCheckChange[];
@@ -83,8 +102,116 @@ export type ApiCheckOutcome = {
83
102
  * model and compare to the committed contract file's text; (b) with `base`, compile the model
84
103
  * at that revision (`resolveRevision`) and diff the public surfaces structurally. A model
85
104
  * absent at `base` (new model) reports every public element as additive.
105
+ *
106
+ * GH #473: the HEAD side compiles through `compileModelFile` — the same storyline-aware loader
107
+ * `em api generate` uses (root plus every storyline model file it declares, GH #421) — so a
108
+ * public element declared in a storyline file is part of the surface instead of making the
109
+ * contract stale forever. `--base` has no multi-file-at-revision loader (`resolveRevision` reads
110
+ * one file at one rev); rather than silently diff a partial surface, a base revision that itself
111
+ * declares storyline files is refused.
86
112
  */
87
113
  export declare function runApiCheck(file: string, base: string | undefined, runGit?: GitRunner): ApiCheckOutcome;
88
114
  /** The text report: one status line, then one `additive:`/`breaking:` line per change. */
89
115
  export declare function formatApiCheckText(r: ApiCheckReport): string[];
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. */
126
+ export interface ApiSurfaceFieldType {
127
+ kind: "scalar" | "declared" | "scalar-alias" | "enum";
128
+ scalar: string | null;
129
+ /** The `PUBLIC_SCALAR_TYPES` key a `scalar-alias` extends; `null` for every other kind. */
130
+ base: string | null;
131
+ tsType: string;
132
+ array: boolean;
133
+ }
134
+ export interface ApiSurfaceField {
135
+ name: string;
136
+ type: ApiSurfaceFieldType;
137
+ optional: boolean;
138
+ tag: boolean;
139
+ assigned: boolean;
140
+ renamedFrom: string[] | null;
141
+ }
142
+ /** One public command/event/view on the surface. `slug` is the kebab-slug of `name` — a stable,
143
+ * human-legible identity a companion tool can use without re-deriving it (same slugging `em api
144
+ * check`'s element ids use, minus the `<kind>.` prefix since commands/events/views are already
145
+ * separated into their own arrays here). */
146
+ export interface ApiSurfaceElement {
147
+ name: string;
148
+ slug: string;
149
+ fields: ApiSurfaceField[];
150
+ invariants: {
151
+ id: string;
152
+ rule: string | null;
153
+ }[] | null;
154
+ }
155
+ /** One declared `type` reachable from a public command/event/view's fields. */
156
+ export interface ApiSurfaceType {
157
+ name: string;
158
+ slug: string;
159
+ fields: ApiSurfaceField[];
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
+ }
175
+ export interface ApiSurfaceReport {
176
+ file: string;
177
+ modelKey: string;
178
+ commands: ApiSurfaceElement[];
179
+ events: ApiSurfaceElement[];
180
+ views: ApiSurfaceElement[];
181
+ types: ApiSurfaceType[];
182
+ scalars: ApiSurfaceScalar[];
183
+ enums: ApiSurfaceEnum[];
184
+ }
185
+ export type ApiSurfaceOutcome = {
186
+ ok: true;
187
+ report: ApiSurfaceReport;
188
+ diagnostics: Diagnostic[];
189
+ } | {
190
+ ok: false;
191
+ message: string;
192
+ diagnostics: Diagnostic[];
193
+ };
194
+ /**
195
+ * Build the `em api surface` report for an already-validated (error-free) model: the `public`
196
+ * commands/events/views (`publicSurfaceOf`, same as `em api generate`/`em api check`) plus every
197
+ * declared type reachable from their fields (`reachableTypeRefs`, same walk `em api generate`'s
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.
203
+ */
204
+ export declare function buildApiSurfaceReport(model: NormalizedModel, refs: RefsResult, diagnostics: Diagnostic[], source: string, file: string): ApiSurfaceReport;
205
+ /**
206
+ * Run `em api surface <file>`: compile, refuse on errors (same posture as `em api generate` —
207
+ * `public-field-type-unresolved` included), build the report. Self-contained (reads + compiles
208
+ * `file` itself) so the MCP `api_surface` tool can call it with just a path, same shape as
209
+ * `runApiCheck`.
210
+ *
211
+ * GH #473: compiles through `compileModelFile` (storyline-aware, GH #421) — a single-file
212
+ * `compile()` here silently omitted every public element declared in a storyline file.
213
+ */
214
+ export declare function runApiSurface(file: string): ApiSurfaceOutcome;
215
+ /** The text listing: one line per command/event/view/type, with its field count. */
216
+ export declare function formatApiSurfaceText(r: ApiSurfaceReport): string[];
90
217
  export {};
package/dist/cli/api.js CHANGED
@@ -1,17 +1,20 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // `em api generate | check` (MIL-237): the contract path convention (briefing R6), the
3
- // "is the committed contract current" text comparison, and the structural public-surface diff
4
- // that annotates each change additive or breaking (briefing R8/R12). Pure where it can be —
2
+ // `em api generate | check | surface` (MIL-237, GH #448): the contract path convention
3
+ // (briefing R6), the "is the committed contract current" text comparison, the structural
4
+ // public-surface diff that annotates each change additive or breaking (briefing R8/R12), and the
5
+ // public surface as a structured document for companion tools (GH #448). Pure where it can be —
5
6
  // fs/git reads go through injectable readers so the diff and the classification are unit-
6
- // testable without a repository; cli.ts and the MCP `api_check` tool share `runApiCheck`.
7
+ // testable without a repository; cli.ts and the MCP `api_check`/`api_surface` tools share
8
+ // `runApiCheck`/`runApiSurface`.
7
9
  import { existsSync, readFileSync } from "node:fs";
8
10
  import { dirname, join, relative, resolve, sep } from "node:path";
9
- import { normalizeName } from "../model/model.js";
11
+ import { normalizeName, resolvePublicType } from "../model/model.js";
10
12
  import { computeModelKey } from "../model/qualifiedRef.js";
11
13
  import { hasErrors } from "../model/validate.js";
12
- import { compile } from "../pipeline.js";
14
+ import { compile, compileModelFile } from "../pipeline.js";
15
+ import { ModelLoadError } from "../model/loadModel.js";
13
16
  import { ParseError } from "../parser/parser.js";
14
- import { buildApiContract, publicSurfaceOf } from "../emit/api.js";
17
+ import { buildApiContract, publicSurfaceOf, reachableEnumRefs, reachableScalarRefs, reachableTypeRefs } from "../emit/api.js";
15
18
  import { buildExportDoc } from "../emit/json.js";
16
19
  import { kebabSlug } from "../util/slug.js";
17
20
  import { realGit, resolveRevision } from "./diff-inputs.js";
@@ -33,14 +36,25 @@ export function generateContract(file, source, compiled, outPath) {
33
36
  }
34
37
  const keyOf = (kind, name) => `${kind}:${normalizeName(name)}`;
35
38
  /** Build the public surface of one compiled model: public commands (input), events and views
36
- * (output), and every declared type reachable from them (carrying the roles that reach it). */
37
- 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 = []) {
38
46
  const s = publicSurfaceOf(slices);
39
47
  const entries = [];
40
48
  const typesByRef = new Map(types.map((t) => [t.ref, t]));
41
49
  const typeRoles = new Map();
50
+ const enumRoles = new Map();
42
51
  const reach = (fields, role) => {
43
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
+ }
44
58
  if (!f.typeRef)
45
59
  continue;
46
60
  const roles = typeRoles.get(f.typeRef.ref) ?? new Set();
@@ -60,6 +74,7 @@ export function surfaceOf(slices, types) {
60
74
  renamedFrom: el.renamedFrom ?? [],
61
75
  roles: new Set([role]),
62
76
  invariants: el.invariants ?? [],
77
+ members: [],
63
78
  });
64
79
  reach(el.fields, role);
65
80
  };
@@ -73,7 +88,13 @@ export function surfaceOf(slices, types) {
73
88
  const roles = typeRoles.get(t.ref);
74
89
  if (!roles)
75
90
  continue;
76
- 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 });
77
98
  }
78
99
  const allElements = new Set();
79
100
  for (const slice of slices)
@@ -81,18 +102,38 @@ export function surfaceOf(slices, types) {
81
102
  allElements.add(keyOf(el.kind, el.name));
82
103
  for (const t of types)
83
104
  allElements.add(keyOf("type", t.name));
105
+ for (const e of enums)
106
+ allElements.add(keyOf("enum", e.name));
84
107
  return { entries, allElements };
85
108
  }
86
- /** A field's type as the contract sees it: a declared type's name or the raw string,
87
- * 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. */
88
111
  export function typeLabel(f) {
89
112
  if (f.typeRef)
90
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 ? "[]" : ""}`;
91
118
  return f.type ?? "(none)";
92
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
+ */
93
130
  export function typeIdentity(f) {
94
131
  if (f.typeRef)
95
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}`;
96
137
  return `raw:${(f.type ?? "").replace(/\s+/g, "").toLowerCase()}`;
97
138
  }
98
139
  function diffFields(base, head, element, out) {
@@ -117,7 +158,25 @@ function diffFields(base, head, element, out) {
117
158
  }
118
159
  const compare = (b, h) => {
119
160
  if (typeIdentity(b) !== typeIdentity(h)) {
120
- 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
+ }
121
180
  }
122
181
  if (b.optional !== h.optional) {
123
182
  if (h.optional) {
@@ -157,6 +216,37 @@ function diffFields(base, head, element, out) {
157
216
  out.push({ kind, element, field: h.name, what: `added (${required ? "required" : "optional"})` });
158
217
  }
159
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
+ }
160
250
  /** MIL-265: a public element's invariants, base vs HEAD, matched by ID. Added or rule text
161
251
  * changed = breaking (the contract got tighter); removed = additive (looser). HEAD order for
162
252
  * additions/changes, then base order for removals. */
@@ -192,7 +282,10 @@ export function diffSurfaces(base, head) {
192
282
  const same = baseByKey.get(h.key);
193
283
  if (same) {
194
284
  matchedBase.add(same.key);
195
- diffFields(same, h, element, out);
285
+ if (h.kind === "enum")
286
+ diffEnumMembers(same, h, element, out);
287
+ else
288
+ diffFields(same, h, element, out);
196
289
  diffInvariants(same, h, element, out);
197
290
  continue;
198
291
  }
@@ -202,11 +295,14 @@ export function diffSurfaces(base, head) {
202
295
  if (renamedFrom) {
203
296
  matchedBase.add(renamedFrom.key);
204
297
  out.push({ kind: "breaking", element, field: null, what: `renamed from "${renamedFrom.name}"` });
205
- diffFields(renamedFrom, h, element, out);
298
+ if (h.kind === "enum")
299
+ diffEnumMembers(renamedFrom, h, element, out);
300
+ else
301
+ diffFields(renamedFrom, h, element, out);
206
302
  diffInvariants(renamedFrom, h, element, out);
207
303
  continue;
208
304
  }
209
- if (h.kind !== "type" && base.allElements.has(h.key)) {
305
+ if (h.kind !== "type" && h.kind !== "enum" && base.allElements.has(h.key)) {
210
306
  out.push({ kind: "additive", element, field: null, what: "marked public" });
211
307
  }
212
308
  else {
@@ -217,7 +313,7 @@ export function diffSurfaces(base, head) {
217
313
  if (matchedBase.has(b.key))
218
314
  continue;
219
315
  const element = elementId(b);
220
- if (b.kind !== "type" && head.allElements.has(b.key)) {
316
+ if (b.kind !== "type" && b.kind !== "enum" && head.allElements.has(b.key)) {
221
317
  out.push({ kind: "breaking", element, field: null, what: "`public` dropped" });
222
318
  }
223
319
  else {
@@ -231,28 +327,33 @@ export function diffSurfaces(base, head) {
231
327
  * model and compare to the committed contract file's text; (b) with `base`, compile the model
232
328
  * at that revision (`resolveRevision`) and diff the public surfaces structurally. A model
233
329
  * absent at `base` (new model) reports every public element as additive.
330
+ *
331
+ * GH #473: the HEAD side compiles through `compileModelFile` — the same storyline-aware loader
332
+ * `em api generate` uses (root plus every storyline model file it declares, GH #421) — so a
333
+ * public element declared in a storyline file is part of the surface instead of making the
334
+ * contract stale forever. `--base` has no multi-file-at-revision loader (`resolveRevision` reads
335
+ * one file at one rev); rather than silently diff a partial surface, a base revision that itself
336
+ * declares storyline files is refused.
234
337
  */
235
338
  export function runApiCheck(file, base, runGit = realGit) {
236
- let source;
237
- try {
238
- source = readFileSync(file, "utf8");
239
- }
240
- catch {
339
+ if (!existsSync(file)) {
241
340
  return { ok: false, message: `em api check: cannot read ${file}`, diagnostics: [] };
242
341
  }
243
342
  let head;
244
343
  try {
245
- head = compile(source);
344
+ head = compileModelFile(file);
246
345
  }
247
346
  catch (e) {
248
347
  if (e instanceof ParseError)
249
348
  return { ok: false, message: `em api check: parse error in ${file} ${e.message}`, diagnostics: [] };
349
+ if (e instanceof ModelLoadError)
350
+ return { ok: false, message: `em api check: ${e.message}`, diagnostics: [] };
250
351
  throw e;
251
352
  }
252
353
  if (hasErrors(head.diagnostics)) {
253
354
  return { ok: false, message: "em api check: not checking — fix the errors above", diagnostics: head.diagnostics };
254
355
  }
255
- const generated = generateContract(file, source, head);
356
+ const generated = generateContract(file, head.source, head);
256
357
  const missing = !existsSync(generated.contractPath);
257
358
  const current = !missing && readFileSync(generated.contractPath, "utf8") === generated.text;
258
359
  let changes = [];
@@ -265,8 +366,8 @@ export function runApiCheck(file, base, runGit = realGit) {
265
366
  if (verify.status !== 0) {
266
367
  return { ok: false, message: `em api check: unknown revision "${base}" (needed for --base)`, diagnostics: head.diagnostics };
267
368
  }
268
- const headDoc = buildExportDoc(head.model, head.refs, head.diagnostics, source, file).doc;
269
- const headSurface = surfaceOf(headDoc.model.slices, headDoc.model.types);
369
+ const headDoc = buildExportDoc(head.model, head.refs, head.diagnostics, head.source, file).doc;
370
+ const headSurface = surfaceOf(headDoc.model.slices, headDoc.model.types, headDoc.model.enums);
270
371
  const atBase = resolveRevision(file, base, runGit);
271
372
  let baseSurface = null;
272
373
  if (atBase.ok) {
@@ -280,10 +381,20 @@ export function runApiCheck(file, base, runGit = realGit) {
280
381
  }
281
382
  throw e;
282
383
  }
384
+ // GH #473: resolveRevision reads one file at one revision — there is no multi-file-at-
385
+ // revision loader yet. A base root that itself declares storyline files would silently
386
+ // diff a surface missing every element those files carry, so refuse loudly instead.
387
+ if ((baseCompiled.model.storylines ?? []).some((st) => st.file !== undefined)) {
388
+ return {
389
+ ok: false,
390
+ message: "em api check: --base does not yet support storyline models (the base revision's storyline files cannot be loaded) — see GH #475",
391
+ diagnostics: head.diagnostics,
392
+ };
393
+ }
283
394
  // Base-side validate errors (e.g. a pre-1.14 free-text public type) do not block the
284
395
  // structural diff — the diff reads shapes, not strictness.
285
396
  const baseDoc = buildExportDoc(baseCompiled.model, baseCompiled.refs, baseCompiled.diagnostics, atBase.content, file).doc;
286
- baseSurface = surfaceOf(baseDoc.model.slices, baseDoc.model.types);
397
+ baseSurface = surfaceOf(baseDoc.model.slices, baseDoc.model.types, baseDoc.model.enums);
287
398
  }
288
399
  else if (!/is not tracked by git/.test(atBase.message)) {
289
400
  return { ok: false, message: atBase.message.replace(/^em diff:/, "em api check:"), diagnostics: head.diagnostics };
@@ -315,4 +426,144 @@ export function formatApiCheckText(r) {
315
426
  }
316
427
  return lines;
317
428
  }
429
+ /** Resolve one field's public type exactly as `em api generate`'s own `fieldType()` does
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);
437
+ if (!r) {
438
+ throw new Error(`em api surface: ${owner} field "${f.name}" has no public type — em validate's ` +
439
+ "public-field-type-unresolved should have refused this model");
440
+ }
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 };
449
+ }
450
+ function surfaceField(f, typesByName, scalarsByName, enumsByName, owner) {
451
+ return {
452
+ name: f.name,
453
+ type: surfaceFieldType(f, typesByName, scalarsByName, enumsByName, owner),
454
+ optional: f.optional,
455
+ tag: f.tag,
456
+ assigned: f.assigned,
457
+ renamedFrom: f.renamedFrom,
458
+ };
459
+ }
460
+ function surfaceElement(el, typesByName, scalarsByName, enumsByName) {
461
+ return {
462
+ name: el.name,
463
+ slug: kebabSlug(el.name),
464
+ fields: (el.fields ?? []).map((f) => surfaceField(f, typesByName, scalarsByName, enumsByName, `${el.kind} "${el.name}"`)),
465
+ invariants: el.invariants ?? null,
466
+ };
467
+ }
468
+ /**
469
+ * Build the `em api surface` report for an already-validated (error-free) model: the `public`
470
+ * commands/events/views (`publicSurfaceOf`, same as `em api generate`/`em api check`) plus every
471
+ * declared type reachable from their fields (`reachableTypeRefs`, same walk `em api generate`'s
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.
477
+ */
478
+ export function buildApiSurfaceReport(model, refs, diagnostics, source, file) {
479
+ const { doc } = buildExportDoc(model, refs, diagnostics, source, file);
480
+ const surface = publicSurfaceOf(doc.model.slices);
481
+ const typesByRef = new Map(doc.model.types.map((t) => [t.ref, t]));
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);
485
+ return {
486
+ file,
487
+ modelKey: doc.model.key,
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)),
491
+ types: doc.model.types
492
+ .filter((t) => reachable.has(t.ref))
493
+ .map((t) => ({
494
+ name: t.name,
495
+ slug: kebabSlug(t.name),
496
+ fields: t.fields.map((f) => surfaceField(f, model.typesByName, model.scalarsByName, model.enumsByName, `type "${t.name}"`)),
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 })),
504
+ };
505
+ }
506
+ /**
507
+ * Run `em api surface <file>`: compile, refuse on errors (same posture as `em api generate` —
508
+ * `public-field-type-unresolved` included), build the report. Self-contained (reads + compiles
509
+ * `file` itself) so the MCP `api_surface` tool can call it with just a path, same shape as
510
+ * `runApiCheck`.
511
+ *
512
+ * GH #473: compiles through `compileModelFile` (storyline-aware, GH #421) — a single-file
513
+ * `compile()` here silently omitted every public element declared in a storyline file.
514
+ */
515
+ export function runApiSurface(file) {
516
+ if (!existsSync(file)) {
517
+ return { ok: false, message: `em api surface: cannot read ${file}`, diagnostics: [] };
518
+ }
519
+ let compiled;
520
+ try {
521
+ compiled = compileModelFile(file);
522
+ }
523
+ catch (e) {
524
+ if (e instanceof ParseError) {
525
+ return { ok: false, message: `em api surface: parse error in ${file} ${e.message}`, diagnostics: [] };
526
+ }
527
+ if (e instanceof ModelLoadError) {
528
+ return { ok: false, message: `em api surface: ${e.message}`, diagnostics: [] };
529
+ }
530
+ throw e;
531
+ }
532
+ if (hasErrors(compiled.diagnostics)) {
533
+ return { ok: false, message: "em api surface: not generating — fix the errors above", diagnostics: compiled.diagnostics };
534
+ }
535
+ const report = buildApiSurfaceReport(compiled.model, compiled.refs, compiled.diagnostics, compiled.source, file);
536
+ return { ok: true, report, diagnostics: compiled.diagnostics };
537
+ }
538
+ /** The text listing: one line per command/event/view/type, with its field count. */
539
+ export function formatApiSurfaceText(r) {
540
+ const lines = [`model ${r.modelKey} (${r.file})`];
541
+ const fieldCount = (n) => `${n} field${n === 1 ? "" : "s"}`;
542
+ const section = (label, elements) => {
543
+ if (elements.length === 0)
544
+ return;
545
+ lines.push(`${label}:`);
546
+ for (const el of elements)
547
+ lines.push(` ${el.slug} (${fieldCount(el.fields.length)})`);
548
+ };
549
+ section("commands", r.commands);
550
+ section("events", r.events);
551
+ section("views", r.views);
552
+ section("types", r.types);
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) {
565
+ lines.push("(no public surface)");
566
+ }
567
+ return lines;
568
+ }
318
569
  //# sourceMappingURL=api.js.map