@milehimikey/em 1.2.0 → 1.3.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.
@@ -28,7 +28,8 @@ validation rules) before doing real work — they are the source of truth. Templ
28
28
  multiple-choice decisions. Never assume a domain fact — extract it. Prefer "who/why/what-if/
29
29
  what-must-always-be-true/how-do-you-know" over yes-no.
30
30
  - **Don't guess — park it.** Unresolved items go into the Open Questions list in
31
- `.event-modeling.md`, not into invented model content.
31
+ `.event-modeling.md`, not into invented model content. Note the source, blocker, and revisit
32
+ date when known — cheap now, useful when you come back to it.
32
33
  - **Happy path first; branches belong to slicing.** Steps 1-7 build the **happy-path spine**.
33
34
  Alternate, unhappy, and exception-path events (rejections, removals, cancellations,
34
35
  expirations, declines) are **not** enumerated as a separate discover/model task — they
@@ -63,6 +64,9 @@ validation rules) before doing real work — they are the source of truth. Templ
63
64
  3. Parse the argument (`$ARGUMENTS`) to pick the phase below. With **no argument**, read the
64
65
  state file and resume the recorded phase/step; if no model exists, propose starting `discover`
65
66
  (greenfield) or `extract` (modeling an existing system).
67
+ 4. Populate the state file's Participants section at session start. For a live workshop, ask for
68
+ a single human proxy to relay questions to the room, and attribute every answer/decision in
69
+ the Decisions log to a named participant.
66
70
 
67
71
  ## Project layout this skill creates
68
72
 
@@ -88,6 +92,10 @@ template placeholders — never leave `{{...}}` in delivered files.
88
92
 
89
93
  Goal: a draft model of events, storyboard, commands, and views. Loose is OK; structure comes next.
90
94
  **Existing system?** Use `extract` instead (next section) — discover is for greenfield modeling.
95
+ **New feature inside an existing codebase?** Stay in `discover`, but before locking in
96
+ command/event names, Grep/Read adjacent real sources (OpenAPI specs, DB migrations, existing
97
+ DTOs/event classes in sibling contexts) rather than guessing conventions — same check `slice`
98
+ phase applies to field tables, below.
91
99
 
92
100
  1. **Brainstorm events (step 1).** Ask the user to name everything that happens, as past-tense
93
101
  facts. Probe for missed state changes. For each candidate, apply the **"is it an event?"
@@ -109,8 +117,8 @@ Goal: a draft model of events, storyboard, commands, and views. Loose is OK; str
109
117
  `reference/methodology.md`).
110
118
 
111
119
  End of phase: write/refresh the `.em`, render it, update `.event-modeling.md` (steps done,
112
- decisions, open questions, slice inventory). Tell the user they can stop here and resume with
113
- `/event-modeling model`.
120
+ decisions, open questions) and `README.md`'s slice index. Tell the user they can stop here and
121
+ resume with `/event-modeling model`.
114
122
 
115
123
  ## Phase: `extract` — current-state model of an existing system
116
124
 
@@ -130,8 +138,8 @@ state).
130
138
  - **Current-state-only:** park unknowns as `# TBD` comments in the `.em`, mirrored in the state
131
139
  file's Open Questions — never guess intended design.
132
140
 
133
- End of phase: validated as-is model; state file updated (source mode, rounds, decisions, slice
134
- inventory); then chain to `/event-modeling model` (steps 5-7).
141
+ End of phase: validated as-is model; state file updated (source mode, rounds, decisions) and
142
+ `README.md`'s slice index seeded; then chain to `/event-modeling model` (steps 5-7).
135
143
 
136
144
  ## Phase: `model` — steps 5-7
137
145
 
@@ -160,7 +168,7 @@ End of phase: render, update state, suggest `/event-modeling slice` to write imp
160
168
  ## Phase: `slice` — deep slice documents
161
169
 
162
170
  Goal: implementation-ready specs. Go slice by slice (let the user pick order, or follow the
163
- timeline). Check the state file's slice inventory for what's already done.
171
+ timeline). Check `README.md`'s slice index for what's already done.
164
172
 
165
173
  This is also where **branch / unhappy-path events** are discovered and added to the model — as a
166
174
  slice's alternate/error flows surface (a rejection, removal, cancellation, decline, expiry), add
@@ -171,13 +179,19 @@ For each slice:
171
179
  1. Hold a Socratic deep-dive to fill every section of `templates/slice.md`: intent, trigger/actor,
172
180
  command + field table (types & rules), event(s) + payload (mark immutable facts), invariants
173
181
  (give each a stable ID), Given/When/Then scenarios (happy path + rule boundaries + edge cases),
174
- alternate/error flows (retries, idempotency, compensations), read models affected, open
175
- questions. Park anything unresolved rather than guessing.
176
- 2. Write the doc to `slices/<slice-name>.md` (kebab-case the slice name).
182
+ alternate/error flows (retries, idempotency, compensations), non-functional requirements
183
+ (security/authz, PII/compliance, performance/SLA), read models affected, open questions. Park
184
+ anything unresolved rather than guessing. If the slice lives in an existing codebase, Grep/Read
185
+ adjacent real sources (OpenAPI specs, DB migrations, existing DTOs/event classes in sibling
186
+ contexts) before finalizing field names/types or invariants — don't guess a shape that's
187
+ already defined elsewhere.
188
+ 2. Write the doc to `slices/<slice-name>.md` (kebab-case the slice name). Record the originating
189
+ need (ticket/conversation link) in the Intent section when one exists.
177
190
  3. Wire it into the `.em`: add `note "slices/<slice-name>.md"` to the slice's primary element
178
191
  (the command for State Change, the view for State View, the processor for Automation, the
179
192
  translation for Translation).
180
- 4. Update `README.md`'s slice index and the state file's slice inventory (`draft` → `ready-to-implement`).
193
+ 4. Update `README.md`'s slice index — the one canonical slice table (`draft` →
194
+ `ready-to-implement`, later `implemented` once shipped, with `Implemented in:` filled in).
181
195
  5. Re-render and `em validate`.
182
196
 
183
197
  ## Phase: `watch` — live team view
@@ -199,6 +213,11 @@ Run `em validate <model-name>.em` and walk through each diagnostic with the user
199
213
  rule (see `reference/em-dsl.md`) and proposing the fix (e.g. split an automation's command into
200
214
  the next slice, add a missing `from`, give a command its event). Apply fixes on agreement.
201
215
 
216
+ For anything genuinely unresolved rather than a rule violation, prefer `issue "text"` on the
217
+ relevant element over a `# TBD` comment — it shows up as a red marker on the rendered diagram and
218
+ `em validate` tracks it as an open-issue warning, so it isn't lost once rendered. `em validate
219
+ --list-issues` gives a quick sweep of everything still open.
220
+
202
221
  Then do one check `em validate` can't: scan every `translation`/`processor`/`automation` slice and
203
222
  confirm none contains an `event` — each reaction must trigger a `command` in the next slice
204
223
  (`reaction → command → event`). If you find a reaction wired straight to an event, split it into
@@ -18,6 +18,8 @@ em render <file> --keep-empty-lanes # keep the API lane even when empty
18
18
  em watch <file> -o out.svg # re-render on every save (file-based)
19
19
  em watch <file> -o out.svg --serve # + localhost live viewer, instant SSE push-reload (--port N)
20
20
  em validate <file> # check event-modeling rules; exit 0 if only warnings/clean
21
+ em validate <file> --list-issues # print only open `issue "..."` clauses (slice, element, line, text)
22
+ em validate <file> --fail-on-issues # opt-in CI gate: exit non-zero while any issue remains open
21
23
  ```
22
24
 
23
25
  Install if missing: `npm i -g @milehimikey/em`. PNG works with no system deps; PDF needs
@@ -48,11 +50,11 @@ arrow From Element -> To Element # explicit cross-slice edge (overrides infer
48
50
  ### Element kinds (8 keywords, nothing else)
49
51
  | Keyword | Band | Meaning | Tag | Extra clauses |
50
52
  |---|---|---|---|---|
51
- | `ui` | persona | screen / interface | `@Persona` | `note`, `{ fields }` |
52
- | `command` | API | state-changing request | — | `note`, `{ fields }` |
53
- | `view` | API | read model / projection | — | `from "Event"…`, `note`, `{ fields }` |
54
- | `event` | context | recorded fact (past tense) | `@Context` | `note`, `{ fields }` |
55
- | `processor` / `automation` / `saga` / `translation` | automation | system reaction / adapter | — | `from "…"`, `note`, `{ fields }` |
53
+ | `ui` | persona | screen / interface | `@Persona` | `note`, `issue`, `{ fields }` |
54
+ | `command` | API | state-changing request | — | `note`, `issue`, `{ fields }` |
55
+ | `view` | API | read model / projection | — | `from "Event"…`, `note`, `issue`, `{ fields }` |
56
+ | `event` | context | recorded fact (past tense) | `@Context` | `note`, `issue`, `{ fields }` |
57
+ | `processor` / `automation` / `saga` / `translation` | automation | system reaction / adapter | — | `from "…"`, `note`, `issue`, `{ fields }` |
56
58
 
57
59
  ### Clauses
58
60
  - **Tags:** `@Persona` only on `ui`; `@Context` only on `event`. Undeclared tags auto-create a row.
@@ -69,6 +71,11 @@ arrow From Element -> To Element # explicit cross-slice edge (overrides infer
69
71
  `from`/`arrow` — plain repeats are only warning-free while unreferenced.
70
72
  - **`note "path.md"`** on ANY element links a markdown doc. Relative to the `.em` file. Renders as
71
73
  a clickable marker in SVG and a legend entry in PNG/PDF. **This is how slice docs attach.**
74
+ - **`issue "text"`** on ANY element flags an open question inline — the diagram-visible red
75
+ sticky note. Renders as a red corner marker (opposite corner from `note`, so both can coexist
76
+ on one element) plus a legend entry; `em validate` warns on every open issue. **Prefer this
77
+ over a `# TBD` comment for anything that should show up on the rendered diagram** — `# TBD` is
78
+ invisible once rendered, `issue` isn't.
72
79
  - **Fields:** `command Place Order { orderId: UUID, items: LineItem[], customerId }` — inline or
73
80
  one-per-line. Types are free text (no semantic checking). Keep these light; full field specs
74
81
  with rules live in the slice doc.
@@ -197,6 +204,16 @@ slice "Read Quote — created" { view Quote from "QuoteCreated" translation S
197
204
  2. **Command without event** — every command should record at least one event.
198
205
  3. **Read model without source** — add `from "Event"` or place the view in a slice with an event.
199
206
  4. **Duplicate name** — the same name defined N times; references resolve to the first. Rename.
207
+ 5. **Open issue** — an element carries `issue "text"`; resolve the question, then remove the
208
+ clause. `em validate --list-issues` prints just these; `--fail-on-issues` (opt-in) makes CI
209
+ fail while any remain open.
210
+ 6. **View field with no source** — a `view` field whose name matches no field on any instance
211
+ of its source events. Only checked once BOTH the view and at least one source event declare
212
+ `{ fields }`.
213
+ 7. **Event field not from a command** — an `event` field whose name matches no field on any
214
+ command in the same slice. Only checked once BOTH the event and at least one same-slice
215
+ command declare `{ fields }`. This is the payoff of the fields feature for slicing rigor:
216
+ once fields are written down, `em validate` checks that data flows forward consistently.
200
217
 
201
218
  **Design rules that keep models valid:**
202
219
  - One element per band per slice (multiple personas/contexts are fine — they're different rows).
@@ -94,8 +94,9 @@ one for a sprawling one. Record any deviation in the Decisions log.
94
94
  - Inline `.em` comment on its own line directly above (or trailing) the element it concerns:
95
95
  `# TBD: <specific question about current behavior>`. Comments are native DSL syntax — they
96
96
  render and validate cleanly.
97
- - Mirror every `# TBD` 1:1 as a checkbox in the state file's **Open questions / parking lot**,
98
- and remove both together when resolved.
97
+ - Mirror every `# TBD` 1:1 as a checkbox in the state file's **Open questions / parking lot** —
98
+ add `source`/`blocked on`/`revisit` metadata when known — and remove both together when
99
+ resolved.
99
100
  - A `# TBD` is only ever resolved by a confirmed current-state fact — never replaced by a
100
101
  guessed element.
101
102
 
@@ -105,8 +106,8 @@ Extraction is complete when: the model renders; `em validate` is clean (or remai
105
106
  are consciously accepted in the Decisions log); every unknown is a parked `# TBD`; and the
106
107
  user has confirmed the model reflects today's behavior. Then:
107
108
 
108
- 1. State file: set phase progress (extraction rounds checked), log the completion decision,
109
- seed the **Slice inventory** (every slice, pattern, doc status `none`).
109
+ 1. State file: set phase progress (extraction rounds checked), log the completion decision.
110
+ Seed `README.md`'s **slice index** (every slice, pattern, status `none`).
110
111
  2. The extracted model **replaces `discover`** — suggest `/event-modeling model` next
111
112
  (steps 5-7: swimlanes, patterns, completeness). Desired-state changes belong *after* the
112
113
  as-is model is agreed — as normal `model`/`slice` evolution, clearly separated from
@@ -163,6 +163,9 @@ dedicated `slice` phase writes the full rich spec (see `templates/slice.md`).
163
163
  - Prompts: *"Given what starting state, when this command/trigger fires, then what event(s)
164
164
  result? What must always be true? What are the failure paths? Which fields are essential and
165
165
  what are their rules?"*
166
+ - **Existing codebase:** before finalizing field names/types, check adjacent real sources
167
+ (OpenAPI specs, DB migrations, existing DTOs/event classes in sibling contexts) rather than
168
+ guessing — mirrors `extract.md`'s whole-system grounding, applied per-slice.
166
169
  - Output: rich slice docs linked into the `.em` via `note "slices/<name>.md"`.
167
170
 
168
171
  ### Step 7 — Evaluate Completeness *(model)*
@@ -192,9 +195,11 @@ The stance inversion, stated authoritatively: **extraction captures how the syst
192
195
  today — faithfully, warts included — and never invents future or desired state.** Step 1's
193
196
  anti-current-system caution is suspended for the duration; the "is it an event?" filter is
194
197
  not — derived values and telemetry are still rejected even when the current system emits them.
195
- Unknown or ambiguous current behavior is parked (`# TBD` in the `.em`, mirrored in the state
196
- file's Open Questions), never guessed. Desired-state improvements belong to the `model` and
197
- `slice` phases, after the as-is picture is agreed.
198
+ Unknown or ambiguous current behavior is parked, never guessed — mirrored in the state file's
199
+ Open Questions, and on the element itself with `issue "text"` when it should also show up on the
200
+ rendered diagram (preferred over a bare `# TBD` comment, which is invisible once rendered).
201
+ Desired-state improvements belong to the `model` and `slice` phases, after the as-is picture is
202
+ agreed.
198
203
 
199
204
  Procedure, source modes (event-driven vs. procedural synthesis), and the round-by-round loop:
200
205
  `reference/extract.md`.
@@ -208,3 +213,6 @@ Procedure, source modes (event-driven vs. procedural synthesis), and the round-b
208
213
  - Mirror the model back after each increment and **re-render** so the team sees it evolve.
209
214
  - Park unresolved questions in the state file instead of guessing.
210
215
  - Name things crisply: events past-tense, commands imperative, views as the thing-shown.
216
+ - **Live workshop, one chat interface:** route through a single human proxy who relays questions
217
+ to the room and reports answers back; attribute every answer/decision in the Decisions log to
218
+ the named participant who gave it (see the state file's Participants section).
@@ -22,10 +22,12 @@ Static render: `em render {{model-name}}.em -o {{model-name}}.svg`
22
22
  - **Translation** — External input (or Read Model) → Translation → (next slice) Command → Event
23
23
 
24
24
  ## Slices
25
- <!-- Keep this index in sync as slice docs are written. -->
26
- | # | Slice | Pattern | Design doc |
27
- |---|-------|---------|------------|
28
- | 1 | {{Slice Name}} | {{Pattern}} | [slices/{{slice-name}}.md](slices/{{slice-name}}.md) |
25
+ <!-- The canonical slice index — the ONE place slices are enumerated (the state file
26
+ points here rather than keeping its own copy). Keep it in sync as slice docs are
27
+ written and as statuses move. -->
28
+ | # | Slice | Pattern | Status | Design doc |
29
+ |---|-------|---------|--------|------------|
30
+ | 1 | {{Slice Name}} | {{Pattern}} | {{none / draft / reviewed / ready-to-implement / implemented}} | [slices/{{slice-name}}.md](slices/{{slice-name}}.md) |
29
31
 
30
32
  ## Status
31
33
  See [`.event-modeling.md`](.event-modeling.md) for current phase, decisions, and open questions.
@@ -9,10 +9,12 @@ Replace the bracketed placeholders; delete guidance comments before finishing.
9
9
 
10
10
  - **Pattern:** {{State Change | State View | Automation | Translation}}
11
11
  - **Swimlane:** {{Persona/Actor}} → {{Context/Aggregate}}
12
- - **Status:** {{draft | reviewed | ready-to-implement}}
12
+ - **Status:** {{draft | reviewed | ready-to-implement | implemented}}
13
+ - **Implemented in:** {{PR/commit link — fill in once status is `implemented`}}
13
14
 
14
15
  ## Intent
15
- {{Why this slice exists — the user or business goal it serves, in one or two sentences.}}
16
+ {{Why this slice exists — the user or business goal it serves, in one or two sentences. Note the
17
+ originating ticket/conversation link here if one exists.}}
16
18
 
17
19
  ## Trigger & Actor
18
20
  {{Who or what initiates this slice and under what circumstances. For automations, the watched
@@ -61,6 +63,13 @@ an event directly.}}
61
63
  - {{e.g. external call fails → retry policy / compensating event}}
62
64
  - {{idempotency: what happens if the command/event arrives twice?}}
63
65
 
66
+ ## Non-Functional Requirements
67
+ <!-- Short checklist. Idempotency is covered above under Alternate & Error Flows — not repeated
68
+ here. -->
69
+ - **Security / authz:** {{who may invoke this; role/permission checks — or "none"}}
70
+ - **PII & compliance:** {{personal data touched, retention/consent constraints — or "none"}}
71
+ - **Performance / SLA:** {{latency/throughput expectation — or "none"}}
72
+
64
73
  ## Dependencies & Read Models Affected
65
74
  - **Upstream events this slice relies on:** {{...}}
66
75
  - **Downstream read models / slices affected:** {{...}}
@@ -18,6 +18,11 @@ Keep it current at the end of every working session.
18
18
  - **Source mode:** {{greenfield | extract-event-driven | extract-procedural}}
19
19
  - **Existing system refs:** {{repo paths, event-schema/topic locations, docs — or "n/a"}}
20
20
 
21
+ ## Participants
22
+ <!-- Populate at session start. Live workshop: one human proxy relays questions to the room;
23
+ attribute every answer/decision in the Decisions log to a named participant here. -->
24
+ - {{Name}} — {{role}} — {{domain area}}
25
+
21
26
  ## Extraction progress (existing-system models only — delete for greenfield)
22
27
  - [ ] R1. Candidate events (extracted/synthesized, filtered, confirmed)
23
28
  - [ ] R2. Timeline order (as-is narrative, actors/callers, first render)
@@ -37,14 +42,18 @@ Keep it current at the end of every working session.
37
42
  - [ ] 7. Evaluate completeness (`em validate` clean)
38
43
 
39
44
  ## Decisions log
40
- <!-- Resolved choices, with the reasoning, so they aren't re-litigated. -->
41
- - {{YYYY-MM-DD}}: {{decision}} — {{why}}
45
+ <!-- Resolved choices, with the reasoning, so they aren't re-litigated. In a live workshop,
46
+ attribute each entry to the participant who made the call (see Participants above). -->
47
+ - {{YYYY-MM-DD}}: {{decision}} — {{why}} — by {{participant, if a live workshop}}
42
48
 
43
49
  ## Open questions / parking lot
44
- <!-- Unresolved items to bring back to the user. Never guess these. -->
45
- - [ ] {{question}}
50
+ <!-- Unresolved items to bring back to the user. Never guess these. Metadata is optional and
51
+ flat — add only what's known: source (ticket/conversation that spawned it), blocked on
52
+ (who/what), revisit (when). -->
53
+ - [ ] {{question}} — source: {{ticket/conversation link, optional}} — blocked on: {{who/what, optional}} — revisit: {{when, optional}}
46
54
 
47
55
  ## Slice inventory
48
- | Slice | Pattern | Doc status |
49
- |-------|---------|------------|
50
- | {{Slice Name}} | {{State Change/View/Automation/Translation}} | {{none / draft / reviewed / ready-to-implement}} |
56
+ <!-- Deliberately NOT a table: the canonical slice index (slice, pattern, status, doc link)
57
+ lives in README.md's "Slices" section — one source of truth, updated there. This file
58
+ only tracks resumable session state. -->
59
+ See `README.md` → **Slices** for the slice index and per-slice doc status.
package/README.md CHANGED
@@ -75,6 +75,7 @@ built this way.
75
75
  | [docs/dsl.md](docs/dsl.md) | Full DSL reference: keywords, `from`, `again`, fields, notes |
76
76
  | [docs/cli.md](docs/cli.md) | Every command and flag |
77
77
  | [docs/validation.md](docs/validation.md) | Every rule `em validate` checks, and the fixes |
78
+ | [docs/ci.md](docs/ci.md) | Copy-paste GitHub Actions recipe: validate `.em` changes in CI |
78
79
  | [docs/timeline.md](docs/timeline.md) | The Two Laws of the Timeline |
79
80
  | [docs/ai-workflow.md](docs/ai-workflow.md) | The Claude Code skill: install, phases, artifacts |
80
81
  | [docs/dependencies.md](docs/dependencies.md) | What's bundled vs. what needs a system install |
@@ -0,0 +1,44 @@
1
+ /** Resolved `em diff` invocation, or a user-facing `error` for an invalid form. */
2
+ export type DiffPlan = {
3
+ error: string;
4
+ } | {
5
+ form: "files";
6
+ oldFile: string;
7
+ newFile: string;
8
+ } | {
9
+ form: "git";
10
+ file: string;
11
+ from: string;
12
+ to?: string;
13
+ };
14
+ /**
15
+ * Validate the `em diff` argument/flag combination. Pure — no I/O. The two forms
16
+ * (two files, or one file across git revisions) are mutually exclusive.
17
+ */
18
+ export declare function planDiffArgs(oldFile: string, newFile: string | undefined, opts: {
19
+ from?: string;
20
+ to?: string;
21
+ }): DiffPlan;
22
+ /** The slice of a `spawnSync` result `resolveRevision` reads; lets tests inject a fake git. */
23
+ export interface GitResult {
24
+ status: number | null;
25
+ stdout: string;
26
+ stderr: string;
27
+ }
28
+ export type GitRunner = (args: string[]) => GitResult;
29
+ /** Default runner: really invokes `git` (array args — no shell, no injection). */
30
+ export declare const realGit: GitRunner;
31
+ /** File content at a git revision, or a user-facing `message` for each failure mode. */
32
+ export type RevisionResult = {
33
+ ok: true;
34
+ content: string;
35
+ } | {
36
+ ok: false;
37
+ message: string;
38
+ };
39
+ /**
40
+ * Resolve `file`'s content at git revision `rev` via `git show <rev>:<repo-relative-path>`.
41
+ * The git runner is injectable so the not-a-repo / not-tracked / bad-rev branches are
42
+ * unit-testable without a real repository.
43
+ */
44
+ export declare function resolveRevision(file: string, rev: string, runGit?: GitRunner): RevisionResult;
@@ -0,0 +1,62 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Testable input handling for `em diff`: argument-form validation and
3
+ // git-revision resolution, factored out of cli.ts so the branching logic can be
4
+ // unit-tested without spawning a subprocess or a real git repo (cli.ts keeps the
5
+ // process.exit/console wiring; see test/diff-inputs.test.ts).
6
+ import { spawnSync } from "node:child_process";
7
+ import { dirname, resolve } from "node:path";
8
+ /**
9
+ * Validate the `em diff` argument/flag combination. Pure — no I/O. The two forms
10
+ * (two files, or one file across git revisions) are mutually exclusive.
11
+ */
12
+ export function planDiffArgs(oldFile, newFile, opts) {
13
+ if (opts.from) {
14
+ if (newFile) {
15
+ return {
16
+ error: "em diff: cannot combine two file arguments with --from/--to — use one form or the other",
17
+ };
18
+ }
19
+ return { form: "git", file: oldFile, from: opts.from, to: opts.to };
20
+ }
21
+ if (opts.to) {
22
+ return { error: "em diff: --to requires --from" };
23
+ }
24
+ if (!newFile) {
25
+ return {
26
+ error: "em diff: provide two files (`em diff old.em new.em`) or one file with --from <rev>",
27
+ };
28
+ }
29
+ return { form: "files", oldFile, newFile };
30
+ }
31
+ /** Default runner: really invokes `git` (array args — no shell, no injection). */
32
+ export const realGit = (args) => {
33
+ const r = spawnSync("git", args, { encoding: "utf8" });
34
+ return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
35
+ };
36
+ /**
37
+ * Resolve `file`'s content at git revision `rev` via `git show <rev>:<repo-relative-path>`.
38
+ * The git runner is injectable so the not-a-repo / not-tracked / bad-rev branches are
39
+ * unit-testable without a real repository.
40
+ */
41
+ export function resolveRevision(file, rev, runGit = realGit) {
42
+ const abs = resolve(file);
43
+ const toplevel = runGit(["-C", dirname(abs), "rev-parse", "--show-toplevel"]);
44
+ if (toplevel.status !== 0) {
45
+ return { ok: false, message: `em diff: ${file} is not inside a git repository (needed for --from/--to)` };
46
+ }
47
+ const repoRoot = toplevel.stdout.trim();
48
+ const lsFiles = runGit(["-C", repoRoot, "ls-files", "--full-name", "--", abs]);
49
+ const relPath = lsFiles.stdout.trim().split("\n")[0];
50
+ if (!relPath) {
51
+ return { ok: false, message: `em diff: ${file} is not tracked by git in ${repoRoot}` };
52
+ }
53
+ const show = runGit(["-C", repoRoot, "show", `${rev}:${relPath}`]);
54
+ if (show.status !== 0) {
55
+ return {
56
+ ok: false,
57
+ message: `em diff: cannot read ${relPath} at revision "${rev}": ${(show.stderr || "").trim() || "unknown git error"}`,
58
+ };
59
+ }
60
+ return { ok: true, content: show.stdout };
61
+ }
62
+ //# sourceMappingURL=diff-inputs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff-inputs.js","sourceRoot":"","sources":["../../src/cli/diff-inputs.ts"],"names":[],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -11,12 +11,19 @@ import { renderDot, formatFromPath } from "./render/render.js";
11
11
  import { watchFile } from "./render/watch.js";
12
12
  import { startLiveServer } from "./render/serve.js";
13
13
  import { formatDiagnostic, hasErrors } from "./model/validate.js";
14
+ import { buildExport } from "./emit/json.js";
15
+ import { diffModels, formatModelDiff, hasChanges } from "./model/diff.js";
16
+ import { planDiffArgs, resolveRevision } from "./cli/diff-inputs.js";
14
17
  import { STARTER_EM } from "./templates.js";
15
18
  const program = new Command();
19
+ // Single source of truth for the version (also read by src/emit/json.ts for
20
+ // `generator.version` in `em export`): package.json, one level up from both
21
+ // src/ and dist/.
22
+ const PKG_VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
16
23
  program
17
24
  .name("em")
18
25
  .description("Event Modeling CLI — slice-first DSL rendered as a strict Graphviz grid")
19
- .version("1.2.0");
26
+ .version(PKG_VERSION);
20
27
  program
21
28
  .command("init")
22
29
  .description("scaffold a starter .em model")
@@ -63,6 +70,54 @@ program
63
70
  await renderDot(dot, model, out, fmt, dirname(file));
64
71
  console.log(`rendered ${out}`);
65
72
  });
73
+ program
74
+ .command("export")
75
+ .description("export a versioned JSON snapshot of the normalized model")
76
+ .argument("<file>", "input .em file")
77
+ .option("-o, --out <path>", "write to a file instead of stdout")
78
+ .action((file, opts) => {
79
+ const { model, diagnostics, source } = compileFile(file);
80
+ printDiagnostics(diagnostics);
81
+ if (hasErrors(diagnostics)) {
82
+ console.error("not exporting: fix the errors above");
83
+ process.exit(1);
84
+ }
85
+ const exported = buildExport(model, diagnostics, source, file);
86
+ printDiagnostics(exported.diagnostics.filter((d) => !diagnostics.includes(d)));
87
+ if (opts.out) {
88
+ writeFileSync(opts.out, exported.text + "\n");
89
+ console.log(`wrote ${opts.out}`);
90
+ }
91
+ else {
92
+ process.stdout.write(exported.text + "\n");
93
+ }
94
+ });
95
+ program
96
+ .command("diff")
97
+ .description("compare two models structurally (two files, or one file across git revisions)")
98
+ .argument("<old>", "old model file — or the file to diff, when using --from")
99
+ .argument("[new]", "new model file (omit when using --from/--to)")
100
+ .option("--from <rev>", "diff <old> against this git revision instead of a second file")
101
+ .option("--to <rev>", "diff against this git revision instead of the current file (requires --from)")
102
+ .option("--exit-code", "exit 1 if the models differ, 0 if identical (git-diff convention)")
103
+ .action((oldFile, newFile, opts) => {
104
+ const plan = planDiffArgs(oldFile, newFile, opts);
105
+ if ("error" in plan) {
106
+ console.error(plan.error);
107
+ process.exit(1);
108
+ }
109
+ if (plan.form === "files") {
110
+ const oldSource = readFileOrExit(plan.oldFile);
111
+ const newSource = readFileOrExit(plan.newFile);
112
+ runDiff(oldSource, plan.oldFile, newSource, plan.newFile, opts.exitCode);
113
+ return;
114
+ }
115
+ const oldSource = readAtRevision(plan.file, plan.from);
116
+ const oldLabel = `${plan.file}@${plan.from}`;
117
+ const newSource = plan.to ? readAtRevision(plan.file, plan.to) : readFileOrExit(plan.file);
118
+ const newLabel = plan.to ? `${plan.file}@${plan.to}` : plan.file;
119
+ runDiff(oldSource, oldLabel, newSource, newLabel, opts.exitCode);
120
+ });
66
121
  program
67
122
  .command("watch")
68
123
  .description("re-render on every save")
@@ -116,13 +171,25 @@ program
116
171
  .command("validate")
117
172
  .description("check a model against event-modeling rules")
118
173
  .argument("<file>", "input .em file")
119
- .action((file) => {
120
- const { diagnostics } = compileFile(file);
121
- printDiagnostics(diagnostics);
174
+ .option("--list-issues", "print only open `issue` diagnostics (slice, element, line, text)")
175
+ .option("--fail-on-issues", "exit non-zero if the model has any open `issue`s (opt-in — issues are warnings and don't block by default)")
176
+ .action((file, opts) => {
177
+ const { model, diagnostics } = compileFile(file);
178
+ if (opts.listIssues) {
179
+ printIssues(model);
180
+ // Errors still fail the run below — surface them rather than exiting
181
+ // non-zero with nothing but the issue list on screen.
182
+ printDiagnostics(diagnostics.filter((d) => d.severity === "error"));
183
+ }
184
+ else {
185
+ printDiagnostics(diagnostics);
186
+ if (diagnostics.length === 0)
187
+ console.log("ok — no issues");
188
+ }
122
189
  if (hasErrors(diagnostics))
123
190
  process.exit(1);
124
- if (diagnostics.length === 0)
125
- console.log("ok — no issues");
191
+ if (opts.failOnIssues && model.elements.some((el) => el.issue))
192
+ process.exit(1);
126
193
  });
127
194
  const skill = program
128
195
  .command("skill")
@@ -160,7 +227,7 @@ function compileFile(file, opts = {}) {
160
227
  process.exit(1);
161
228
  }
162
229
  try {
163
- return compile(source, opts);
230
+ return { ...compile(source, opts), source };
164
231
  }
165
232
  catch (e) {
166
233
  if (e instanceof ParseError) {
@@ -170,6 +237,53 @@ function compileFile(file, opts = {}) {
170
237
  throw e;
171
238
  }
172
239
  }
240
+ /** Read a file's text, or exit with a clear error — used by `em diff`'s two-file form. */
241
+ function readFileOrExit(file) {
242
+ try {
243
+ return readFileSync(file, "utf8");
244
+ }
245
+ catch {
246
+ console.error(`cannot read ${file}`);
247
+ process.exit(1);
248
+ }
249
+ }
250
+ /** Parse+normalize source text for `em diff`, exiting with a labeled parse error on failure. */
251
+ function compileSource(source, label) {
252
+ try {
253
+ return compile(source);
254
+ }
255
+ catch (e) {
256
+ if (e instanceof ParseError) {
257
+ console.error(`parse error in ${label} ${e.message}`);
258
+ process.exit(1);
259
+ }
260
+ throw e;
261
+ }
262
+ }
263
+ /** Resolve `file`'s content at a git revision, exiting with a clear error on any git failure. */
264
+ function readAtRevision(file, rev) {
265
+ const result = resolveRevision(file, rev);
266
+ if (!result.ok) {
267
+ console.error(result.message);
268
+ process.exit(1);
269
+ }
270
+ return result.content;
271
+ }
272
+ /** Shared body for both `em diff` forms: compile both sides, gate on errors, print, exit-code. */
273
+ function runDiff(oldSource, oldLabel, newSource, newLabel, exitCode) {
274
+ const oldResult = compileSource(oldSource, oldLabel);
275
+ const newResult = compileSource(newSource, newLabel);
276
+ printDiagnostics(oldResult.diagnostics);
277
+ printDiagnostics(newResult.diagnostics);
278
+ if (hasErrors(oldResult.diagnostics) || hasErrors(newResult.diagnostics)) {
279
+ console.error("not diffing: fix the errors above");
280
+ process.exit(1);
281
+ }
282
+ const diff = diffModels(oldResult.model, newResult.model);
283
+ console.log(formatModelDiff(diff));
284
+ if (exitCode && hasChanges(diff))
285
+ process.exit(1);
286
+ }
173
287
  function defaultOut(file, fmt) {
174
288
  const base = basename(file, extname(file));
175
289
  return `${base}.${fmt}`;
@@ -183,6 +297,18 @@ function warnMissingNotes(file, model) {
183
297
  }
184
298
  }
185
299
  }
300
+ /** Print only the open `issue "text"` diagnostics: slice, element, line, text — for CI. */
301
+ function printIssues(model) {
302
+ const withIssues = model.elements.filter((el) => el.issue);
303
+ if (withIssues.length === 0) {
304
+ console.log("no open issues");
305
+ return;
306
+ }
307
+ for (const el of withIssues) {
308
+ const slice = model.slices[el.sliceIndex];
309
+ console.log(` issue :${el.line} slice "${slice.name}" ${el.kind} "${el.name}": ${el.issue}`);
310
+ }
311
+ }
186
312
  function printDiagnostics(diags) {
187
313
  if (diags.length === 0)
188
314
  return;