@hydranium/cli 1.0.0-next.29 → 1.0.0-next.293

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 (222) hide show
  1. package/README.md +75 -204
  2. package/bin/hydranium-cli.js +13 -0
  3. package/heap-analysis/heap/core/load.mjs +2 -2
  4. package/lib/cli-version.js.map +1 -1
  5. package/lib/cli.js +7 -2
  6. package/lib/cli.js.map +1 -1
  7. package/lib/commands/analyze-heap-args.js.map +1 -1
  8. package/lib/commands/analyze-heap.d.ts +3 -0
  9. package/lib/commands/analyze-heap.d.ts.map +1 -1
  10. package/lib/commands/analyze-heap.js +2 -1
  11. package/lib/commands/analyze-heap.js.map +1 -1
  12. package/lib/commands/ast-ground-truth-args.d.ts +3 -2
  13. package/lib/commands/ast-ground-truth-args.d.ts.map +1 -1
  14. package/lib/commands/ast-ground-truth-args.js +11 -9
  15. package/lib/commands/ast-ground-truth-args.js.map +1 -1
  16. package/lib/commands/ast-ground-truth-driver.js +2 -1
  17. package/lib/commands/ast-ground-truth-driver.js.map +1 -1
  18. package/lib/commands/ast-ground-truth.js +1 -1
  19. package/lib/commands/ast-ground-truth.js.map +1 -1
  20. package/lib/commands/client-session.d.ts +17 -0
  21. package/lib/commands/client-session.d.ts.map +1 -0
  22. package/lib/commands/client-session.js +27 -0
  23. package/lib/commands/client-session.js.map +1 -0
  24. package/lib/commands/create-args.d.ts +17 -0
  25. package/lib/commands/create-args.d.ts.map +1 -0
  26. package/lib/commands/create-args.js +53 -0
  27. package/lib/commands/create-args.js.map +1 -0
  28. package/lib/commands/create.d.ts +38 -0
  29. package/lib/commands/create.d.ts.map +1 -0
  30. package/lib/commands/create.js +43 -0
  31. package/lib/commands/create.js.map +1 -0
  32. package/lib/commands/delete-args.d.ts +17 -0
  33. package/lib/commands/delete-args.d.ts.map +1 -0
  34. package/lib/commands/delete-args.js +49 -0
  35. package/lib/commands/delete-args.js.map +1 -0
  36. package/lib/commands/delete.d.ts +30 -0
  37. package/lib/commands/delete.d.ts.map +1 -0
  38. package/lib/commands/delete.js +32 -0
  39. package/lib/commands/delete.js.map +1 -0
  40. package/lib/commands/generate-ast-builder.d.ts +49 -0
  41. package/lib/commands/generate-ast-builder.d.ts.map +1 -0
  42. package/lib/commands/generate-ast-builder.js +200 -0
  43. package/lib/commands/generate-ast-builder.js.map +1 -0
  44. package/lib/commands/generate-transfer-model-args.d.ts.map +1 -1
  45. package/lib/commands/generate-transfer-model-args.js +11 -1
  46. package/lib/commands/generate-transfer-model-args.js.map +1 -1
  47. package/lib/commands/generate-transfer-model-config.d.ts.map +1 -1
  48. package/lib/commands/generate-transfer-model-config.js +6 -3
  49. package/lib/commands/generate-transfer-model-config.js.map +1 -1
  50. package/lib/commands/generate-transfer-model.d.ts +9 -0
  51. package/lib/commands/generate-transfer-model.d.ts.map +1 -1
  52. package/lib/commands/generate-transfer-model.js +18 -4
  53. package/lib/commands/generate-transfer-model.js.map +1 -1
  54. package/lib/commands/harness-args.d.ts +44 -42
  55. package/lib/commands/harness-args.d.ts.map +1 -1
  56. package/lib/commands/harness-args.js +109 -27
  57. package/lib/commands/harness-args.js.map +1 -1
  58. package/lib/commands/headless-harness.d.ts +37 -20
  59. package/lib/commands/headless-harness.d.ts.map +1 -1
  60. package/lib/commands/headless-harness.js +68 -17
  61. package/lib/commands/headless-harness.js.map +1 -1
  62. package/lib/commands/init-args.js +6 -6
  63. package/lib/commands/init-args.js.map +1 -1
  64. package/lib/commands/init-prompt.d.ts.map +1 -1
  65. package/lib/commands/init-templates.d.ts +8 -10
  66. package/lib/commands/init-templates.d.ts.map +1 -1
  67. package/lib/commands/init-templates.js +261 -167
  68. package/lib/commands/init-templates.js.map +1 -1
  69. package/lib/commands/init-wizard.js +4 -4
  70. package/lib/commands/init-wizard.js.map +1 -1
  71. package/lib/commands/init-workspace.d.ts +13 -3
  72. package/lib/commands/init-workspace.d.ts.map +1 -1
  73. package/lib/commands/init-workspace.js +21 -6
  74. package/lib/commands/init-workspace.js.map +1 -1
  75. package/lib/commands/init.d.ts +16 -10
  76. package/lib/commands/init.d.ts.map +1 -1
  77. package/lib/commands/init.js +17 -15
  78. package/lib/commands/init.js.map +1 -1
  79. package/lib/commands/lint-grammar-args.d.ts +3 -2
  80. package/lib/commands/lint-grammar-args.d.ts.map +1 -1
  81. package/lib/commands/lint-grammar-args.js +13 -10
  82. package/lib/commands/lint-grammar-args.js.map +1 -1
  83. package/lib/commands/lint-grammar-driver.js +2 -1
  84. package/lib/commands/lint-grammar-driver.js.map +1 -1
  85. package/lib/commands/lint-grammar-report.js.map +1 -1
  86. package/lib/commands/lint-grammar.js +1 -1
  87. package/lib/commands/lint-grammar.js.map +1 -1
  88. package/lib/commands/measure-memory-args.d.ts +3 -2
  89. package/lib/commands/measure-memory-args.d.ts.map +1 -1
  90. package/lib/commands/measure-memory-args.js +22 -11
  91. package/lib/commands/measure-memory-args.js.map +1 -1
  92. package/lib/commands/measure-memory-driver.js +5 -3
  93. package/lib/commands/measure-memory-driver.js.map +1 -1
  94. package/lib/commands/measure-memory.js +1 -1
  95. package/lib/commands/measure-memory.js.map +1 -1
  96. package/lib/commands/model-docs-args.d.ts +4 -3
  97. package/lib/commands/model-docs-args.d.ts.map +1 -1
  98. package/lib/commands/model-docs-args.js +12 -10
  99. package/lib/commands/model-docs-args.js.map +1 -1
  100. package/lib/commands/model-docs-driver.js +2 -1
  101. package/lib/commands/model-docs-driver.js.map +1 -1
  102. package/lib/commands/model-docs.js +1 -1
  103. package/lib/commands/model-docs.js.map +1 -1
  104. package/lib/commands/projects-args.d.ts.map +1 -1
  105. package/lib/commands/projects-args.js +5 -5
  106. package/lib/commands/projects-args.js.map +1 -1
  107. package/lib/commands/projects.d.ts +3 -4
  108. package/lib/commands/projects.d.ts.map +1 -1
  109. package/lib/commands/projects.js +2 -2
  110. package/lib/commands/projects.js.map +1 -1
  111. package/lib/commands/query-args.d.ts.map +1 -1
  112. package/lib/commands/query-args.js +5 -5
  113. package/lib/commands/query-args.js.map +1 -1
  114. package/lib/commands/query.d.ts +3 -4
  115. package/lib/commands/query.d.ts.map +1 -1
  116. package/lib/commands/query.js +2 -2
  117. package/lib/commands/query.js.map +1 -1
  118. package/lib/commands/reflect-args.d.ts +3 -2
  119. package/lib/commands/reflect-args.d.ts.map +1 -1
  120. package/lib/commands/reflect-args.js +13 -10
  121. package/lib/commands/reflect-args.js.map +1 -1
  122. package/lib/commands/reflect-driver.js +2 -1
  123. package/lib/commands/reflect-driver.js.map +1 -1
  124. package/lib/commands/reflect-report.js.map +1 -1
  125. package/lib/commands/reflect.js +1 -1
  126. package/lib/commands/reflect.js.map +1 -1
  127. package/lib/commands/save-args.d.ts.map +1 -1
  128. package/lib/commands/save-args.js +7 -4
  129. package/lib/commands/save-args.js.map +1 -1
  130. package/lib/commands/save.d.ts +8 -4
  131. package/lib/commands/save.d.ts.map +1 -1
  132. package/lib/commands/save.js +12 -23
  133. package/lib/commands/save.js.map +1 -1
  134. package/lib/commands/validate-args.d.ts +3 -2
  135. package/lib/commands/validate-args.d.ts.map +1 -1
  136. package/lib/commands/validate-args.js +13 -10
  137. package/lib/commands/validate-args.js.map +1 -1
  138. package/lib/commands/validate-driver.js +2 -1
  139. package/lib/commands/validate-driver.js.map +1 -1
  140. package/lib/commands/validate-report.js.map +1 -1
  141. package/lib/commands/validate.js +1 -1
  142. package/lib/commands/validate.js.map +1 -1
  143. package/lib/commands/watch-args.d.ts.map +1 -1
  144. package/lib/commands/watch-args.js +6 -4
  145. package/lib/commands/watch-args.js.map +1 -1
  146. package/lib/commands/watch.d.ts +3 -4
  147. package/lib/commands/watch.d.ts.map +1 -1
  148. package/lib/commands/watch.js +20 -2
  149. package/lib/commands/watch.js.map +1 -1
  150. package/lib/driver-heap.d.ts +52 -0
  151. package/lib/driver-heap.d.ts.map +1 -0
  152. package/lib/driver-heap.js +51 -0
  153. package/lib/driver-heap.js.map +1 -0
  154. package/lib/index.d.ts +3 -0
  155. package/lib/index.d.ts.map +1 -1
  156. package/lib/index.js +3 -0
  157. package/lib/index.js.map +1 -1
  158. package/lib/log-level.d.ts +21 -11
  159. package/lib/log-level.d.ts.map +1 -1
  160. package/lib/log-level.js +25 -14
  161. package/lib/log-level.js.map +1 -1
  162. package/lib/spawn-data-server.d.ts +9 -4
  163. package/lib/spawn-data-server.d.ts.map +1 -1
  164. package/lib/spawn-data-server.js +5 -4
  165. package/lib/spawn-data-server.js.map +1 -1
  166. package/lib/stderr-connection-logger.d.ts +12 -0
  167. package/lib/stderr-connection-logger.d.ts.map +1 -0
  168. package/lib/stderr-connection-logger.js +16 -0
  169. package/lib/stderr-connection-logger.js.map +1 -0
  170. package/lib/testing/echo-server.js +9 -1
  171. package/lib/testing/echo-server.js.map +1 -1
  172. package/package.json +50 -39
  173. package/src/cli.ts +7 -2
  174. package/src/commands/analyze-heap.ts +4 -1
  175. package/src/commands/ast-ground-truth-args.ts +15 -12
  176. package/src/commands/ast-ground-truth-driver.ts +2 -1
  177. package/src/commands/ast-ground-truth.ts +1 -1
  178. package/src/commands/client-session.ts +33 -0
  179. package/src/commands/create-args.ts +68 -0
  180. package/src/commands/create.ts +83 -0
  181. package/src/commands/delete-args.ts +64 -0
  182. package/src/commands/delete.ts +61 -0
  183. package/src/commands/generate-ast-builder.ts +246 -0
  184. package/src/commands/generate-transfer-model-args.ts +11 -1
  185. package/src/commands/generate-transfer-model-config.ts +6 -3
  186. package/src/commands/generate-transfer-model.ts +27 -4
  187. package/src/commands/harness-args.ts +99 -31
  188. package/src/commands/headless-harness.ts +86 -26
  189. package/src/commands/init-args.ts +6 -6
  190. package/src/commands/init-templates.ts +272 -169
  191. package/src/commands/init-wizard.ts +4 -4
  192. package/src/commands/init-workspace.ts +31 -7
  193. package/src/commands/init.ts +36 -26
  194. package/src/commands/lint-grammar-args.ts +17 -13
  195. package/src/commands/lint-grammar-driver.ts +2 -1
  196. package/src/commands/lint-grammar.ts +1 -1
  197. package/src/commands/measure-memory-args.ts +27 -14
  198. package/src/commands/measure-memory-driver.ts +5 -3
  199. package/src/commands/measure-memory.ts +1 -1
  200. package/src/commands/model-docs-args.ts +16 -13
  201. package/src/commands/model-docs-driver.ts +2 -1
  202. package/src/commands/model-docs.ts +1 -1
  203. package/src/commands/projects-args.ts +5 -4
  204. package/src/commands/projects.ts +4 -6
  205. package/src/commands/query-args.ts +5 -4
  206. package/src/commands/query.ts +4 -6
  207. package/src/commands/reflect-args.ts +17 -13
  208. package/src/commands/reflect-driver.ts +2 -1
  209. package/src/commands/reflect.ts +1 -1
  210. package/src/commands/save-args.ts +7 -3
  211. package/src/commands/save.ts +24 -27
  212. package/src/commands/validate-args.ts +17 -13
  213. package/src/commands/validate-driver.ts +2 -1
  214. package/src/commands/validate.ts +1 -1
  215. package/src/commands/watch-args.ts +6 -3
  216. package/src/commands/watch.ts +28 -7
  217. package/src/driver-heap.ts +65 -0
  218. package/src/index.ts +3 -0
  219. package/src/log-level.ts +42 -14
  220. package/src/spawn-data-server.ts +17 -5
  221. package/src/stderr-connection-logger.ts +18 -0
  222. package/src/testing/echo-server.ts +11 -2
@@ -82,16 +82,6 @@ export interface InitTemplate {
82
82
  content: string;
83
83
  }
84
84
 
85
- /**
86
- * The version every framework package carries before the first release.
87
- *
88
- * A scaffold made by a CLI still at this version pins a range the registry
89
- * cannot serve, so the emitted README keeps its yalc note and `init` keeps its
90
- * install warning while — and only while — it holds: from a published CLI the
91
- * derived pins resolve and either note would be false as printed.
92
- */
93
- export const UNPUBLISHED_FRAMEWORK_VERSION = '0.0.0';
94
-
95
85
  /**
96
86
  * The version `init` pins every `@hydranium/*` dependency at: the scaffolding
97
87
  * CLI's own.
@@ -177,7 +167,7 @@ function columnsFor(composition: InitComposition): number {
177
167
 
178
168
  /**
179
169
  * A named import on one line while it fits the column budget, else one symbol
180
- * per line. Emitted files are supposed to pass a prettier check as-is, and the
170
+ * per line. Emitted files are supposed to pass a format check as-is, and the
181
171
  * generated-module import grows by one symbol per grammar.
182
172
  */
183
173
  function importList(symbols: readonly string[], from: string, columns: number, typeOnly = false): string {
@@ -228,7 +218,12 @@ const HEAD_FRAMEWORK_DEPENDENCIES: Record<InitHead, readonly string[]> = {
228
218
  const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, string>>> = {
229
219
  lsp: {
230
220
  langium: '4.3.1',
231
- 'vscode-languageserver': '~10.0.1'
221
+ // Exact, as are the protocol and transport it pins, so Langium and the
222
+ // framework share their copies; across copies, an error a handler throws
223
+ // or returns loses its code, and a later patch can pin another protocol.
224
+ 'vscode-languageserver': '10.0.1',
225
+ 'vscode-jsonrpc': '9.0.0',
226
+ 'vscode-languageserver-protocol': '3.18.1'
232
227
  },
233
228
  data: {},
234
229
  glsp: {
@@ -241,6 +236,37 @@ const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, st
241
236
  }
242
237
  };
243
238
 
239
+ /**
240
+ * The exact pins of `heads`, for a workspace root to declare too. npm installs a
241
+ * root's own dependencies at the top of the tree, so another member's version of
242
+ * one nests beneath that member instead of taking the place of the copy this
243
+ * package shares with the framework.
244
+ */
245
+ export function workspaceRootPins(heads: readonly InitHead[]): ReadonlyArray<readonly [string, string]> {
246
+ return heads
247
+ .flatMap(head => Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES[head]))
248
+ .filter(([, version]) => /^\d+\.\d+\.\d+$/.test(version))
249
+ .sort(([left], [right]) => left.localeCompare(right));
250
+ }
251
+
252
+ /** Whether an npm version is 11.6.0 or later, the first that installs `vitest` 4.1's peer set; `11.6.0-rc.1` is earlier. */
253
+ function installsVitest41(npmVersion: string | undefined): boolean {
254
+ const [release, prerelease] = (npmVersion ?? '0.0.0').split('-', 2);
255
+ const [major, minor, patch] = release.split('.').map(Number);
256
+ return major > 11 || (major === 11 && (minor > 6 || (minor === 6 && (patch > 0 || prerelease === undefined))));
257
+ }
258
+
259
+ /**
260
+ * Whether the scaffold holds `vitest` below 4.1, whose peer set crashes npm
261
+ * before 11.6 (every npm a Node 22 release bundles) on the first install. Only a
262
+ * workspace root declaring a newer npm lifts it, and only when `init` runs under
263
+ * an npm known to be newer: npm does not enforce `packageManager`.
264
+ */
265
+ function pinsVitest(composition: InitComposition): boolean {
266
+ const workspace = composition.packaging.workspace;
267
+ return !(installsVitest41(workspace?.packageManagerNpm) && installsVitest41(workspace?.npmVersion));
268
+ }
269
+
244
270
  /** The dependency block for a head set, merged and sorted as npm writes it. */
245
271
  function dependencyBlock(composition: InitComposition, indent: string): string {
246
272
  const merged: Record<string, string> = {};
@@ -260,7 +286,7 @@ function dependencyBlock(composition: InitComposition, indent: string): string {
260
286
  // npm convention, and `npm install` rewrites `package.json` at two spaces
261
287
  // regardless — so three would not survive the adopter's first install.
262
288
  //
263
- // The KEY ORDER is `prettier-plugin-packagejson`'s canonical one, `private`
289
+ // The KEY ORDER is the one Oxfmt's `sortPackageJson` canonicalises to, `private`
264
290
  // before `license` included. Any other order is rewritten by the scaffolded
265
291
  // project's own first `format` run — a diff on a file the adopter never touched
266
292
  // — and it makes a byte-compare against a formatted copy of this emission
@@ -291,16 +317,9 @@ const PACKAGE_JSON = `{
291
317
  "version": "0.0.0",
292
318
  __PRIVATE__ "description": "__NAME__ language server, built with the Hydranium framework.",
293
319
  "keywords": [
294
- "hydranium",
295
- "langium",
296
- "language-server",
297
- "__PROJECT_ID__"
298
- ],
320
+ __KEYWORDS__ ],
299
321
  "license": "UNLICENSED",
300
322
  "author": "",
301
- "type": "module",
302
- "main": "lib/index.js",
303
- "types": "lib/index.d.ts",
304
323
  "bin": {
305
324
  __BIN__ },
306
325
  "files": [
@@ -308,11 +327,14 @@ __BIN__ },
308
327
  "src",
309
328
  "syntaxes"
310
329
  ],
330
+ "type": "module",
331
+ "main": "lib/index.js",
332
+ "types": "lib/index.d.ts",
311
333
  "scripts": {
312
334
  "build": "npm run generate && tsc",
313
- "clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-transfer tsconfig.tsbuildinfo",
335
+ "clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-hydranium tsconfig.tsbuildinfo",
314
336
  "generate": "npm run langium:generate && npm run generate:transfer-model",
315
- "generate:transfer-model": "hydranium-cli generate-transfer-model --ast-file src/language-server/generated/ast.ts --augmentation-file src/language-server/ast.ts --out-file src/language-server/generated-transfer/transfer-model.ts --element-type-name __NAME__Element --terminals-name __NAME__Terminals --regen-command \\"Run: __NPM_RUN__ generate:transfer-model\\"",
337
+ "generate:transfer-model": "hydranium-cli generate-transfer-model --ast-file src/language-server/generated/ast.ts --augmentation-file src/language-server/ast.ts --out-file src/language-server/generated-hydranium/transfer-model.ts --ast-builder-file src/language-server/generated-hydranium/ast-builder.ts --element-type-name __NAME__Element --terminals-name __NAME__Terminals --regen-command \\"Run: __NPM_RUN__ generate:transfer-model\\"",
316
338
  "langium:generate": "langium generate",
317
339
  "langium:watch": "langium generate --watch",
318
340
  __LINT__ "start": "node lib/main.js --stdio",
@@ -328,8 +350,8 @@ __DEPENDENCIES__
328
350
  "@types/node": "^22.0.0",
329
351
  "langium-cli": "4.3.0",
330
352
  "rimraf": "^5.0.0",
331
- "typescript": "^5.8.0",
332
- "vitest": "^4.0.0"
353
+ "typescript": "^7.0.2",
354
+ "vitest": "__VITEST__"
333
355
  },
334
356
  "engines": {
335
357
  "node": ">=22.13"
@@ -359,7 +381,7 @@ function npmRun(composition: InitComposition): string {
359
381
  * name. Without the second key a scaffolded project has no command line that
360
382
  * reaches its own data head at all.
361
383
  *
362
- * Alphabetical, because `prettier-plugin-packagejson` sorts `bin` and any other
384
+ * Alphabetical, because Oxfmt's `sortPackageJson` sorts `bin` and any other
363
385
  * order is rewritten by the scaffolded project's first `format` run.
364
386
  */
365
387
  function binBlock(composition: InitComposition): string {
@@ -374,15 +396,32 @@ function binBlock(composition: InitComposition): string {
374
396
  function packageJson(composition: InitComposition): string {
375
397
  const { scope } = composition.packaging;
376
398
  const packageName = scope === undefined ? composition.projectId : `${scope}/${composition.projectId}`;
377
- // Emitted only where a root eslint config says the repo lints. The absence is
399
+ const keywords =
400
+ ['hydranium', 'langium', 'language-server', composition.projectId]
401
+ .sort()
402
+ .map(keyword => ` ${JSON.stringify(keyword)}`)
403
+ .join(',\n') + '\n';
404
+ // Emitted only where a root lint config says the repo lints. The absence is
378
405
  // the dangerous direction: a task runner runs a script only where one is
379
406
  // declared, so a package with no `lint` is SKIPPED rather than reported, and
380
407
  // that reads as a clean lint. A wrong invocation is one visible line to edit.
381
- const lint = composition.packaging.workspace?.eslintConfig === undefined ? '' : ' "lint": "eslint src test --max-warnings 0",\n';
408
+ const workspace = composition.packaging.workspace;
409
+ const oxlintConfig =
410
+ workspace?.oxlintConfig === undefined
411
+ ? undefined
412
+ : `${'../'.repeat(workspace.targetPath.split('/').length)}${workspace.oxlintConfig}`;
413
+ const lint =
414
+ workspace?.oxlintConfig !== undefined
415
+ ? ` "lint": "oxlint --report-unused-disable-directives --format stylish --config ${oxlintConfig} src test --max-warnings 0",\n`
416
+ : workspace?.eslintConfig !== undefined
417
+ ? ' "lint": "eslint src test --max-warnings 0",\n'
418
+ : '';
382
419
  return project(PACKAGE_JSON, composition)
383
420
  .replace('__PACKAGE_NAME__', packageName)
421
+ .replace('__KEYWORDS__', keywords)
384
422
  .replace('__BIN__', binBlock(composition))
385
423
  .replace('__LINT__', lint)
424
+ .replace('__VITEST__', pinsVitest(composition) ? '~4.0.18' : '^4.0.0')
386
425
  .replace('__PRIVATE__', composition.packaging.private ? ' "private": true,\n' : '')
387
426
  .replace('__NPM_RUN__', npmRun(composition))
388
427
  .replace('__FRAMEWORK_PIN__', frameworkPin(composition))
@@ -439,6 +478,11 @@ const TSCONFIG_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
439
478
  ['outDir', 'lib'],
440
479
  ['strict', true],
441
480
  ['esModuleInterop', true],
481
+ // A server that renders its own user-facing messages loads a catalogue, and
482
+ // a catalogue is JSON. Without this the import does not resolve at all, and
483
+ // without the `include` glob below a composite project rejects it with
484
+ // TS6307 naming neither cause.
485
+ ['resolveJsonModule', true],
442
486
  ['skipLibCheck', true],
443
487
  ['declaration', true],
444
488
  ['experimentalDecorators', true],
@@ -455,9 +499,20 @@ const TSCONFIG_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
455
499
  */
456
500
  const PACKAGE_LOCAL_OPTIONS: readonly string[] = ['rootDir', 'outDir'];
457
501
 
458
- /** Compare two JSON option values. Sound here because compiler options are scalars and flat arrays. */
459
- function jsonEquals(left: JsonValue | undefined, right: JsonValue): boolean {
460
- return JSON.stringify(left) === JSON.stringify(right);
502
+ /**
503
+ * The enum-valued options of {@link TSCONFIG_COMPILER_OPTIONS}, whose values
504
+ * `tsc` reads case-insensitively, so `nodenext` and `NodeNext` are one value.
505
+ * An enum-valued option added there belongs here too.
506
+ */
507
+ const CASE_INSENSITIVE_OPTIONS: readonly string[] = ['target', 'lib', 'module', 'moduleResolution'];
508
+
509
+ /** Compare two option values. Sound here because compiler options are scalars and flat arrays. */
510
+ function optionEquals(key: string, left: JsonValue | undefined, right: JsonValue): boolean {
511
+ const normalise = (value: JsonValue | undefined) => {
512
+ const text = JSON.stringify(value);
513
+ return CASE_INSENSITIVE_OPTIONS.includes(key) ? text?.toLowerCase() : text;
514
+ };
515
+ return normalise(left) === normalise(right);
461
516
  }
462
517
 
463
518
  /**
@@ -476,20 +531,23 @@ function tsconfigJson(composition: InitComposition): string {
476
531
  ? TSCONFIG_COMPILER_OPTIONS
477
532
  : [
478
533
  ...TSCONFIG_COMPILER_OPTIONS.filter(([key]) => PACKAGE_LOCAL_OPTIONS.includes(key)),
479
- ...TSCONFIG_COMPILER_OPTIONS.filter(([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !jsonEquals(base[key], value))
534
+ ...TSCONFIG_COMPILER_OPTIONS.filter(
535
+ ([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !optionEquals(key, base[key], value)
536
+ )
480
537
  ];
481
538
  const extendsLine = workspace?.baseTsconfig === undefined ? '' : ` "extends": "${workspace.baseTsconfig}",\n`;
482
539
  const body = options.map(([key, value]) => ` "${key}": ${JSON.stringify(value)}`).join(',\n');
483
- return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ["src"]\n}\n`;
540
+ return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ["src", "src/**/*.json"]\n}\n`;
484
541
  }
485
542
 
486
543
  // `isolatedModules` is what makes this check agree with the transform that
487
544
  // actually runs the tests. `npm test` is `typecheck:test && vitest run`, and
488
- // vitest compiles through esbuild — a per-file transform with no type
545
+ // vitest compiles each file on its own — a transform with no type
489
546
  // information, which cannot tell a re-exported TYPE from a re-exported value.
490
- // Without the flag `tsc` accepts `export { SomeType } from './x'` that esbuild
491
- // then emits as a real import of a symbol that does not exist at runtime, so
492
- // the typecheck passes and the suite fails with a confusing missing-export.
547
+ // Without the flag `tsc` accepts `export { SomeType } from './x'` that the
548
+ // transform then emits as a real import of a symbol that does not exist at
549
+ // runtime, so the typecheck passes and the suite fails with a confusing
550
+ // missing-export.
493
551
  const TSCONFIG_TEST = `{
494
552
  "extends": "./tsconfig.json",
495
553
  "compilerOptions": {
@@ -498,7 +556,16 @@ const TSCONFIG_TEST = `{
498
556
  "isolatedModules": true,
499
557
  "types": ["node"]
500
558
  },
501
- "include": ["src", "test"]
559
+ "include": ["src", "test", "src/**/*.json"]
560
+ }
561
+ `;
562
+
563
+ // Found by name, unlike `tsconfig.test.json`. Under vite 8, vitest's transform
564
+ // applies a tsconfig only to the files its own `include` lists, so without this
565
+ // a test declaring a decorated class fails to load.
566
+ const TEST_DIR_TSCONFIG = `{
567
+ "extends": "../tsconfig.test.json",
568
+ "include": ["../src", ".", "../src/**/*.json"]
502
569
  }
503
570
  `;
504
571
 
@@ -507,7 +574,7 @@ const VITEST_CONFIG = `import { defineConfig } from 'vitest/config';
507
574
  // Vitest transforms TypeScript itself and resolves \`.js\` specifiers to their
508
575
  // \`.ts\` source, so tests import from \`../src/\` with the same specifiers the
509
576
  // compiled output uses. \`include\` is scoped to \`test/\` so \`lib/\` is never
510
- // scanned; \`npm test\` runs \`typecheck:test\` first, because the esbuild
577
+ // scanned; \`npm test\` runs \`typecheck:test\` first, because vitest's
511
578
  // transform strips types without checking them.
512
579
 
513
580
  export default defineConfig({
@@ -518,6 +585,11 @@ export default defineConfig({
518
585
  });
519
586
  `;
520
587
 
588
+ /**
589
+ * The `.gitignore`, written for a workspace member too: its root may not ignore
590
+ * `lib/` (a `yo langium` root ignores `out/`), and a rule the root repeats
591
+ * costs nothing.
592
+ */
521
593
  const GITIGNORE = `node_modules/
522
594
  lib/
523
595
  *.tsbuildinfo
@@ -526,27 +598,6 @@ lib/
526
598
  syntaxes/
527
599
  `;
528
600
 
529
- /**
530
- * The workspace-member `.gitignore` — one entry, and the one entry a root
531
- * cannot be assumed to have.
532
- *
533
- * A member inherits the root's rules, and `node_modules/`, `lib/` and
534
- * `*.tsbuildinfo` are in every monorepo root already, so repeating them here
535
- * would be three lines to keep in step for no coverage. `syntaxes/` is not like
536
- * them: it is a Langium artefact, so a root that has never held a Langium
537
- * package has no rule for it, and the first `langium generate` then offers
538
- * generated output up for commit with nothing to warn the adopter. Dropping the
539
- * whole file rather than this subset is the mistake this template exists to
540
- * undo.
541
- */
542
- const GITIGNORE_WORKSPACE_MEMBER = `# The workspace root already covers \`node_modules/\`, \`lib/\` and \`*.tsbuildinfo\`.
543
- # It has no reason to know about this one: the TextMate grammar is a Langium
544
- # artefact, rewritten by every \`langium generate\`, so a root that has never held
545
- # a Langium package ignores nothing here and the generated file is offered for
546
- # commit.
547
- syntaxes/
548
- `;
549
-
550
601
  /** The terminals every grammar needs, shared verbatim by the fragment and the lone-grammar case. */
551
602
  const TERMINALS = `hidden terminal WS: /\\s+/;
552
603
  terminal ID: /[_a-zA-Z][\\w_]*/;
@@ -814,9 +865,9 @@ const SERIALIZER = `// Concrete-syntax emitter for __GRAMMAR__ — the parser's
814
865
  //
815
866
  // Without it every structured write fails: the framework's default binding at
816
867
  // \`services.serializer.Serializer\` THROWS, because a concrete syntax is
817
- // language knowledge no framework can derive. \`ModelService.update\` / \`save\`,
818
- // the data head's \`saveModelDocument\` and a GLSP \`SaveModelAction\` all reach
819
- // it.
868
+ // language knowledge no framework can derive. A client session's \`update\` /
869
+ // \`save\`, the data head's \`saveModelDocument\` and a GLSP \`SaveModelAction\`
870
+ // all reach it.
820
871
  //
821
872
  // \`AbstractSerializer\`'s generic property walk lays out FORMAT-structured
822
873
  // output — its YAML and JSON subclasses are what it exists for — and cannot
@@ -1003,7 +1054,7 @@ import { startGlspServer } from '@hydranium/glsp-server/node';
1003
1054
  // \`TransferElement\` structurally, so naming the AST type here compiles fine and
1004
1055
  // silently tells every typed client that a reference is a resolvable object
1005
1056
  // rather than a name.
1006
- ${importList(roots, './language-server/generated-transfer/transfer-model.js', columns, true)}
1057
+ ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
1007
1058
  `
1008
1059
  : '';
1009
1060
 
@@ -1027,19 +1078,22 @@ const dataServer = startSocketServer({ port: 0, logTag: 'ModelServer', logger: s
1027
1078
  return { dispose: () => undefined };
1028
1079
  });
1029
1080
  dataServer.started
1030
- .then(() => {
1031
- const { port } = dataServer;
1032
- if (port === undefined) {
1033
- // \`started\` resolves only once the address is resolved, so this is
1034
- // unreachable; a non-null assertion in its place would publish
1035
- // \`undefined\`, which the host cannot tell from an unreachable port.
1036
- throw new Error('the data head started without a resolved port');
1037
- }
1038
- publishPortOnLspConnection(shared.lsp.Connection, ${upper}_DATA_SERVER_PORT_COMMAND, port);
1039
- })
1081
+ .then(
1082
+ () => {
1083
+ const { port } = dataServer;
1084
+ if (port === undefined) {
1085
+ // \`started\` resolves only once the address is resolved, so this is
1086
+ // unreachable; a non-null assertion in its place would publish
1087
+ // \`undefined\`, which the host cannot tell from an unreachable port.
1088
+ throw new Error('the data head started without a resolved port');
1089
+ }
1090
+ publishPortOnLspConnection(shared.lsp.Connection, ${upper}_DATA_SERVER_PORT_COMMAND, port);
1091
+ },
1092
+ // The launcher logged its own bind failure; the rejection is control flow.
1093
+ () => undefined
1094
+ )
1040
1095
  .catch((error: unknown) => {
1041
- const reason = error instanceof Error ? error.message : String(error);
1042
- shared.Logger.error(\`[ModelServer] Could not publish the data-server port: \${reason}\`);
1096
+ shared.Logger.error(\`[ModelServer] Could not publish the data-server port: \${describeErrorWithStack(error)}\`);
1043
1097
  });
1044
1098
  `
1045
1099
  : '';
@@ -1076,9 +1130,9 @@ void glspServer;
1076
1130
  data
1077
1131
  ? `
1078
1132
  //
1079
- // NOT the entry \`hydranium-cli query\` / \`save\` / \`projects\` / \`watch\` speak to:
1080
- // stdio here carries LSP, and the data head is a socket whose port is published
1081
- // over the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
1133
+ // NOT the entry the data-server subcommands of \`hydranium-cli\` speak to: stdio
1134
+ // here carries LSP, and the data head is a socket whose port is published over
1135
+ // the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
1082
1136
  : ''
1083
1137
  }
1084
1138
  //
@@ -1086,11 +1140,17 @@ void glspServer;
1086
1140
  // than a library entry — import \`./index.js\` instead to compose the language.
1087
1141
 
1088
1142
  ${reflectImport}${glspImports}${importList(coreNodeSymbols, '@hydranium/core/node', columns)}
1089
- ${data ? "import { DataServer } from '@hydranium/data-server';\n" : ''}import { startLanguageServer } from '@hydranium/langium/lsp';
1143
+ ${data ? "import { DataServer } from '@hydranium/data-server';\nimport { describeErrorWithStack } from '@hydranium/protocol';\n" : ''}// The framework's entry point, NOT Langium's \`@hydranium/langium/lsp\` one. It is
1144
+ // signature-compatible and delegates straight through; what it adds first is
1145
+ // \`assertLspHeadComposed\`, which fails the start if the LSP head's SHARED module
1146
+ // was never composed. Reaching for Langium's is the natural mistake and it is
1147
+ // silent: the server boots, links and completes, while echo suppression, the
1148
+ // didChangeContent debounce and the last-client-close rebuild are all inert.
1149
+ import { startLanguageServer, withHydraniumLspFeatures } from '@hydranium/core/lsp';
1090
1150
  import { ProposedFeatures, createConnection } from 'vscode-languageserver/node';
1091
1151
  ${diagramImports}${transferImports}import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1092
1152
  ${portCommands === '' ? '' : '\n' + portCommands}
1093
- const connection = createConnection(ProposedFeatures.all);
1153
+ const connection = createConnection(withHydraniumLspFeatures(ProposedFeatures.all));
1094
1154
  const { shared } = create__NAME__Services({ connection, ...NodeFileSystem });
1095
1155
  startLanguageServer(shared);
1096
1156
  ${dataBlock}${glspBlock}`;
@@ -1103,11 +1163,11 @@ ${dataBlock}${glspBlock}`;
1103
1163
  * Emitted with the `data` head because it is the ONLY command line that reaches
1104
1164
  * that head from outside the editor. `main.ts` gives stdio to LSP and publishes
1105
1165
  * the data head's socket port over the LSP connection, so a client that is not
1106
- * an LSP client cannot discover it — which is every one of `hydranium-cli`'s
1107
- * `query` / `save` / `projects` / `watch`, all of which drive the data protocol
1108
- * over a spawned child's stdin/stdout. Without this file a scaffolded project
1109
- * has no `--server` value, and the closest guess — the LSP entry — fails as an
1110
- * unrecognised method rather than as a missing head.
1166
+ * an LSP client cannot discover it — which is every data-server subcommand of
1167
+ * `hydranium-cli`, since each drives the data protocol over a spawned child's
1168
+ * stdin/stdout. Without this file a scaffolded project has no `--server` value,
1169
+ * and the closest guess — the LSP entry — fails as an unrecognised method
1170
+ * rather than as a missing head.
1111
1171
  *
1112
1172
  * A separate process rather than a flag on `main.ts`: only one protocol can own
1113
1173
  * stdio, so the two entries are mutually exclusive by construction.
@@ -1118,10 +1178,9 @@ function dataServerMainFile(composition: InitComposition): string {
1118
1178
  const plural = roots.length > 1 ? 's' : '';
1119
1179
 
1120
1180
  const text = `${SHEBANG}// Standalone entry point: a data-server head on **stdio**, with no LSP head in
1121
- // the process. This is the transport \`hydranium-cli\` speaks — \`query\`, \`save\`,
1122
- // \`projects\` and \`watch\` all spawn a server command and drive JSON-RPC over its
1123
- // stdin/stdout — so it is what makes those subcommands usable against this
1124
- // language.
1181
+ // the process. This is the transport \`hydranium-cli\` speaks — its data-server
1182
+ // subcommands spawn a server command and drive JSON-RPC over its stdin/stdout —
1183
+ // so it is what makes those subcommands usable against this language.
1125
1184
  //
1126
1185
  // Invocation: \`node lib/data-server-main.js [<workspace-path>]\`, or the
1127
1186
  // package's \`__PROJECT_ID__-data-server\` bin script. The workspace path defaults to
@@ -1140,7 +1199,7 @@ function dataServerMainFile(composition: InitComposition): string {
1140
1199
  ${importList(['NodeFileSystem', 'startStdioServer'], '@hydranium/core/node', columns)}
1141
1200
  import { DataServer } from '@hydranium/data-server';
1142
1201
  // The TRANSFER root${plural}, not the AST one${plural} — same reasoning as \`main.ts\`.
1143
- ${importList(roots, './language-server/generated-transfer/transfer-model.js', columns, true)}
1202
+ ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
1144
1203
  import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1145
1204
 
1146
1205
  const { shared } = create__NAME__Services({ ...NodeFileSystem });
@@ -1259,8 +1318,8 @@ function serializationTest(composition: InitComposition): string {
1259
1318
  const transferTypes = composition.grammars.map(grammar => `${grammar.entryRule} as Transfer${grammar.entryRule}`);
1260
1319
  const text = `// Round-trips each grammar through its serializer: parse the source, serialize
1261
1320
  // the model, compare the text. The serializer is the parser's inverse, so this
1262
- // is the assertion that keeps \`ModelService.update\` / \`save\` writing files the
1263
- // language server can read back.
1321
+ // is the assertion that keeps a client session's \`update\` / \`save\` writing
1322
+ // files the language server can read back.
1264
1323
  //
1265
1324
  // The transfer case is the one that catches the mistake worth catching.
1266
1325
  // \`ModelService.modelToText\` short-circuits only a RAW STRING, so a typed model
@@ -1270,7 +1329,7 @@ function serializationTest(composition: InitComposition): string {
1270
1329
  import { parseHelper } from '@hydranium/core/testing';
1271
1330
  import { describe, expect, it } from 'vitest';
1272
1331
  ${importList(astTypes, '../src/language-server/ast.js', columns, true)}
1273
- ${importList(transferTypes, '../src/language-server/generated-transfer/transfer-model.js', columns, true)}
1332
+ ${importList(transferTypes, '../src/language-server/generated-hydranium/transfer-model.js', columns, true)}
1274
1333
  import { createServices } from '../src/services.js';
1275
1334
 
1276
1335
  ${composition.grammars.map(grammar => render(SERIALIZATION_SUITE, composition, grammar)).join('\n\n')}
@@ -1439,20 +1498,6 @@ function readme(composition: InitComposition): string {
1439
1498
  '',
1440
1499
  '## Getting started',
1441
1500
  '',
1442
- // Conditional on the pin this scaffold actually carries — see
1443
- // `UNPUBLISHED_FRAMEWORK_VERSION`.
1444
- ...(composition.frameworkVersion === UNPUBLISHED_FRAMEWORK_VERSION
1445
- ? [
1446
- '> **Pre-publish note.** `@hydranium/*` is not on npm yet, so the `0.0.0`',
1447
- '> pins below are placeholders and `npm install` will fail with a 404 until',
1448
- '> the framework is released. Until then, supply the packages from a local',
1449
- '> framework checkout with [yalc](https://github.com/wclr/yalc) — a plain',
1450
- '> `file:` path or `npm link` is not enough, because the framework packages',
1451
- '> depend on each other by version and npm would try to fetch those from the',
1452
- '> registry too.',
1453
- ''
1454
- ]
1455
- : []),
1456
1501
  '```bash',
1457
1502
  'npm install',
1458
1503
  'npm run langium:generate # generate the AST from the grammar',
@@ -1566,6 +1611,24 @@ function readme(composition: InitComposition): string {
1566
1611
  'atomic set and depends on a single physical copy, so a floating range can',
1567
1612
  'silently resolve a second one.',
1568
1613
  '',
1614
+ '`vscode-languageserver` is pinned to `10.0.1`, and',
1615
+ '`vscode-languageserver-protocol` and `vscode-jsonrpc` to the `3.18.1` and',
1616
+ '`9.0.0` it pins, so Langium, the LSP connection and the framework share one',
1617
+ 'copy of each. With two, an error a Langium handler returns, such as a request',
1618
+ 'for a document the server does not have, reaches the client as a result, and a',
1619
+ 'framework error your own LSP handler throws loses its code. The Langium 4.4',
1620
+ 'upgrade',
1621
+ '([eclipse-emfcloud/hydranium#142](https://github.com/eclipse-emfcloud/hydranium/issues/142))',
1622
+ 'replaces these pins.',
1623
+ '',
1624
+ ...(pinsVitest(composition)
1625
+ ? [
1626
+ '`vitest` is held below 4.1, whose peer set crashes the npm every Node 22',
1627
+ 'release bundles. Raise it once every npm that installs this project is',
1628
+ '11.6 or later.',
1629
+ ''
1630
+ ]
1631
+ : []),
1569
1632
  '## If your repo gates license headers',
1570
1633
  '',
1571
1634
  'The emitted `.ts` files carry no copyright header — the scaffold cannot know',
@@ -1585,10 +1648,9 @@ function readme(composition: InitComposition): string {
1585
1648
  /**
1586
1649
  * The GLSP files for ONE grammar's diagram.
1587
1650
  *
1588
- * Per grammar rather than per project because a diagram type has exactly one
1589
- * grammar — `AbstractHydraniumGlspDiagramModule.declareLanguage()` returns that
1590
- * grammar's `LanguageMetaData`, and it is bound on the SESSION container so a
1591
- * grammar with no diagram can sit on the same server.
1651
+ * Per grammar rather than per project because the state, storage and GModel
1652
+ * factory are typed over that grammar's model, and a grammar with no diagram
1653
+ * can sit on the same server.
1592
1654
  *
1593
1655
  * The index and the computed-bounds handler are framework classes used
1594
1656
  * unmodified; storage and the submission handler are thin typed subclasses,
@@ -1656,8 +1718,11 @@ export class __GRAMMAR__GlspState extends FullTextHydraniumGlspState<__ENTRY_RUL
1656
1718
 
1657
1719
  const storage = `// Source-model storage for the __GRAMMAR__ diagram, inheriting both framework
1658
1720
  // defaults: \`loadSourceModel\` (open + settle + \`setSourceRoot\`) and
1659
- // \`saveSourceModel\` (through \`ModelService.save\` → the per-URI \`Serializer\` →
1660
- // the multi-client text store → \`WritableFileSystemProvider\`).
1721
+ // \`saveSourceModel\` (flush the store's text for the primary and every tracked
1722
+ // secondary through the diagram's client session).
1723
+ //
1724
+ // No serializer runs on the save path — a save persists what the diagram's
1725
+ // operations already wrote to the store.
1661
1726
  //
1662
1727
  // The subclass exists so a bespoke load or save has a stable place to land.
1663
1728
 
@@ -1821,27 +1886,28 @@ export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
1821
1886
  // read-only is a deletion the compiler checks, while read-only → editable is
1822
1887
  // authoring against a seam you have not used yet.
1823
1888
  //
1824
- // **It composes TEXT rather than mutating the AST.** The source model of
1825
- // \`FullTextHydraniumGlspState\` is the document text, and reading it back through
1826
- // \`state.sourceModel\` serialises the AST through the per-URI \`Serializer\`, which
1827
- // this scaffold does not bind — so that getter throws until you do. Appending a
1828
- // declaration to the text the parser last read needs no serializer, which is what
1829
- // makes a scaffolded diagram editable on day one. Bind a \`Serializer\` at
1830
- // \`services.serializer.Serializer\` and this becomes a
1831
- // \`HydraniumGlspRecordingCommand\` over \`state.sourceModel\` instead — the same
1832
- // binding the diagram's own save action needs.
1889
+ // **It edits the operation's working copy.** A handler runs inside an operation,
1890
+ // where \`sourceRoot\` is a copy of the built root: the recording command's
1891
+ // runnable appends to the copy, and the operation writes the copy's text through
1892
+ // the grammar's \`Serializer\` once, gated on the version the copy was made at,
1893
+ // with undo and redo applying that one change. A command that writes the
1894
+ // document itself instead reads no text off the copy, which has no \`$document\`,
1895
+ // and the operation's own write then overwrites it.
1833
1896
  //
1834
1897
  // **The drop location is discarded.** \`needsClientLayout\` is \`true\` and the
1835
1898
  // starter grammar persists no bounds, so there is nowhere to put a coordinate: the
1836
1899
  // node is appended at the end of the document and the client places it.
1837
1900
  //
1838
1901
  // Like \`types.ts\`, \`gmodel-factory.ts\` and \`diagram-configuration.ts\`, this file
1839
- // knows the starter grammar's concrete syntax — the \`node\` keyword below is that
1840
- // grammar's. Replacing the grammar means replacing these four together.
1902
+ // knows the starter grammar's shape — the \`nodes\` list and the \`__NODE_RULE__\`
1903
+ // it holds. Replacing the grammar means replacing these four together.
1841
1904
 
1842
1905
  import { type Command, type CreateNodeOperation, JsonCreateNodeOperationHandler, type MaybePromise } from '@eclipse-glsp/server';
1906
+ import { appendChild } from '@hydranium/core';
1907
+ import { HydraniumGlspRecordingCommand } from '@hydranium/glsp-server';
1843
1908
  import { findNextUnique } from '@hydranium/protocol';
1844
1909
  import { injectable } from 'inversify';
1910
+ import type { __NODE_RULE__ } from '../../language-server/ast.js';
1845
1911
  import { ${upper}_NODE_TYPE } from './types.js';
1846
1912
  import type { __GRAMMAR__GlspState } from './state.js';
1847
1913
 
@@ -1860,46 +1926,20 @@ export class __GRAMMAR__CreateNodeOperationHandler extends JsonCreateNodeOperati
1860
1926
  if (!this.elementTypeIds.includes(operation.elementTypeId)) {
1861
1927
  return undefined;
1862
1928
  }
1863
- const state = this.modelState;
1864
- const before = this.documentText();
1865
- const after = this.withNode(
1866
- before,
1867
- findNextUnique(
1929
+ return new HydraniumGlspRecordingCommand(this.modelState, this.label, () => {
1930
+ const root = this.modelState.sourceRoot;
1931
+ const name = findNextUnique(
1868
1932
  NODE_NAME_STEM,
1869
- state.sourceRoot.nodes.map(node => node.name)
1870
- )
1871
- );
1872
- // Whole-document undo, which is all a full-text source model can offer: it
1873
- // has exactly one field, so there is nothing to merge a concurrent edit into.
1874
- return {
1875
- execute: () => state.updateSourceModel({ text: after }),
1876
- undo: () => state.updateSourceModel({ text: before }),
1877
- redo: () => state.updateSourceModel({ text: after })
1878
- };
1879
- }
1880
-
1881
- /** The text the captured source root was parsed from — the baseline an edit appends to. */
1882
- protected documentText(): string {
1883
- return this.modelState.sourceRoot.$document?.textDocument.getText() ?? '';
1884
- }
1885
-
1886
- /** \`text\` with one more node declaration, under exactly one trailing newline. */
1887
- protected withNode(text: string, name: string): string {
1888
- const body = text.trimEnd();
1889
- const declaration = \`node \${name}\`;
1890
- return body.length === 0 ? \`\${declaration}\\n\` : \`\${body}\\n\${declaration}\\n\`;
1933
+ root.nodes.map(node => node.name)
1934
+ );
1935
+ appendChild(root, 'nodes', root.nodes, { $type: '__NODE_RULE__', name } as __NODE_RULE__);
1936
+ });
1891
1937
  }
1892
1938
  }
1893
1939
  `;
1894
1940
 
1895
1941
  const module = `// GLSP diagram module for __GRAMMAR__ — the DI wiring of one diagram type.
1896
1942
  //
1897
- // \`declareLanguage\` is the multi-grammar seam: it binds the grammar this diagram
1898
- // edits on the SESSION container, so \`modelState.diagramLanguage\` and the
1899
- // per-language lookups resolve to __LANGUAGE_ID__. That is what stops one
1900
- // diagram type from fighting over a process-wide binding when a project holds
1901
- // several grammars — a grammar with no diagram is registered on the same server.
1902
- //
1903
1943
  // \`configureActionHandlers\` REBINDS rather than adds: GLSP's own
1904
1944
  // \`DiagramModule\` already registers \`ComputedBoundsActionHandler\`, and two
1905
1945
  // handlers for one action would both run.
@@ -1926,13 +1966,11 @@ ${importList(
1926
1966
  true
1927
1967
  )}
1928
1968
  import { ComputedBoundsActionHandler } from '@eclipse-glsp/server';
1929
- import type { LanguageMetaData } from '@hydranium/langium';
1930
1969
  ${importList(
1931
1970
  ['HydraniumGlspComputedBoundsActionHandler', 'AbstractHydraniumGlspDiagramModule', 'HydraniumGlspIndex'],
1932
1971
  '@hydranium/glsp-server',
1933
1972
  columns
1934
1973
  )}
1935
- import { __GRAMMAR__LanguageMetaData } from '../../language-server/generated/module.js';
1936
1974
  import { __GRAMMAR__CreateNodeOperationHandler } from './create-node-operation-handler.js';
1937
1975
  import { __GRAMMAR__DiagramConfiguration } from './diagram-configuration.js';
1938
1976
  import { __GRAMMAR__GModelFactory } from './gmodel-factory.js';
@@ -1944,10 +1982,6 @@ import { ${upper}_DIAGRAM_TYPE } from './types.js';
1944
1982
  export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule {
1945
1983
  readonly diagramType = ${upper}_DIAGRAM_TYPE;
1946
1984
 
1947
- protected override declareLanguage(): LanguageMetaData {
1948
- return __GRAMMAR__LanguageMetaData;
1949
- }
1950
-
1951
1985
  protected override bindModelState(): BindingTarget<ModelState> {
1952
1986
  return { service: __GRAMMAR__GlspState };
1953
1987
  }
@@ -1983,6 +2017,76 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
1983
2017
  binding.add(__GRAMMAR__CreateNodeOperationHandler);
1984
2018
  }
1985
2019
  }
2020
+ `;
2021
+
2022
+ const diagramTest = `// The starter create-node handler, run the way a client runs it: an in-process
2023
+ // GLSP server opens a document, a create-node operation adds a node, and an undo
2024
+ // and a redo take it out and put it back. Every step must keep the nodes the
2025
+ // document already had, and its comments: the diagram writes the document from
2026
+ // its AST, which carries none.
2027
+
2028
+ import 'reflect-metadata';
2029
+ import { type Action, CreateNodeOperation, RedoAction, ServerModule, SetDirtyStateAction, UndoAction } from '@eclipse-glsp/server';
2030
+ import { initializeWorkspaceProgrammatically } from '@hydranium/core';
2031
+ import { makeScratchWorkspace, type ScratchWorkspace } from '@hydranium/core/testing/node';
2032
+ import { HydraniumGlspAppModule } from '@hydranium/glsp-server';
2033
+ import { type GlspHarness, makeGlspHarness } from '@hydranium/glsp-server/testing';
2034
+ import { waitFor } from '@hydranium/protocol/testing';
2035
+ import { afterEach, describe, expect, it } from 'vitest';
2036
+ import { __GRAMMAR__DiagramModule } from '../src/glsp/__GRAMMAR_ID__/diagram-module.js';
2037
+ import type { __GRAMMAR__GlspState } from '../src/glsp/__GRAMMAR_ID__/state.js';
2038
+ import { ${upper}_DIAGRAM_TYPE, ${upper}_NODE_TYPE } from '../src/glsp/__GRAMMAR_ID__/types.js';
2039
+ import { createServices } from '../src/services.js';
2040
+
2041
+ let workspace: ScratchWorkspace | undefined;
2042
+ let diagram: GlspHarness<__GRAMMAR__GlspState> | undefined;
2043
+
2044
+ afterEach(() => {
2045
+ diagram?.dispose();
2046
+ workspace?.dispose();
2047
+ diagram = undefined;
2048
+ workspace = undefined;
2049
+ });
2050
+
2051
+ /** Send \`action\` and wait for the dirty state the server answers it with, which it sends once the edit is written. */
2052
+ async function send(harness: GlspHarness<__GRAMMAR__GlspState>, action: Action, reason: string): Promise<void> {
2053
+ const before = harness.actions.length;
2054
+ harness.dispatch(action);
2055
+ await waitFor(() => harness.actions.slice(before).some(sent => SetDirtyStateAction.is(sent) && sent.reason === reason), {
2056
+ message: \`no '\${reason}' dirty state\`
2057
+ });
2058
+ }
2059
+
2060
+ describe('__GRAMMAR__ diagram', () => {
2061
+ it('creates a node after the existing ones, keeping their comments, and undoes and redoes it', async () => {
2062
+ workspace = makeScratchWorkspace({ prefix: '__GRAMMAR_ID__-diagram-' });
2063
+ const original = '// keep this comment\\nnode first -> second\\n// about second\\nnode second';
2064
+ const file = workspace.write('model.__EXTENSION__', original);
2065
+ const { shared } = createServices();
2066
+ await initializeWorkspaceProgrammatically(shared, workspace.root);
2067
+ diagram = makeGlspHarness<__GRAMMAR__GlspState>({
2068
+ serverModule: new ServerModule().configureDiagramModule(new __GRAMMAR__DiagramModule()),
2069
+ diagramType: ${upper}_DIAGRAM_TYPE,
2070
+ appModules: [new HydraniumGlspAppModule({ shared })]
2071
+ });
2072
+ await diagram.start();
2073
+ await diagram.openDocument(file);
2074
+ const uri = workspace.uri('model.__EXTENSION__');
2075
+ const text = (): string | undefined => shared.workspace.TextDocuments.get(uri)?.getText();
2076
+
2077
+ await send(diagram, CreateNodeOperation.create(${upper}_NODE_TYPE), 'operation');
2078
+ const created = text();
2079
+ await send(diagram, UndoAction.create(), 'undo');
2080
+ const undone = text();
2081
+ await send(diagram, RedoAction.create(), 'redo');
2082
+
2083
+ expect({ created, undone, redone: text() }).toEqual({
2084
+ created: \`\${original}\\nnode Node\`,
2085
+ undone: original,
2086
+ redone: \`\${original}\\nnode Node\`
2087
+ });
2088
+ });
2089
+ });
1986
2090
  `;
1987
2091
 
1988
2092
  return [
@@ -1993,7 +2097,8 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
1993
2097
  { path: `${dir}/gmodel-factory.ts`, content: render(factory) },
1994
2098
  { path: `${dir}/diagram-configuration.ts`, content: render(configuration) },
1995
2099
  { path: `${dir}/create-node-operation-handler.ts`, content: render(createHandler) },
1996
- { path: `${dir}/diagram-module.ts`, content: render(module) }
2100
+ { path: `${dir}/diagram-module.ts`, content: render(module) },
2101
+ { path: `test/${grammar.grammarId}-diagram.test.ts`, content: render(diagramTest) }
1997
2102
  ];
1998
2103
  }
1999
2104
 
@@ -2021,11 +2126,9 @@ export function buildInitTemplates(composition: InitComposition): InitFile[] {
2021
2126
  { path: 'langium-config.json', content: langiumConfig(composition) },
2022
2127
  { path: 'tsconfig.json', content: tsconfigJson(composition) },
2023
2128
  { path: 'tsconfig.test.json', content: TSCONFIG_TEST },
2129
+ { path: 'test/tsconfig.json', content: TEST_DIR_TSCONFIG },
2024
2130
  { path: 'vitest.config.ts', content: VITEST_CONFIG },
2025
- {
2026
- path: '.gitignore',
2027
- content: composition.packaging.workspace === undefined ? GITIGNORE : GITIGNORE_WORKSPACE_MEMBER
2028
- },
2131
+ { path: '.gitignore', content: GITIGNORE },
2029
2132
  { path: 'README.md', content: readme(composition) },
2030
2133
  ...grammarFiles,
2031
2134
  { path: `src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },