@diagrammo/dgmo 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/advanced.cjs +133 -280
  2. package/dist/advanced.d.cts +9 -2
  3. package/dist/advanced.d.ts +9 -2
  4. package/dist/advanced.js +133 -280
  5. package/dist/auto.cjs +135 -269
  6. package/dist/auto.js +96 -96
  7. package/dist/auto.mjs +135 -269
  8. package/dist/cli.cjs +125 -125
  9. package/dist/index.cjs +132 -266
  10. package/dist/index.js +132 -266
  11. package/dist/internal.cjs +133 -280
  12. package/dist/internal.d.cts +9 -2
  13. package/dist/internal.d.ts +9 -2
  14. package/dist/internal.js +133 -280
  15. package/docs/language-reference.md +14 -18
  16. package/docs/migration-sequence-color-to-tags.md +1 -1
  17. package/gallery/fixtures/sequence-tags-protocols.dgmo +3 -3
  18. package/gallery/fixtures/sequence-tags.dgmo +3 -3
  19. package/gallery/fixtures/sequence.dgmo +4 -4
  20. package/package.json +7 -3
  21. package/src/auto/index.ts +2 -2
  22. package/src/boxes-and-lines/layout.ts +1 -2
  23. package/src/c4/parser.ts +1 -1
  24. package/src/class/parser.ts +1 -1
  25. package/src/cli.ts +2 -2
  26. package/src/completion.ts +1 -14
  27. package/src/cycle/parser.ts +1 -1
  28. package/src/d3.ts +2 -3
  29. package/src/diagnostics.ts +20 -0
  30. package/src/echarts.ts +2 -2
  31. package/src/editor/dgmo.grammar.d.ts +1 -1
  32. package/src/er/parser.ts +1 -1
  33. package/src/graph/flowchart-renderer.ts +3 -0
  34. package/src/infra/renderer.ts +1 -2
  35. package/src/journey-map/parser.ts +1 -1
  36. package/src/kanban/parser.ts +1 -1
  37. package/src/mindmap/parser.ts +2 -3
  38. package/src/org/parser.ts +1 -1
  39. package/src/pert/analyzer.ts +10 -10
  40. package/src/pert/layout.ts +1 -1
  41. package/src/pert/parser.ts +1 -1
  42. package/src/pyramid/parser.ts +1 -1
  43. package/src/raci/parser.ts +2 -2
  44. package/src/ring/parser.ts +1 -1
  45. package/src/sequence/parser.ts +66 -14
  46. package/src/sequence/participant-inference.ts +18 -181
  47. package/src/sequence/renderer.ts +47 -136
  48. package/src/sitemap/parser.ts +1 -1
  49. package/src/tech-radar/parser.ts +2 -2
  50. package/src/utils/extract-alias.ts +1 -1
  51. package/src/utils/inline-markdown.ts +1 -1
  52. package/src/utils/time-ticks.ts +1 -1
  53. package/src/wireframe/parser.ts +1 -1
@@ -263,7 +263,7 @@ quote.
263
263
 
264
264
  ### 2.3 Examples
265
265
 
266
- - `Auth Service is a service` — bare multi-word, no quoting needed
266
+ - `Auth Database is a database` — bare multi-word, no quoting needed
267
267
  - `"first name" varchar` — quote when name contains a reserved char (the `:` ER type separator)
268
268
  - `"Order | Items"` — quote the pipe
269
269
  - `class "Customer Service"` — bare multi-word also accepts in class
@@ -289,6 +289,7 @@ These are intentionally outside the universal rule:
289
289
  - `I_NAME_MERGED` (warning) — two source-distinct names normalize to the same key with different displayed forms
290
290
  - `E_NAME_RESERVED_CHAR` (error) — bare name contains a reserved char without quoting
291
291
  - `E_AKA_REMOVED` (error) — removed `aka` keyword used in sequence participant declaration
292
+ - `E_PARTICIPANT_TYPE_REMOVED` (error) — sequence `is a X` declaration used a removed type keyword (`service`, `frontend`, `networking`, `gateway`, `external`)
292
293
 
293
294
  ---
294
295
 
@@ -302,7 +303,7 @@ tag-shorthand and venn `alias` keyword forms with a uniform rule.
302
303
 
303
304
  ```
304
305
  sequence
305
- Alice is a service as a
306
+ Alice is an actor as a
306
307
  Bob is a database as b
307
308
  a -hello-> b
308
309
  b -ack-> a
@@ -376,43 +377,38 @@ Name is a <type> [position N]
376
377
  Name | key: value
377
378
  ```
378
379
 
379
- Types: `service`, `database`, `actor`, `queue`, `cache`, `gateway`, `external`, `networking`, `frontend`
380
+ Types: `actor`, `database`, `cache`, `queue` (plus default — the plain rectangle, used when `is a` is omitted).
381
+
382
+ Type names in `is a X` are **case-insensitive** (`is a Actor`, `is an ACTOR`, `is an actor` all parse the same). The keywords `service`, `frontend`, `networking`, `gateway`, and `external` were removed in 0.16.0 and now emit `E_PARTICIPANT_TYPE_REMOVED`; drop the override and the participant renders as the default rectangle.
383
+
384
+ A participant *named* with a removed-type keyword (e.g. `service -> User: hi` declares a participant named "service") remains valid. The trim affects only the `is a X` declaration syntax, not name resolution.
380
385
 
381
386
  **Inference rules** — the parser infers the type (and shape) from the participant name. Only use `is a` when the name does not match or you want to override:
382
387
 
383
388
  | Inferred Type | Shape | Name Patterns (examples) |
384
389
  |--------------|-------|--------------------------|
385
- | actor | Stick figure | `User`, `Customer`, `Client`, `Admin`, `Agent`, `Person`, `Buyer`, `Seller`, `Guest`, `Visitor`, `Operator`, Alice, Bob, Charlie, `*User`, `*Actor`, `*Analyst`, `*Staff` |
386
- | service | Rounded rectangle | `*Service`, `*Svc`, `*API`, Lambda, `*Function`, `*Fn`, `*Job`, Cron, Auth, SSO, OAuth, Stripe, Twilio, S3, Vercel, Docker, K8s, Vault, KMS, IAM, LLM, GPT, Claude, `*Pipeline`, `*Engine`, and many `-er`/`-or` suffixes (Scheduler, Handler, Processor, Worker, etc.) |
387
- | database | Cylinder (vertical) | `*DB`, `Database`, `*Store`, `Storage`, `*Repo`, `SQL`, Postgres, MySQL, Mongo, Dynamo, Aurora, Spanner, Supabase, Firebase, BigQuery, Redshift, Snowflake, Cassandra, Neo4j, ClickHouse, Elastic, OpenSearch, Pinecone, Weaviate, `*Table` |
390
+ | actor | Stick figure | `User`, `Customer`, `Admin`, `Agent`, `Person`, `Buyer`, `Seller`, `Guest`, `Visitor`, `Operator`, `Developer`, Alice, Bob, Charlie, Fan, Purchaser, Reviewer, `*User`, `*Actor`, `*Analyst`, `*Staff` |
391
+ | database | Cylinder (vertical) | `*DB`, `Database`, `Datastore`, `*Store`, `Storage`, `*Repo`, `Repository`, `SQL`, Postgres, MySQL, Mongo, Dynamo, Aurora, Spanner, Supabase, Firebase, BigQuery, Redshift, Snowflake, Cassandra, Neo4j, ClickHouse, Elastic, OpenSearch, Druid, Trino, Pinecone, Weaviate, Qdrant, Milvus, Presto, `*Table` |
388
392
  | cache | Dashed cylinder | `*Cache`, Redis, Memcache, KeyDB, Dragonfly, Hazelcast, Valkey |
389
- | queue | Horizontal cylinder (pipe) | `*Queue`, `*MQ`, SQS, Kafka, RabbitMQ, `EventBus`, `*Bus`, `Topic`, `*Stream`, SNS, PubSub, NATS, Pulsar, Kinesis, EventBridge, Celery, Sidekiq, `*Channel`, `*Broker` |
390
- | networking | Hexagon | `*Router`, `*Balancer`, `Gateway`, `Proxy`, `LB`, `CDN`, `Firewall`, `WAF`, `DNS`, `Ingress`, Nginx, Traefik, Envoy, Istio, Kong, Akamai, Cloudflare, `*Mesh` |
391
- | frontend | Monitor (screen + stand) | `*App`, `Application`, `Mobile`, iOS, Android, `Web`, `Browser`, `Frontend`, `*UI`, `Dashboard`, `*CLI`, `Terminal`, React, Vue, Angular, Svelte, NextJS, Electron, Tauri, `*Widget`, `Portal`, `*Console`, SPA, PWA |
392
- | gateway | Rectangle (same as default) | matched via `is a gateway` only |
393
- | external | Dashed rectangle | `External`, `*Ext`, `ThirdParty`, `*3P`, `Vendor`, `Webhook`, `Upstream`, `Downstream`, `Callback`, AWS, GCP, Azure |
393
+ | queue | Horizontal cylinder (pipe) | `*Queue`, `*MQ`, SQS, Kafka, RabbitMQ, `EventBus`, `MessageBus`, `*Bus`, `Topic`, `*Stream`, SNS, PubSub, `*Broker`, NATS, Pulsar, Kinesis, EventBridge, CloudEvents, Celery, Sidekiq, EventHub, `*Channel` |
394
394
  | default | Rectangle | Everything else (no `is a` needed) |
395
395
 
396
396
  **Inference handles it (skip `is a`):**
397
397
  ```
398
- AuthService // service (matches *Service)
399
398
  PostgresDB // database (matches *DB)
400
399
  Redis // cache (exact match)
401
400
  User // actor (exact match)
402
401
  Kafka // queue (exact match)
403
- API Gateway // networking (matches Gateway)
404
- WebApp // frontend (matches *App)
405
- Stripe // service (exact match)
406
402
  ```
407
403
 
408
404
  **Inference would miss (use `is a`):**
409
405
  ```
410
- Payments is a service // "Payments" matches no rule
411
- Vault is a database // "Vault" infers as service, but you want database
406
+ Vault is a database // "Vault" matches no rule, but you want database
412
407
  Notifications is a queue // "Notifications" matches no rule
413
- Analytics is a frontend // "Analytics" matches no rule
414
408
  ```
415
409
 
410
+ Names that previously inferred to a removed type — `AuthService`, `WebApp`, `Cloudflare`, `API Gateway`, `Stripe`, `Webhook` — now fall through to default (plain rectangle). That is the intended outcome of the trim: the visual differentiation is gone because the underlying distinction did not pull its weight.
411
+
416
412
  ### 2.2 Participant Groups
417
413
 
418
414
  ```
@@ -27,7 +27,7 @@ tag: Role
27
27
  Gateway(blue)
28
28
  Storage(green)
29
29
 
30
- API is a service | role: Gateway
30
+ API | role: Gateway
31
31
  DB is a database | role: Storage
32
32
  ```
33
33
 
@@ -14,9 +14,9 @@ tag Owner as o
14
14
  Data blue
15
15
 
16
16
  Buyer is an actor
17
- CheckoutSvc is a service | o: Checkout
18
- InventorySvc is a service | o: Fulfillment
19
- PaymentSvc is a service | o: Payments
17
+ CheckoutSvc | o: Checkout
18
+ InventorySvc | o: Fulfillment
19
+ PaymentSvc | o: Payments
20
20
  OrderDB is a database | o: Data
21
21
  EventBus is a queue
22
22
 
@@ -13,12 +13,12 @@ tag Team as t
13
13
  Security red
14
14
 
15
15
  Mobile is an actor
16
- Gateway is a gateway | t: Platform
16
+ Gateway | t: Platform
17
17
  Redis is a cache | c: Caching, t: Platform
18
18
 
19
19
  [Backend] | t: Product
20
- UserAPI is a service
21
- OrderAPI is a service
20
+ UserAPI
21
+ OrderAPI
22
22
  DB is a database
23
23
 
24
24
  == Authentication ==
@@ -1,10 +1,10 @@
1
1
  sequence E-Commerce Checkout
2
2
 
3
3
  User is an actor
4
- App is a frontend
5
- API is a gateway
6
- Orders is a service
7
- Payments is an external
4
+ App
5
+ API
6
+ Orders
7
+ Payments
8
8
  DB is a database
9
9
 
10
10
  [Place Order]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -117,6 +117,7 @@
117
117
  "prebuild": "rm -rf dist && pnpm codegen",
118
118
  "build": "tsup",
119
119
  "typecheck": "tsc --noEmit",
120
+ "typecheck:strict": "tsc --noEmit -p tsconfig.strict.json",
120
121
  "dev": "tsup --watch",
121
122
  "pretest": "pnpm codegen",
122
123
  "test": "vitest run --coverage",
@@ -130,12 +131,14 @@
130
131
  "lint:fix": "eslint . --fix",
131
132
  "format": "prettier --write src/",
132
133
  "format:check": "prettier --check src/",
134
+ "check:api": "pnpm build && bash scripts/check-api.sh check",
135
+ "check:api:update": "pnpm build && bash scripts/check-api.sh update",
133
136
  "check:duplication": "jscpd ./src",
134
137
  "check:deadcode": "knip",
135
138
  "check:spelling": "cspell \"src/**/*.ts\" \"tests/**/*.ts\"",
136
- "check:all": "pnpm check:deadcode && pnpm check:spelling && pnpm check:duplication && pnpm check:circular && pnpm check:deps && pnpm check:security && pnpm build && pnpm check:publish && pnpm check:types",
139
+ "check:all": "pnpm check:deadcode && pnpm check:spelling && pnpm check:duplication && pnpm check:circular && pnpm check:deps && pnpm check:security && pnpm build && bash scripts/check-api.sh check && pnpm check:publish && pnpm check:types",
137
140
  "check:circular": "madge --circular --extensions ts src/ --json | node -e \"const c=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const n=c.length; if(n>4){console.error('New circular deps found ('+n+' > 4 known type-only cycles)');process.exit(1)}else if(n>0){console.log(n+' known type-only/dynamic cycles (safe)')}else{console.log('No circular dependencies')}\"",
138
- "check:deps": "depcheck --ignores='@codemirror/language,@lezer/*,husky,lint-staged,tsup,axe-core'",
141
+ "check:deps": "depcheck --ignores='@codemirror/language,@lezer/*,husky,lint-staged,tsup,axe-core,type-coverage'",
139
142
  "check:security": "pnpm audit --prod",
140
143
  "check:publish": "publint",
141
144
  "check:size": "pnpm build && du -sh dist/ && echo '---' && ls -lh dist/*.js dist/*.cjs",
@@ -185,6 +188,7 @@
185
188
  "prettier": "^3.8.2",
186
189
  "publint": "^0.3.18",
187
190
  "tsup": "^8.5.1",
191
+ "type-coverage": "^2.29.7",
188
192
  "typescript": "^6.0.2",
189
193
  "typescript-eslint": "^8.58.1",
190
194
  "vitest": "^4.1.4"
package/src/auto/index.ts CHANGED
@@ -279,7 +279,7 @@ export function resolveTheme(
279
279
  function ensureStyles(): void {
280
280
  if (typeof document === 'undefined') return;
281
281
  const html = document.documentElement;
282
- if (html && html.dataset && html.dataset[STYLE_FLAG] === '1') return;
282
+ if (html?.dataset?.[STYLE_FLAG] === '1') return;
283
283
 
284
284
  // If a <link rel="stylesheet"> for our css is already linked, skip inline.
285
285
  const linked = document.querySelector(
@@ -509,7 +509,7 @@ function determineReplaceTarget(matched: Element): Element {
509
509
  // If matched is a <code> whose only child of <pre> is itself, replace <pre>.
510
510
  if (matched.tagName === 'CODE') {
511
511
  const parent = matched.parentElement;
512
- if (parent && parent.tagName === 'PRE') {
512
+ if (parent?.tagName === 'PRE') {
513
513
  const meaningfulChildren = Array.from(parent.childNodes).filter(
514
514
  (n) =>
515
515
  n.nodeType === 1 ||
@@ -636,8 +636,7 @@ export async function layoutBoxesAndLines(
636
636
  const edge = parsed.edges[i];
637
637
  if (edgeParallelCounts[i] === 0) continue;
638
638
  const elkEdge = edgeById.get(`e${i}`);
639
- if (!elkEdge || !elkEdge.sections || elkEdge.sections.length === 0)
640
- continue;
639
+ if (!elkEdge?.sections || elkEdge.sections.length === 0) continue;
641
640
  const container = elkEdge.container ?? 'root';
642
641
  const off = containerAbs.get(container) ?? { x: 0, y: 0 };
643
642
  const s = elkEdge.sections[0];
package/src/c4/parser.ts CHANGED
@@ -232,7 +232,7 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
232
232
  return result;
233
233
  };
234
234
 
235
- if (!content || !content.trim()) {
235
+ if (!content?.trim()) {
236
236
  return fail(0, 'No content provided');
237
237
  }
238
238
 
@@ -259,7 +259,7 @@ export function parseClassDiagram(
259
259
  // First line: bare chart type + optional title (new syntax)
260
260
  if (!contentStarted && indent === 0 && i === 0) {
261
261
  const firstLine = parseFirstLine(trimmed);
262
- if (firstLine && firstLine.chartType === 'class') {
262
+ if (firstLine?.chartType === 'class') {
263
263
  if (firstLine.title) {
264
264
  result.title = firstLine.title;
265
265
  result.titleLineNumber = lineNumber;
package/src/cli.ts CHANGED
@@ -190,8 +190,8 @@ title: Auth Flow
190
190
 
191
191
  // Participants auto-inferred, or declare explicitly:
192
192
  User is an actor
193
- API is a service
194
193
  DB is a database
194
+ Cache is a cache
195
195
 
196
196
  User -Login-> API
197
197
  API -Find user-> DB
@@ -1157,7 +1157,7 @@ async function main(): Promise<void> {
1157
1157
  }
1158
1158
 
1159
1159
  const existingDgmo = config.mcpServers?.['dgmo'];
1160
- if (existingDgmo && existingDgmo.command === 'dgmo-mcp') {
1160
+ if (existingDgmo?.command === 'dgmo-mcp') {
1161
1161
  console.log(`✓ dgmo MCP server already configured in ${configPath}`);
1162
1162
  } else {
1163
1163
  if (existingDgmo) {
package/src/completion.ts CHANGED
@@ -595,20 +595,7 @@ export const CHART_TYPES: ReadonlyArray<{ name: string; description: string }> =
595
595
  * C4_IS_A_RE).
596
596
  */
597
597
  export const ENTITY_TYPES = new Map<string, string[]>([
598
- [
599
- 'sequence',
600
- [
601
- 'service',
602
- 'database',
603
- 'actor',
604
- 'queue',
605
- 'cache',
606
- 'gateway',
607
- 'external',
608
- 'networking',
609
- 'frontend',
610
- ],
611
- ],
598
+ ['sequence', ['actor', 'database', 'queue', 'cache']],
612
599
  [
613
600
  'c4',
614
601
  ['person', 'system', 'container', 'component', 'external', 'database'],
@@ -103,7 +103,7 @@ export function parseCycle(content: string): ParsedCycle {
103
103
  // ── First line: chart type declaration ──
104
104
  if (!headerParsed) {
105
105
  const firstLineResult = parseFirstLine(trimmed);
106
- if (firstLineResult && firstLineResult.chartType === 'cycle') {
106
+ if (firstLineResult?.chartType === 'cycle') {
107
107
  result.title = firstLineResult.title ?? '';
108
108
  result.titleLineNumber = lineNum;
109
109
  headerParsed = true;
package/src/d3.ts CHANGED
@@ -496,7 +496,7 @@ export function parseVisualization(
496
496
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
497
497
  };
498
498
 
499
- if (!content || !content.trim()) {
499
+ if (!content?.trim()) {
500
500
  return fail(0, 'Empty content');
501
501
  }
502
502
 
@@ -4107,8 +4107,7 @@ function renderTimelineTagLegendOverlay(
4107
4107
  groupEl.attr('data-tag-group', groupKey);
4108
4108
  if (isActive && !viewMode) {
4109
4109
  const isSwimActive =
4110
- currentSwimlaneGroup != null &&
4111
- currentSwimlaneGroup.toLowerCase() === groupKey;
4110
+ currentSwimlaneGroup?.toLowerCase() === groupKey;
4112
4111
  const pillWidth =
4113
4112
  measureLegendText(groupName, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
4114
4113
  const pillXOff = LG_CAPSULE_PAD;
@@ -121,6 +121,14 @@ export const NAME_DIAGNOSTIC_CODES = {
121
121
  * unnecessary; the diagnostic directs users to the new syntax.
122
122
  */
123
123
  AKA_REMOVED: 'E_AKA_REMOVED',
124
+ /**
125
+ * Error: a removed sequence participant-type keyword was used in
126
+ * an `is a X` declaration. The 0.16.0 trim retained only
127
+ * `actor`/`database`/`cache`/`queue`; `service`/`frontend`/
128
+ * `networking`/`gateway`/`external` no longer carry semantic
129
+ * weight and emit this error so users drop the override.
130
+ */
131
+ PARTICIPANT_TYPE_REMOVED: 'E_PARTICIPANT_TYPE_REMOVED',
124
132
  } as const;
125
133
 
126
134
  /**
@@ -152,6 +160,18 @@ export function akaRemovedMessage(): string {
152
160
  return `'aka' is no longer supported — use the participant name directly`;
153
161
  }
154
162
 
163
+ /**
164
+ * Canonical message for `E_PARTICIPANT_TYPE_REMOVED`. Emitted when a
165
+ * sequence participant declaration uses a removed type keyword
166
+ * (`service`, `frontend`, `networking`, `gateway`, `external`).
167
+ */
168
+ export function participantTypeRemovedMessage(type: string): string {
169
+ return (
170
+ `'${type}' is no longer supported — drop 'is a ${type}'; ` +
171
+ `the participant renders as the default rectangle`
172
+ );
173
+ }
174
+
155
175
  // ============================================================
156
176
  // Universal Alias Syntax diagnostic codes (TD-18)
157
177
  // ============================================================
package/src/echarts.ts CHANGED
@@ -467,7 +467,7 @@ export function parseExtendedChart(
467
467
  )
468
468
  : trimmed;
469
469
  const dataRow = parseDataRowValues(strippedLine);
470
- if (dataRow && dataRow.values.length === 1) {
470
+ if (dataRow?.values.length === 1) {
471
471
  const source = sankeyStack.at(-1)!.name;
472
472
  const linkColor = valColorMatch?.[2]
473
473
  ? resolveColorWithDiagnostic(
@@ -745,7 +745,7 @@ export function parseExtendedChart(
745
745
 
746
746
  // Funnel / generic data point: "Label value"
747
747
  const dataRow = parseDataRowValues(trimmed);
748
- if (dataRow && dataRow.values.length === 1) {
748
+ if (dataRow?.values.length === 1) {
749
749
  const { label: rawLabel, color: pointColor } = extractColor(
750
750
  dataRow.label,
751
751
  palette
@@ -1,2 +1,2 @@
1
- import { LRParser } from '@lezer/lr';
1
+ import type { LRParser } from '@lezer/lr';
2
2
  export declare const parser: LRParser;
package/src/er/parser.ts CHANGED
@@ -308,7 +308,7 @@ export function parseERDiagram(
308
308
  // First line: chart type + optional title
309
309
  if (!firstLineParsed && indent === 0) {
310
310
  const firstLineResult = parseFirstLine(trimmed);
311
- if (firstLineResult && firstLineResult.chartType === 'er') {
311
+ if (firstLineResult?.chartType === 'er') {
312
312
  firstLineParsed = true;
313
313
  if (firstLineResult.title) {
314
314
  result.title = firstLineResult.title;
@@ -373,6 +373,9 @@ function renderNodeShape(
373
373
  case 'document':
374
374
  renderDocument(g, node, palette, isDark, colorOff, solid);
375
375
  break;
376
+ default:
377
+ // state/pseudostate are routed through state-renderer; ignored here.
378
+ break;
376
379
  }
377
380
  }
378
381
 
@@ -1733,8 +1733,7 @@ function renderNodes(
1733
1733
  }
1734
1734
 
1735
1735
  // Role badge dots — only shown when Capabilities legend is expanded
1736
- const showDots =
1737
- activeGroup != null && activeGroup.toLowerCase() === 'capabilities';
1736
+ const showDots = activeGroup?.toLowerCase() === 'capabilities';
1738
1737
  const roles = showDots && !node.isEdge ? inferRoles(node.properties) : [];
1739
1738
  if (roles.length > 0) {
1740
1739
  // Move dots up above the collapse bar for collapsed groups
@@ -64,7 +64,7 @@ export function parseJourneyMap(
64
64
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
65
65
  };
66
66
 
67
- if (!content || !content.trim()) {
67
+ if (!content?.trim()) {
68
68
  return fail(0, 'No content provided');
69
69
  }
70
70
 
@@ -71,7 +71,7 @@ export function parseKanban(
71
71
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
72
72
  };
73
73
 
74
- if (!content || !content.trim()) {
74
+ if (!content?.trim()) {
75
75
  return fail(0, 'No content provided');
76
76
  }
77
77
 
@@ -62,7 +62,7 @@ export function parseMindmap(
62
62
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
63
63
  };
64
64
 
65
- if (!content || !content.trim()) {
65
+ if (!content?.trim()) {
66
66
  return fail(0, 'No content provided');
67
67
  }
68
68
 
@@ -278,8 +278,7 @@ export function parseMindmap(
278
278
  result.diagnostics.push(diag);
279
279
  result.error = formatDgmoError(diag);
280
280
  } else if (
281
- titleRoot &&
282
- titleRoot.children.length === 0 &&
281
+ titleRoot?.children.length === 0 &&
283
282
  result.roots.length === 1 &&
284
283
  !result.error
285
284
  ) {
package/src/org/parser.ts CHANGED
@@ -115,7 +115,7 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
115
115
  result.diagnostics.push(makeDgmoError(line, message, 'warning'));
116
116
  };
117
117
 
118
- if (!content || !content.trim()) {
118
+ if (!content?.trim()) {
119
119
  return fail(0, 'No content provided');
120
120
  }
121
121
 
@@ -180,7 +180,7 @@ export function analyzePert(parsed: ParsedPert): ResolvedPert {
180
180
  for (const e of edges) {
181
181
  if (!e.lag || e.lag.amount >= 0) continue;
182
182
  const src = activities.find((a) => a.id === e.source);
183
- if (!src || !src.duration) continue;
183
+ if (!src?.duration) continue;
184
184
  const leadDays = -toDays(e.lag, sprintDays);
185
185
  const srcDurDays = toDays(src.duration.m, sprintDays);
186
186
  if (e.type === 'FS' && leadDays > srcDurDays) {
@@ -923,7 +923,7 @@ export function buildSummary(input: BuildSummaryInput): CaptionRow[] | null {
923
923
  // Expected duration AND each percentile latest-safe start so the
924
924
  // caption shape stays parallel to the feasible case (one top row +
925
925
  // three percentile sub-rows).
926
- if (anchor && anchor.kind === 'backward') {
926
+ if (anchor?.kind === 'backward') {
927
927
  return [
928
928
  { text: 'Expected duration: ?', level: 0 },
929
929
  { text: 'P50 latest-safe start: ?', level: 0 },
@@ -958,13 +958,13 @@ export function buildSummary(input: BuildSummaryInput): CaptionRow[] | null {
958
958
  const sigmaParen = showMcDetail
959
959
  ? ` (± ${roundForCaption(projectSigma!)} ${pluralizeUnit(projectSigma!, unit)})`
960
960
  : '';
961
- if (anchor && anchor.kind === 'forward') {
961
+ if (anchor?.kind === 'forward') {
962
962
  const projectMuDays = projectMu * unitToDays(unit);
963
963
  rows.push({
964
964
  text: `Expected finish: ${addCalendarDays(anchor.date, projectMuDays)}${sigmaParen}.`,
965
965
  level: 0,
966
966
  });
967
- } else if (anchor && anchor.kind === 'backward') {
967
+ } else if (anchor?.kind === 'backward') {
968
968
  const projectMuDays = projectMu * unitToDays(unit);
969
969
  rows.push({
970
970
  text: `Expected start: ${addCalendarDays(anchor.date, -projectMuDays)}${sigmaParen}.`,
@@ -990,13 +990,13 @@ export function buildSummary(input: BuildSummaryInput): CaptionRow[] | null {
990
990
  { pct: 80, days: monteCarloResult!.p80 },
991
991
  { pct: 95, days: monteCarloResult!.p95 },
992
992
  ];
993
- if (anchor && anchor.kind === 'forward') {
993
+ if (anchor?.kind === 'forward') {
994
994
  for (const { pct, days } of percentiles) {
995
995
  const offsetDays = roundConservative(days, 'forward');
996
996
  const date = addCalendarDays(anchor.date, offsetDays);
997
997
  rows.push({ text: `P${pct} finish: ${date}.`, level: 1 });
998
998
  }
999
- } else if (anchor && anchor.kind === 'backward') {
999
+ } else if (anchor?.kind === 'backward') {
1000
1000
  for (const { pct, days } of percentiles) {
1001
1001
  const offsetDays = roundConservative(days, 'backward');
1002
1002
  const date = addCalendarDays(anchor.date, -offsetDays);
@@ -1074,10 +1074,10 @@ export function buildProjectSubtitle(input: {
1074
1074
 
1075
1075
  if (projectMu === null) {
1076
1076
  // Anchored + TBD: keep the framing prefix, mark the math as ?.
1077
- if (anchor && anchor.kind === 'forward') {
1077
+ if (anchor?.kind === 'forward') {
1078
1078
  return `Expected finish: ? · ≈ ? ${pluralizeUnit(2, unit)} of work`;
1079
1079
  }
1080
- if (anchor && anchor.kind === 'backward') {
1080
+ if (anchor?.kind === 'backward') {
1081
1081
  return `Expected start: ? · ≈ ? ${pluralizeUnit(2, unit)} lead time`;
1082
1082
  }
1083
1083
  // Unanchored + TBD: surface that the total is unknown. The per-node
@@ -1087,11 +1087,11 @@ export function buildProjectSubtitle(input: {
1087
1087
 
1088
1088
  const muStr = `${roundForCaption(projectMu)} ${pluralizeUnit(projectMu, unit)}`;
1089
1089
 
1090
- if (anchor && anchor.kind === 'forward') {
1090
+ if (anchor?.kind === 'forward') {
1091
1091
  const projectMuDays = projectMu * unitToDays(unit);
1092
1092
  return `Expected finish: ${addCalendarDays(anchor.date, projectMuDays)} · ≈ ${muStr} of work${sigmaParen}`;
1093
1093
  }
1094
- if (anchor && anchor.kind === 'backward') {
1094
+ if (anchor?.kind === 'backward') {
1095
1095
  const projectMuDays = projectMu * unitToDays(unit);
1096
1096
  return `Expected start: ${addCalendarDays(anchor.date, -projectMuDays)} · ≈ ${muStr} lead time${sigmaParen}`;
1097
1097
  }
@@ -208,7 +208,7 @@ function nodeDimensions(
208
208
  sizing: NodeSizing,
209
209
  overrides?: LayoutOverrides
210
210
  ): { width: number; height: number } {
211
- if (overrides && overrides[id]) {
211
+ if (overrides?.[id]) {
212
212
  return { width: overrides[id].width, height: overrides[id].height };
213
213
  }
214
214
  const r = resolved.activities.find((a) => a.activity.id === id);
@@ -823,7 +823,7 @@ export function parsePert(
823
823
  const head = trimmed.slice(0, firstSpace).toLowerCase();
824
824
  const value = trimmed.slice(firstSpace + 1).trim();
825
825
  const hint = NEAR_DIRECTIVE_HINTS.find((h) => h.stem === head);
826
- if (hint && hint.matches.test(value)) {
826
+ if (hint?.matches.test(value)) {
827
827
  error(
828
828
  lineNumber,
829
829
  `Unknown directive '${head}'. Did you mean '${hint.canonical}'?`,
@@ -89,7 +89,7 @@ export function parsePyramid(content: string): ParsedPyramid {
89
89
  // ── First line: chart type declaration ──
90
90
  if (!headerParsed) {
91
91
  const firstLineResult = parseFirstLine(trimmed);
92
- if (firstLineResult && firstLineResult.chartType === 'pyramid') {
92
+ if (firstLineResult?.chartType === 'pyramid') {
93
93
  result.title = firstLineResult.title ?? '';
94
94
  result.titleLineNumber = lineNum;
95
95
  headerParsed = true;
@@ -178,7 +178,7 @@ export function parseRaci(
178
178
  result.diagnostics.push(makeDgmoError(line, message, 'error', code));
179
179
  };
180
180
 
181
- if (!content || !content.trim()) {
181
+ if (!content?.trim()) {
182
182
  return fail(0, 'No content provided');
183
183
  }
184
184
 
@@ -624,7 +624,7 @@ export function parseRaci(
624
624
  // detect by whether it was declared before `bodyStarted`.
625
625
  // We piggyback the `declaredLine` we recorded.
626
626
  const entry = roleStore.get(roleId);
627
- if (entry && entry.declaredLine === lineNumber) {
627
+ if (entry?.declaredLine === lineNumber) {
628
628
  const candidates = result.roleDisplayNames.filter(
629
629
  (n) => n !== entry.displayName
630
630
  );
@@ -74,7 +74,7 @@ export function parseRing(content: string): ParsedRing {
74
74
  // ── First line: chart type declaration ──
75
75
  if (!headerParsed) {
76
76
  const firstLineResult = parseFirstLine(trimmed);
77
- if (firstLineResult && firstLineResult.chartType === 'ring') {
77
+ if (firstLineResult?.chartType === 'ring') {
78
78
  result.title = firstLineResult.title ?? '';
79
79
  result.titleLineNumber = lineNum;
80
80
  headerParsed = true;