@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.
- package/.claude/skills/event-modeling/SKILL.md +29 -10
- package/.claude/skills/event-modeling/reference/em-dsl.md +22 -5
- package/.claude/skills/event-modeling/reference/extract.md +5 -4
- package/.claude/skills/event-modeling/reference/methodology.md +11 -3
- package/.claude/skills/event-modeling/templates/model-readme.md +6 -4
- package/.claude/skills/event-modeling/templates/slice.md +11 -2
- package/.claude/skills/event-modeling/templates/state.md +16 -7
- package/README.md +1 -0
- package/dist/cli/diff-inputs.d.ts +44 -0
- package/dist/cli/diff-inputs.js +62 -0
- package/dist/cli/diff-inputs.js.map +1 -0
- package/dist/cli.js +133 -7
- package/dist/cli.js.map +1 -1
- package/dist/emit/json.d.ts +28 -0
- package/dist/emit/json.js +121 -0
- package/dist/emit/json.js.map +1 -0
- package/dist/model/diff.d.ts +52 -0
- package/dist/model/diff.js +351 -0
- package/dist/model/diff.js.map +1 -0
- package/dist/model/model.d.ts +2 -0
- package/dist/model/model.js +1 -0
- package/dist/model/model.js.map +1 -1
- package/dist/model/validate.js +74 -0
- package/dist/model/validate.js.map +1 -1
- package/dist/parser/ast.d.ts +2 -0
- package/dist/parser/parser.js +8 -0
- package/dist/parser/parser.js.map +1 -1
- package/dist/render/drawNotes.d.ts +8 -2
- package/dist/render/drawNotes.js +106 -42
- package/dist/render/drawNotes.js.map +1 -1
- package/dist/render/render.js +5 -4
- package/dist/render/render.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
134
|
-
|
|
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
|
|
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),
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
`
|
|
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
|
-
<!--
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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(
|
|
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
|
-
.
|
|
120
|
-
|
|
121
|
-
|
|
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 (
|
|
125
|
-
|
|
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;
|