@hydranium/core 1.0.0-next.4 → 1.0.0-next.42

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 (228) hide show
  1. package/lib/documents/ast-document-manager.d.ts +42 -5
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +9 -12
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  6. package/lib/documents/hydranium-text-documents.js +41 -9
  7. package/lib/documents/hydranium-text-documents.js.map +1 -1
  8. package/lib/documents/language-client-text-shadow.d.ts +24 -1
  9. package/lib/documents/language-client-text-shadow.d.ts.map +1 -1
  10. package/lib/documents/language-client-text-shadow.js +36 -2
  11. package/lib/documents/language-client-text-shadow.js.map +1 -1
  12. package/lib/documents/self-save-registry.d.ts +37 -6
  13. package/lib/documents/self-save-registry.d.ts.map +1 -1
  14. package/lib/documents/self-save-registry.js +28 -15
  15. package/lib/documents/self-save-registry.js.map +1 -1
  16. package/lib/index.d.ts +2 -0
  17. package/lib/index.d.ts.map +1 -1
  18. package/lib/index.js +5 -0
  19. package/lib/index.js.map +1 -1
  20. package/lib/langium/ast-extension/ast-node-builder.d.ts +32 -9
  21. package/lib/langium/ast-extension/ast-node-builder.d.ts.map +1 -1
  22. package/lib/langium/ast-extension/ast-node-builder.js +60 -22
  23. package/lib/langium/ast-extension/ast-node-builder.js.map +1 -1
  24. package/lib/langium/bootstrap.d.ts.map +1 -1
  25. package/lib/langium/bootstrap.js +16 -9
  26. package/lib/langium/bootstrap.js.map +1 -1
  27. package/lib/langium/document-builder/build-pipeline-integration.d.ts +13 -1
  28. package/lib/langium/document-builder/build-pipeline-integration.d.ts.map +1 -1
  29. package/lib/langium/document-builder/build-pipeline-integration.js +1 -1
  30. package/lib/langium/document-builder/build-pipeline-integration.js.map +1 -1
  31. package/lib/langium/document-builder/build-session.d.ts +93 -0
  32. package/lib/langium/document-builder/build-session.d.ts.map +1 -0
  33. package/lib/langium/document-builder/build-session.js +72 -0
  34. package/lib/langium/document-builder/build-session.js.map +1 -0
  35. package/lib/langium/document-builder/document-builder.d.ts +150 -7
  36. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  37. package/lib/langium/document-builder/document-builder.js +298 -11
  38. package/lib/langium/document-builder/document-builder.js.map +1 -1
  39. package/lib/langium/document-builder/index.d.ts +1 -0
  40. package/lib/langium/document-builder/index.d.ts.map +1 -1
  41. package/lib/langium/document-builder/index.js +1 -0
  42. package/lib/langium/document-builder/index.js.map +1 -1
  43. package/lib/langium/integration-services.d.ts +45 -1
  44. package/lib/langium/integration-services.d.ts.map +1 -1
  45. package/lib/langium/integration-services.js +10 -5
  46. package/lib/langium/integration-services.js.map +1 -1
  47. package/lib/langium/integrity/integrity-service.d.ts +5 -0
  48. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  49. package/lib/langium/integrity/integrity-service.js +34 -1
  50. package/lib/langium/integrity/integrity-service.js.map +1 -1
  51. package/lib/langium/keys/containment.d.ts +64 -0
  52. package/lib/langium/keys/containment.d.ts.map +1 -0
  53. package/lib/langium/keys/containment.js +53 -0
  54. package/lib/langium/keys/containment.js.map +1 -0
  55. package/lib/langium/keys/index.d.ts +1 -0
  56. package/lib/langium/keys/index.d.ts.map +1 -1
  57. package/lib/langium/keys/index.js +1 -0
  58. package/lib/langium/keys/index.js.map +1 -1
  59. package/lib/langium/keys/name-based-key-provider.d.ts +4 -0
  60. package/lib/langium/keys/name-based-key-provider.d.ts.map +1 -1
  61. package/lib/langium/keys/name-based-key-provider.js +4 -0
  62. package/lib/langium/keys/name-based-key-provider.js.map +1 -1
  63. package/lib/langium/model-service/model-service.d.ts +103 -7
  64. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  65. package/lib/langium/model-service/model-service.js +70 -89
  66. package/lib/langium/model-service/model-service.js.map +1 -1
  67. package/lib/langium/module.d.ts +82 -13
  68. package/lib/langium/module.d.ts.map +1 -1
  69. package/lib/langium/module.js +21 -12
  70. package/lib/langium/module.js.map +1 -1
  71. package/lib/langium/naming/name-provider.d.ts +10 -0
  72. package/lib/langium/naming/name-provider.d.ts.map +1 -1
  73. package/lib/langium/naming/name-provider.js.map +1 -1
  74. package/lib/langium/naming/name-separator-validation.d.ts +23 -0
  75. package/lib/langium/naming/name-separator-validation.d.ts.map +1 -1
  76. package/lib/langium/naming/name-separator-validation.js +30 -1
  77. package/lib/langium/naming/name-separator-validation.js.map +1 -1
  78. package/lib/langium/residency/cst-residency-service.d.ts +31 -9
  79. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  80. package/lib/langium/residency/cst-residency-service.js +13 -56
  81. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  82. package/lib/langium/scope/hydranium-scope-provider.d.ts +32 -13
  83. package/lib/langium/scope/hydranium-scope-provider.d.ts.map +1 -1
  84. package/lib/langium/scope/hydranium-scope-provider.js +37 -20
  85. package/lib/langium/scope/hydranium-scope-provider.js.map +1 -1
  86. package/lib/langium/shared-services.d.ts +15 -2
  87. package/lib/langium/shared-services.d.ts.map +1 -1
  88. package/lib/langium/shared-services.js.map +1 -1
  89. package/lib/langium/transfer/transfer-encoder.d.ts +33 -2
  90. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  91. package/lib/langium/transfer/transfer-encoder.js +14 -3
  92. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  93. package/lib/langium/validation/document-validator.d.ts +145 -6
  94. package/lib/langium/validation/document-validator.d.ts.map +1 -1
  95. package/lib/langium/validation/document-validator.js +211 -4
  96. package/lib/langium/validation/document-validator.js.map +1 -1
  97. package/lib/langium/workspace/document-uri-policy.d.ts +3 -4
  98. package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
  99. package/lib/langium/workspace/document-uri-policy.js +3 -4
  100. package/lib/langium/workspace/document-uri-policy.js.map +1 -1
  101. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +25 -0
  102. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
  103. package/lib/langium/workspace/hydranium-langium-document-factory.js +25 -0
  104. package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
  105. package/lib/langium/workspace/hydranium-workspace-manager.d.ts +21 -1
  106. package/lib/langium/workspace/hydranium-workspace-manager.d.ts.map +1 -1
  107. package/lib/langium/workspace/hydranium-workspace-manager.js +28 -0
  108. package/lib/langium/workspace/hydranium-workspace-manager.js.map +1 -1
  109. package/lib/langium/workspace/in-memory-file-system-provider.d.ts +8 -0
  110. package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
  111. package/lib/langium/workspace/in-memory-file-system-provider.js +11 -2
  112. package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
  113. package/lib/langium/workspace/initialize-workspace.d.ts +22 -2
  114. package/lib/langium/workspace/initialize-workspace.d.ts.map +1 -1
  115. package/lib/langium/workspace/initialize-workspace.js +13 -5
  116. package/lib/langium/workspace/initialize-workspace.js.map +1 -1
  117. package/lib/langium/workspace/langium-documents.d.ts +79 -17
  118. package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
  119. package/lib/langium/workspace/langium-documents.js +83 -28
  120. package/lib/langium/workspace/langium-documents.js.map +1 -1
  121. package/lib/locale/index.d.ts +10 -0
  122. package/lib/locale/index.d.ts.map +1 -0
  123. package/lib/locale/index.js +10 -0
  124. package/lib/locale/index.js.map +1 -0
  125. package/lib/locale/server-locale.d.ts +73 -0
  126. package/lib/locale/server-locale.d.ts.map +1 -0
  127. package/lib/locale/server-locale.js +61 -0
  128. package/lib/locale/server-locale.js.map +1 -0
  129. package/lib/lsp/hydranium-document-update-handler.js +1 -1
  130. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  131. package/lib/messages/carriers.d.ts +54 -0
  132. package/lib/messages/carriers.d.ts.map +1 -0
  133. package/lib/messages/carriers.js +62 -0
  134. package/lib/messages/carriers.js.map +1 -0
  135. package/lib/messages/index.d.ts +25 -0
  136. package/lib/messages/index.d.ts.map +1 -0
  137. package/lib/messages/index.js +25 -0
  138. package/lib/messages/index.js.map +1 -0
  139. package/lib/messages/renderer.d.ts +136 -0
  140. package/lib/messages/renderer.d.ts.map +1 -0
  141. package/lib/messages/renderer.js +159 -0
  142. package/lib/messages/renderer.js.map +1 -0
  143. package/lib/node/node-file-system-provider.d.ts.map +1 -1
  144. package/lib/node/node-file-system-provider.js +1 -40
  145. package/lib/node/node-file-system-provider.js.map +1 -1
  146. package/lib/node/rename-over-open-readers.d.ts +52 -0
  147. package/lib/node/rename-over-open-readers.d.ts.map +1 -0
  148. package/lib/node/rename-over-open-readers.js +66 -0
  149. package/lib/node/rename-over-open-readers.js.map +1 -0
  150. package/lib/testing/fake-document.d.ts +14 -3
  151. package/lib/testing/fake-document.d.ts.map +1 -1
  152. package/lib/testing/fake-document.js +18 -3
  153. package/lib/testing/fake-document.js.map +1 -1
  154. package/lib/testing/make-noop-shared-services.d.ts +14 -0
  155. package/lib/testing/make-noop-shared-services.d.ts.map +1 -1
  156. package/lib/testing/make-noop-shared-services.js +28 -2
  157. package/lib/testing/make-noop-shared-services.js.map +1 -1
  158. package/lib/testing/make-test-services.d.ts +31 -4
  159. package/lib/testing/make-test-services.d.ts.map +1 -1
  160. package/lib/testing/make-test-services.js +19 -3
  161. package/lib/testing/make-test-services.js.map +1 -1
  162. package/lib/testing/playwright/server-log-capture.d.ts +14 -4
  163. package/lib/testing/playwright/server-log-capture.d.ts.map +1 -1
  164. package/lib/testing/playwright/server-log-capture.js +21 -4
  165. package/lib/testing/playwright/server-log-capture.js.map +1 -1
  166. package/lib/testing/playwright/server-log-rename-reporter.d.ts.map +1 -1
  167. package/lib/testing/playwright/server-log-rename-reporter.js +10 -1
  168. package/lib/testing/playwright/server-log-rename-reporter.js.map +1 -1
  169. package/lib/testing/stub-ast-document-manager.d.ts +3 -4
  170. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  171. package/lib/testing/stub-ast-document-manager.js +2 -5
  172. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  173. package/lib/testing/stub-document-builder.d.ts +8 -0
  174. package/lib/testing/stub-document-builder.d.ts.map +1 -1
  175. package/lib/testing/stub-document-builder.js +22 -3
  176. package/lib/testing/stub-document-builder.js.map +1 -1
  177. package/lib/testing/stub-model-service.d.ts +1 -1
  178. package/lib/testing/stub-model-service.d.ts.map +1 -1
  179. package/lib/testing/stub-model-service.js +2 -2
  180. package/lib/testing/stub-model-service.js.map +1 -1
  181. package/package.json +17 -8
  182. package/src/documents/ast-document-manager.ts +55 -14
  183. package/src/documents/hydranium-text-documents.ts +41 -9
  184. package/src/documents/language-client-text-shadow.ts +38 -2
  185. package/src/documents/self-save-registry.ts +52 -13
  186. package/src/index.ts +5 -0
  187. package/src/langium/ast-extension/ast-node-builder.ts +66 -22
  188. package/src/langium/bootstrap.ts +16 -9
  189. package/src/langium/document-builder/build-pipeline-integration.ts +14 -1
  190. package/src/langium/document-builder/build-session.ts +87 -0
  191. package/src/langium/document-builder/document-builder.ts +339 -11
  192. package/src/langium/document-builder/index.ts +1 -0
  193. package/src/langium/integration-services.ts +67 -6
  194. package/src/langium/integrity/integrity-service.ts +35 -1
  195. package/src/langium/keys/containment.ts +87 -0
  196. package/src/langium/keys/index.ts +1 -0
  197. package/src/langium/keys/name-based-key-provider.ts +4 -0
  198. package/src/langium/model-service/model-service.ts +145 -22
  199. package/src/langium/module.ts +99 -19
  200. package/src/langium/naming/name-provider.ts +10 -0
  201. package/src/langium/naming/name-separator-validation.ts +34 -5
  202. package/src/langium/residency/cst-residency-service.ts +36 -12
  203. package/src/langium/scope/hydranium-scope-provider.ts +40 -22
  204. package/src/langium/shared-services.ts +19 -2
  205. package/src/langium/transfer/transfer-encoder.ts +45 -3
  206. package/src/langium/validation/document-validator.ts +255 -8
  207. package/src/langium/workspace/document-uri-policy.ts +3 -4
  208. package/src/langium/workspace/hydranium-langium-document-factory.ts +34 -0
  209. package/src/langium/workspace/hydranium-workspace-manager.ts +30 -1
  210. package/src/langium/workspace/in-memory-file-system-provider.ts +13 -3
  211. package/src/langium/workspace/initialize-workspace.ts +34 -5
  212. package/src/langium/workspace/langium-documents.ts +106 -33
  213. package/src/locale/index.ts +10 -0
  214. package/src/locale/server-locale.ts +86 -0
  215. package/src/lsp/hydranium-document-update-handler.ts +1 -1
  216. package/src/messages/carriers.ts +78 -0
  217. package/src/messages/index.ts +27 -0
  218. package/src/messages/renderer.ts +204 -0
  219. package/src/node/node-file-system-provider.ts +1 -41
  220. package/src/node/rename-over-open-readers.ts +78 -0
  221. package/src/testing/fake-document.ts +22 -4
  222. package/src/testing/make-noop-shared-services.ts +50 -2
  223. package/src/testing/make-test-services.ts +49 -7
  224. package/src/testing/playwright/server-log-capture.ts +24 -5
  225. package/src/testing/playwright/server-log-rename-reporter.ts +12 -1
  226. package/src/testing/stub-ast-document-manager.ts +5 -10
  227. package/src/testing/stub-document-builder.ts +32 -3
  228. package/src/testing/stub-model-service.ts +2 -2
@@ -0,0 +1,204 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import {
11
+ describeError,
12
+ renderFrameworkMessage,
13
+ resolve,
14
+ resolvedFromResponseError,
15
+ type MessageDefinition,
16
+ type ParamsArg,
17
+ type ResolvedMessage,
18
+ type Tracer
19
+ } from '@hydranium/protocol';
20
+ import { SimpleCache } from '@hydranium/langium';
21
+ import type { ResponseError } from 'vscode-jsonrpc';
22
+ import { Diagnostic } from 'vscode-languageserver-protocol';
23
+ import { type LogNameOptions } from '../langium/diagnostics/logger.js';
24
+ import type { ServerSharedServicesMinimal } from '../langium/shared-services.js';
25
+ import type { ServerLocale } from '../locale/server-locale.js';
26
+ import { resolvedFromDiagnostic } from './carriers.js';
27
+
28
+ /** Construction options for {@link DefaultMessageRenderer}. */
29
+ export type MessageRendererOptions = LogNameOptions;
30
+
31
+ /**
32
+ * The render contract the `MessageRenderer` slot holds, implemented by
33
+ * {@link DefaultMessageRenderer}.
34
+ *
35
+ * An interface rather than the class, so the slot is compared STRUCTURALLY. A
36
+ * class-typed slot carries its `protected` members into every assignability
37
+ * check and TypeScript compares those nominally — which makes the slot
38
+ * unsatisfiable by a subclass declared against a second physical copy of this
39
+ * package, the state a pre-publish `yalc` / `file:` install produces. It also
40
+ * lets an adopter REPLACE this declaration rather than intersect with it, so
41
+ * slot resolution does not depend on the order a services type is written in.
42
+ *
43
+ * Every method carries a no-throw contract; see the implementation.
44
+ */
45
+ export interface MessageRenderer {
46
+ renderDiagnostic(diagnostic: Diagnostic): string;
47
+ renderError(error: ResponseError<unknown>): string;
48
+ renderMessage<S extends string>(message: MessageDefinition<S>, ...params: ParamsArg<S>): string;
49
+ }
50
+
51
+ /**
52
+ * Renders every user-facing message the server sends, in the locale the server
53
+ * was handed at init. The framework ships no catalogue, so its own behaviour is
54
+ * a pass-through.
55
+ *
56
+ * Adopters override {@link translationsFor}: the public render methods carry
57
+ * the no-throw contract, and the catalogue lookup they wrap is what can fail.
58
+ *
59
+ * Two render methods rather than one, because what is useful to an adopter is
60
+ * the carrier's structured fields and the two carriers have different ones. A
61
+ * single `render(text)` would force an adopter to match English prose, which
62
+ * breaks on the first Langium reword.
63
+ */
64
+ export class DefaultMessageRenderer implements MessageRenderer {
65
+ protected readonly tracer: Tracer;
66
+ protected readonly serverLocale: ServerLocale;
67
+ /**
68
+ * {@link translationsFor}'s answer per locale, `undefined` answers included —
69
+ * a `SimpleCache` distinguishes "absent" from "cached as none", which matters
70
+ * because shipping no catalogue is the framework's own hot path.
71
+ *
72
+ * `SimpleCache` rather than Langium's `WorkspaceCache`: the eviction axis
73
+ * there is document change, and a catalogue does not depend on documents, so
74
+ * it would evict on every build AND put a `DocumentBuilder` dependency on a
75
+ * service the shared tier declares free of one. Nothing evicts this: the
76
+ * locale arrives once per process. A subclass whose catalogue can change
77
+ * clears it.
78
+ */
79
+ protected readonly catalogues = new SimpleCache<string | undefined, Record<string, string> | undefined>();
80
+
81
+ constructor(services: ServerSharedServicesMinimal, options: MessageRendererOptions = {}) {
82
+ this.serverLocale = services.ServerLocale;
83
+ this.tracer = services.Tracer.for(options.logName ?? 'MessageRenderer').trace('instantiated');
84
+ }
85
+
86
+ /**
87
+ * The sentence to publish for `diagnostic`, replacing its current `message`.
88
+ * Sees lexer and parser errors too, which Langium pushes onto the document
89
+ * without routing them through `toDiagnostic`.
90
+ *
91
+ * **Must not throw**, which is why the guard is here and not at the call
92
+ * site: this runs inside the `Validated` phase, and Langium's
93
+ * `notifyDocumentPhase` rethrows anything that is not a cancellation — so an
94
+ * escaping error leaves the document AT `Validated` with the phase's
95
+ * listeners never run, and the client receives no diagnostics for that file.
96
+ * A subclass overriding this method takes that contract on itself.
97
+ */
98
+ renderDiagnostic(diagnostic: Diagnostic): string {
99
+ try {
100
+ const resolved = resolvedFromDiagnostic(diagnostic);
101
+ return resolved ? this.render(resolved) : Diagnostic.getMessageString(diagnostic);
102
+ } catch (err: unknown) {
103
+ this.reportFailure(err, 'a diagnostic');
104
+ return Diagnostic.getMessageString(diagnostic);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The sentence to send for `error`, replacing its current `message`. Same
110
+ * no-throw contract as {@link renderDiagnostic}: an escaping error replaces a
111
+ * typed rejection the caller can handle with one it cannot.
112
+ */
113
+ renderError(error: ResponseError<unknown>): string {
114
+ try {
115
+ const resolved = resolvedFromResponseError(error);
116
+ return resolved ? this.render(resolved) : error.message;
117
+ } catch (err: unknown) {
118
+ this.reportFailure(err, 'an RPC error');
119
+ return error.message;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * The sentence for a declaration the caller holds directly, for a carrier
125
+ * with no slot to put an identity in.
126
+ *
127
+ * GLSP's action protocol is the case: every member of its message, status
128
+ * and reject actions is prose or an enum, so a code cannot travel and the
129
+ * raise site is the last place that still knows which message this is.
130
+ * Prefer {@link renderDiagnostic} / {@link renderError} wherever a carrier
131
+ * does hold the identity — they keep the render off the raise site, so an
132
+ * adopter's own messages ride the same one binding.
133
+ *
134
+ * Same no-throw contract as the carrier methods.
135
+ */
136
+ renderMessage<S extends string>(message: MessageDefinition<S>, ...params: ParamsArg<S>): string {
137
+ // `resolve` is inside the guard with the render, not before it: it calls
138
+ // the declaration's own `format`, which for an adopter declaration is
139
+ // arbitrary code, and a throw there would escape a method whose contract
140
+ // says it cannot.
141
+ let resolved: ResolvedMessage | undefined;
142
+ try {
143
+ resolved = resolve(message, ...params);
144
+ return this.render(resolved);
145
+ } catch (err: unknown) {
146
+ this.reportFailure(err, `the message '${message.code}'`);
147
+ // Unset only when `format` ITSELF threw, which leaves the
148
+ // uninterpolated template as the only text there is. A failed render
149
+ // still has the resolved English.
150
+ return resolved?.text ?? message.text;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The catalogue for the current locale, or `undefined` for none — the
156
+ * framework's answer, shipping none. Overriding this leaves every identity
157
+ * decision, every pass-through and the no-throw guard in place.
158
+ *
159
+ * **Called once per locale, not once per message**, so an override may load
160
+ * a file or build a map without that cost landing per diagnostic. The
161
+ * corollary is that a catalogue mutated in place afterwards is not seen;
162
+ * clear {@link catalogues} to invalidate.
163
+ *
164
+ * An adopter wanting Langium's own uncoded sentences instead matches
165
+ * `Diagnostic.data.code` from an overridden {@link renderDiagnostic}, never
166
+ * the sentence.
167
+ */
168
+ protected translationsFor(_locale: string | undefined): Record<string, string> | undefined {
169
+ return undefined;
170
+ }
171
+
172
+ /**
173
+ * Resolve one identity against the current locale's catalogue.
174
+ *
175
+ * Reads the locale per call. Copying it into a field at construction pins
176
+ * whatever was there before init ran, which is always `undefined` — services
177
+ * compose first.
178
+ *
179
+ * The catalogue behind it is memoized, because a workspace-wide validation
180
+ * renders once per diagnostic and the naive override — parse a JSON file,
181
+ * build a map — then pays that per diagnostic rather than per locale.
182
+ */
183
+ protected render(message: ResolvedMessage): string {
184
+ const locale = this.serverLocale.value;
185
+ return renderFrameworkMessage(
186
+ message,
187
+ this.catalogues.get(locale, () => this.translationsFor(locale))
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Emit a failed render. Logged rather than swallowed: the fallback output is
193
+ * byte-identical to a correctly-configured default, so a throwing catalogue
194
+ * is otherwise invisible.
195
+ */
196
+ protected reportFailure(err: unknown, carrier: string): void {
197
+ this.tracer.error(this.formatRenderFailure(err, carrier));
198
+ }
199
+
200
+ /** Format the failed-render line. Override to name the adopter's catalogue entry. */
201
+ protected formatRenderFailure(err: unknown, carrier: string): string {
202
+ return `rendering ${carrier} failed; falling back to the server's own text. ${describeError(err)}`;
203
+ }
204
+ }
@@ -15,6 +15,7 @@ import { realpathSync } from 'node:fs';
15
15
  import * as fsp from 'node:fs/promises';
16
16
  import { type WritableFileSystemProvider } from '../documents/ast-document-manager.js';
17
17
  import { type SelfSaveRegistry } from '../documents/self-save-registry.js';
18
+ import { renameOverOpenReaders } from './rename-over-open-readers.js';
18
19
  import { type LogNameOptions } from '../langium/diagnostics/logger.js';
19
20
  import { serverSharedFactory, type ServerSharedServicesMinimal } from '../langium/shared-services.js';
20
21
  import { serveVirtualDocument } from '../langium/workspace/virtual-document.js';
@@ -195,47 +196,6 @@ async function replacedFile(path: string): Promise<ReplacedFile | undefined> {
195
196
  }
196
197
  }
197
198
 
198
- /**
199
- * Errno values a rename reports on Windows when another handle holds the
200
- * destination open. POSIX renames are unaffected by open handles, so this
201
- * cannot fire there and the retry below costs nothing.
202
- */
203
- const RENAME_CONTENTION_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
204
-
205
- /**
206
- * `rename` the staged content over `destination`, retrying while Windows
207
- * reports the destination as held open.
208
- *
209
- * The staging file exists so a reader never sees a half-written file, and on
210
- * Windows the very presence of that reader is what makes the replacing rename
211
- * fail — so the indivisible write breaks under exactly the contention it was
212
- * built for, and it breaks intermittently, which is worse than never working.
213
- * A reader's handle is released in milliseconds, so waiting turns a spurious
214
- * failure into a slightly later success.
215
- *
216
- * BOUNDED, not open-ended: a genuine permission fault raises the same errno and
217
- * is distinguishable from contention only by never clearing, so an unbounded
218
- * wait would convert a hard error into a hang. Exhausting the budget rethrows
219
- * the last failure, leaving the caller's cleanup and error contract unchanged.
220
- */
221
- async function renameOverOpenReaders(staging: string, destination: string): Promise<void> {
222
- const deadline = Date.now() + 2_000;
223
- for (let attempt = 0; ; attempt++) {
224
- try {
225
- await fsp.rename(staging, destination);
226
- return;
227
- } catch (err: unknown) {
228
- const code = err instanceof Error && 'code' in err ? String((err as NodeJS.ErrnoException).code) : undefined;
229
- if (code === undefined || !RENAME_CONTENTION_CODES.has(code) || Date.now() >= deadline) {
230
- throw err;
231
- }
232
- // Backs off to keep a long contention window from spinning, capped so
233
- // a late attempt still lands promptly once the handle is released.
234
- await new Promise(resolve => setTimeout(resolve, Math.min(2 ** attempt, 50)));
235
- }
236
- }
237
- }
238
-
239
199
  /**
240
200
  * Module fragment that binds the writable Node {@link DefaultFileSystemProvider},
241
201
  * spread into the services context on a Node host.
@@ -0,0 +1,78 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import * as fsp from 'node:fs/promises';
11
+
12
+ /**
13
+ * Errnos Windows raises when a replacing `rename` meets a handle already on the
14
+ * destination open. POSIX renames are unaffected by open handles, so these
15
+ * cannot fire there and the retry below costs nothing.
16
+ */
17
+ export const RENAME_CONTENTION_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
18
+
19
+ /**
20
+ * How long a replacing rename may keep retrying before it gives up.
21
+ *
22
+ * BOUNDED, not open-ended: a genuine permission fault raises the same errno as
23
+ * contention and is distinguishable from it only by never clearing, so an
24
+ * unbounded wait would convert a hard error into a hang.
25
+ *
26
+ * The figure is a judgement, not a measurement, and the honest bound on it is
27
+ * that the previous 2s was observed EXHAUSTED on a loaded Windows CI runner
28
+ * while no upper bound has been established. What would settle it is recording
29
+ * how long a real contended rename takes to clear. graceful-fs tolerates 60s
30
+ * for Windows rename contention, but that is weaker support than it looks: it
31
+ * retries only where the destination has GONE, which is the opposite of
32
+ * replacing one a reader holds open.
33
+ */
34
+ export const RENAME_BUDGET_MS = 10_000;
35
+
36
+ /** Seams for the test, which cannot make a real rename fail on demand. */
37
+ export interface RenameOverOpenReadersOptions {
38
+ readonly rename?: (from: string, to: string) => Promise<void>;
39
+ readonly budgetMs?: number;
40
+ }
41
+
42
+ /**
43
+ * `rename` the staged content over `destination`, retrying while Windows
44
+ * reports the destination as held open.
45
+ *
46
+ * The staging file exists so a reader never sees a half-written file, and on
47
+ * Windows the very presence of that reader is what makes the replacing rename
48
+ * fail — so the indivisible write breaks under exactly the contention it was
49
+ * built for, and it breaks intermittently, which is worse than never working.
50
+ * A reader's handle is released in milliseconds, so waiting turns a spurious
51
+ * failure into a slightly later success.
52
+ *
53
+ * Exhausting the budget rethrows the last failure, leaving the caller's cleanup
54
+ * and error contract unchanged. An errno OUTSIDE the contention set is rethrown
55
+ * without waiting at all: retrying a fault that will not clear only delays it.
56
+ */
57
+ export async function renameOverOpenReaders(
58
+ staging: string,
59
+ destination: string,
60
+ options: RenameOverOpenReadersOptions = {}
61
+ ): Promise<void> {
62
+ const rename = options.rename ?? fsp.rename;
63
+ const deadline = Date.now() + (options.budgetMs ?? RENAME_BUDGET_MS);
64
+ for (let attempt = 0; ; attempt++) {
65
+ try {
66
+ await rename(staging, destination);
67
+ return;
68
+ } catch (err: unknown) {
69
+ const code = err instanceof Error && 'code' in err ? String((err as NodeJS.ErrnoException).code) : undefined;
70
+ if (code === undefined || !RENAME_CONTENTION_CODES.has(code) || Date.now() >= deadline) {
71
+ throw err;
72
+ }
73
+ // Backs off to keep a long contention window from spinning, capped so
74
+ // a late attempt still lands promptly once the handle is released.
75
+ await new Promise(resolve => setTimeout(resolve, Math.min(2 ** attempt, 50)));
76
+ }
77
+ }
78
+ }
@@ -7,7 +7,8 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { DocumentState, type AstNode, type LangiumDocument, UriUtils, type URI } from '@hydranium/langium';
10
+ import { DocumentState, type AstNode, type AstReflection, type LangiumDocument, UriUtils, type URI } from '@hydranium/langium';
11
+ import { buildAstNode } from '../langium/ast-extension/ast-node-builder.js';
11
12
 
12
13
  /**
13
14
  * Build a typed AST-node fixture for unit tests.
@@ -23,12 +24,29 @@ import { DocumentState, type AstNode, type LangiumDocument, UriUtils, type URI }
23
24
  * framework synthetic node, and marking it would change scope/index behaviour.
24
25
  * A test that wants a genuine synthetic node calls `markSynthetic` explicitly.
25
26
  *
27
+ * **Without a `reflection`, the fixture carries only what the test spells out**,
28
+ * which is a shape production never produces: a parsed or built node always has
29
+ * its grammar-declared containment arrays, so a fixture that omits one lets a
30
+ * test pass against an impossible node, and lets code that walks a list crash
31
+ * only under test. Pass the grammar's reflection whenever the type is a real
32
+ * one and the code under test reads a collection off it. Fixtures typed against
33
+ * ad-hoc names have no reflection to pass and keep the plain form.
34
+ *
26
35
  * @param node the node shape — `$type` is required; every other property is a
27
36
  * value the test cares about (`$container`, cross-reference values, internal
28
- * `_id` fields, …). The object is returned by reference, not copied.
37
+ * `_id` fields, …).
38
+ * @param reflection when given, pre-fills the grammar's declared defaults the
39
+ * same way {@link buildAstNode} does, and the result is a fresh object rather
40
+ * than `node` itself. Omitted, `node` is returned by reference.
29
41
  */
30
- export function makeFakeAstNode<TAst extends AstNode = AstNode>(node: { $type: string } & Record<string, unknown>): TAst {
31
- return node as unknown as TAst;
42
+ export function makeFakeAstNode<TAst extends AstNode = AstNode>(
43
+ node: { $type: string } & Record<string, unknown>,
44
+ reflection?: AstReflection
45
+ ): TAst {
46
+ if (!reflection) {
47
+ return node as unknown as TAst;
48
+ }
49
+ return buildAstNode<TAst>(reflection, node.$type, node);
32
50
  }
33
51
 
34
52
  /**
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { type Clock, DefaultTracer, type Logger, NoopLogger, SystemClock, type Tracer } from '@hydranium/protocol';
11
11
  import type { ServerSharedServicesMinimal } from '../langium/shared-services.js';
12
+ import { DefaultServerLocale, type ServerLocale } from '../locale/server-locale.js';
13
+ import { DefaultMessageRenderer, type MessageRenderer } from '../messages/renderer.js';
12
14
 
13
15
  /**
14
16
  * Overrides for {@link makeNoopSharedServices}. The three observability slots
@@ -29,6 +31,18 @@ export interface NoopSharedServicesOverrides {
29
31
  Logger?: Logger;
30
32
  /** Bound on the top-level `Tracer` slot. Default: a `DefaultTracer` over `Logger` + `Clock`. */
31
33
  Tracer?: Tracer;
34
+ /** Factory for the `ServerLocale` slot. Default: a real one, reporting no locale. */
35
+ ServerLocale?: (services: ServerSharedServicesMinimal) => ServerLocale;
36
+ /**
37
+ * Factory for the `MessageRenderer` slot. Default: the real
38
+ * {@link DefaultMessageRenderer}, whose no-catalogue behaviour is a
39
+ * pass-through — so the default is the framework's own behaviour, not a stub.
40
+ *
41
+ * A factory rather than an instance, because a renderer reads the tree it is
42
+ * bound into. Both slots are resolved lazily, so an override is honoured
43
+ * however late the caller reads them.
44
+ */
45
+ MessageRenderer?: (services: ServerSharedServicesMinimal) => MessageRenderer;
32
46
  /** Per-slot `workspace` overrides. Slots left out resolve to `undefined`. */
33
47
  workspace?: Record<string, unknown>;
34
48
  /** Any other minimal slot (`ServiceRegistry`, `AstReflection`, `additionalDocuments`, …). */
@@ -63,11 +77,21 @@ export interface NoopSharedServicesOverrides {
63
77
  export function makeNoopSharedServices<T extends ServerSharedServicesMinimal = ServerSharedServicesMinimal>(
64
78
  overrides: NoopSharedServicesOverrides = {}
65
79
  ): T {
66
- const { Clock: clock, Logger: logger, Tracer: tracer, workspace, additionalDocuments, ...restTop } = overrides;
80
+ const {
81
+ Clock: clock,
82
+ Logger: logger,
83
+ Tracer: tracer,
84
+ ServerLocale: serverLocale,
85
+ MessageRenderer: messageRenderer,
86
+ workspace,
87
+ additionalDocuments,
88
+ ...restTop
89
+ } = overrides;
67
90
  const resolvedClock = clock ?? new SystemClock();
68
91
  const resolvedLogger = logger ?? new NoopLogger();
69
92
  const resolvedTracer = tracer ?? new DefaultTracer(resolvedLogger, resolvedClock);
70
- return {
93
+ // Assembled in two steps, because both defaults read the tree they belong to.
94
+ const services = {
71
95
  Clock: resolvedClock,
72
96
  Logger: resolvedLogger,
73
97
  Tracer: resolvedTracer,
@@ -75,4 +99,28 @@ export function makeNoopSharedServices<T extends ServerSharedServicesMinimal = S
75
99
  ...restTop,
76
100
  workspace: { ...(workspace ?? {}) }
77
101
  } as unknown as T;
102
+ // Lazy, like Langium's own per-slot construction — and load-bearing here:
103
+ // both services emit an `instantiated` trace, so building them eagerly puts
104
+ // two lines into the capture of every test that passes a capturing logger
105
+ // and asserts on emptiness.
106
+ defineLazySlot(services, 'ServerLocale', () => serverLocale?.(services) ?? new DefaultServerLocale(services));
107
+ defineLazySlot(services, 'MessageRenderer', () => messageRenderer?.(services) ?? new DefaultMessageRenderer(services));
108
+ return services;
109
+ }
110
+
111
+ /** Install `name` as a memoized getter, so nothing is constructed until it is read. */
112
+ function defineLazySlot(target: object, name: string, create: () => unknown): void {
113
+ let resolved: unknown;
114
+ let built = false;
115
+ Object.defineProperty(target, name, {
116
+ configurable: true,
117
+ enumerable: true,
118
+ get: () => {
119
+ if (!built) {
120
+ resolved = create();
121
+ built = true;
122
+ }
123
+ return resolved;
124
+ }
125
+ });
78
126
  }
@@ -18,11 +18,13 @@ import {
18
18
  type TransferDiagnostic,
19
19
  type TransferElement
20
20
  } from '@hydranium/protocol';
21
+ import { DefaultServerLocale, type ServerLocale } from '../locale/server-locale.js';
22
+ import { DefaultMessageRenderer, type MessageRenderer } from '../messages/renderer.js';
21
23
  import type { Harness } from '@hydranium/protocol/testing';
22
24
  import { type AstNode, type AstNodeDescription, type WorkspaceLock } from '@hydranium/langium';
23
25
  import type { ModelService, ModelServiceOptions } from '../langium/model-service/model-service.js';
24
26
  import type { ServerSharedServices } from '../langium/module.js';
25
- import { TransferEncoder } from '../langium/transfer/transfer-encoder.js';
27
+ import { DefaultTransferEncoder, type TransferEncoder } from '../langium/transfer/transfer-encoder.js';
26
28
  import { DefaultDocumentUriPolicy, type DocumentUriPolicy } from '../langium/workspace/document-uri-policy.js';
27
29
  import { HydraniumWorkspaceLock } from '../langium/workspace/hydranium-workspace-lock.js';
28
30
  import type { FakeDocumentOptions } from './fake-document.js';
@@ -90,9 +92,18 @@ export interface TestSharedServices<
90
92
  WorkspaceLock: WorkspaceLock;
91
93
  };
92
94
  readonly model: {
93
- TransferEncoder: TransferEncoder<unknown, TDiagnostic>;
95
+ TransferEncoder: TransferEncoder<TDiagnostic>;
94
96
  ModelService: ModelService<TAst, TDiagnostic, TTransfer>;
95
97
  };
98
+ /**
99
+ * The REAL services, not stubs — the framework renderer's no-catalogue
100
+ * behaviour is a pass-through, so the stub tree agrees with a production one
101
+ * unless a test installs a catalogue. Bound rather than omitted because
102
+ * `HydraniumDocumentBuilder` renders through the renderer on every
103
+ * `Validated` phase, where an omitted slot is a `TypeError`.
104
+ */
105
+ readonly ServerLocale: ServerLocale;
106
+ readonly MessageRenderer: MessageRenderer;
96
107
  }
97
108
 
98
109
  /** Optional configuration for {@link makeTestServices}. */
@@ -112,6 +123,14 @@ export interface MakeTestServicesOptions<
112
123
  * for tests that don't assert on the produced text.
113
124
  */
114
125
  serialize?: (uri: string, root: TTransfer) => string;
126
+ /**
127
+ * Renderer bound on `MessageRenderer`. Default: the framework's own, which
128
+ * passes every sentence through unchanged. Supply one to install a catalogue
129
+ * or to drive the throwing path.
130
+ */
131
+ messageRenderer?: (services: ServerSharedServices<TProject>) => MessageRenderer;
132
+ /** Locale handed to the bundle's {@link ServerLocale}. Default: none, i.e. the framework's English. */
133
+ locale?: string;
115
134
  /**
116
135
  * Languages to register on a {@link StubServiceRegistry} bound on the
117
136
  * `ServiceRegistry` slot. This is how a test gets multi-language routing:
@@ -187,7 +206,7 @@ export interface MakeTestServicesOptions<
187
206
  * Override the {@link TransferEncoder} factory. Default: framework
188
207
  * {@link TransferEncoder} with no overrides.
189
208
  */
190
- transferEncoder?: (services: ServerSharedServices<TProject>) => TransferEncoder<unknown, TDiagnostic>;
209
+ transferEncoder?: (services: ServerSharedServices<TProject>) => TransferEncoder<TDiagnostic>;
191
210
  }
192
211
 
193
212
  /**
@@ -229,10 +248,18 @@ export interface TestServicesBundle<
229
248
  */
230
249
  readonly indexManager: StubIndexManager | undefined;
231
250
  readonly modelService: ModelService<TAst, TDiagnostic, TTransfer>;
232
- readonly transferEncoder: TransferEncoder<unknown, TDiagnostic>;
251
+ readonly transferEncoder: TransferEncoder<TDiagnostic>;
233
252
  readonly logger: Logger;
234
253
  /** The clock bound on the `Clock` slot — a `makeFakeClock()` if one was passed. */
235
254
  readonly clock: Clock;
255
+ /**
256
+ * The service bound on `ServerLocale`, so a test can hand over a locale
257
+ * mid-run — which is also how it verifies the renderer reads the locale per
258
+ * render rather than caching it at construction.
259
+ */
260
+ readonly serverLocale: ServerLocale;
261
+ /** The renderer bound on `MessageRenderer` — the framework's own unless one was supplied. */
262
+ readonly messageRenderer: MessageRenderer;
236
263
  }
237
264
 
238
265
  /**
@@ -311,20 +338,33 @@ export function makeTestServices<
311
338
  ...(indexManager ? { IndexManager: indexManager } : {}),
312
339
  WorkspaceLock: new HydraniumWorkspaceLock()
313
340
  },
314
- model: {} as TestSharedServices<TAst, TDiagnostic, TTransfer, TProject>['model']
341
+ model: {} as TestSharedServices<TAst, TDiagnostic, TTransfer, TProject>['model'],
342
+ ServerLocale: {} as ServerLocale,
343
+ MessageRenderer: {} as DefaultMessageRenderer
315
344
  };
316
345
  const sharedServices = services as unknown as ServerSharedServices<TProject>;
317
346
 
347
+ // Patched in after the literal, like `model` below: both read the tree they
348
+ // belong to, and the renderer reads the locale service.
349
+ const mutableMessages = services as { ServerLocale: ServerLocale; MessageRenderer: MessageRenderer };
350
+ const serverLocale = new DefaultServerLocale(sharedServices);
351
+ if (options.locale) {
352
+ serverLocale.accept(options.locale);
353
+ }
354
+ mutableMessages.ServerLocale = serverLocale;
355
+ const messageRenderer = options.messageRenderer?.(sharedServices) ?? new DefaultMessageRenderer(sharedServices);
356
+ mutableMessages.MessageRenderer = messageRenderer;
357
+
318
358
  const serialize = options.serialize ?? ((_uri: string, root: TTransfer) => JSON.stringify(root));
319
359
  const transferEncoder = options.transferEncoder
320
360
  ? options.transferEncoder(sharedServices)
321
- : new TransferEncoder<unknown, TDiagnostic>(sharedServices);
361
+ : new DefaultTransferEncoder<unknown, TDiagnostic>(sharedServices);
322
362
  const modelService = options.modelService
323
363
  ? options.modelService(sharedServices)
324
364
  : makeStubModelService<TAst, TDiagnostic, TTransfer>(sharedServices, serialize, options.modelServiceOptions);
325
365
 
326
366
  const mutableModel = services.model as {
327
- TransferEncoder: TransferEncoder<unknown, TDiagnostic>;
367
+ TransferEncoder: TransferEncoder<TDiagnostic>;
328
368
  ModelService: ModelService<TAst, TDiagnostic, TTransfer>;
329
369
  };
330
370
  mutableModel.TransferEncoder = transferEncoder;
@@ -346,6 +386,8 @@ export function makeTestServices<
346
386
  transferEncoder,
347
387
  logger,
348
388
  clock,
389
+ serverLocale,
390
+ messageRenderer,
349
391
  dispose: () => {
350
392
  // No-op: the bundled stubs hold only in-memory state (maps, arrays),
351
393
  // released with the bundle when it goes out of scope. The hook exists
@@ -160,16 +160,28 @@ export function serverLogSpecName(file: string): string {
160
160
  * Rename each `<token>.log` (backend) and `<token>.browser.log` (forwarded
161
161
  * browser console) to the spec name recorded in its `<token>.spec` sidecar
162
162
  * (written by {@link markServerLog}), then delete the sidecar. Colliding names —
163
- * a spec that opened more than one workspace — get a `-2`, `-3`, … suffix. Token
164
- * logs without a sidecar are left untouched. Run once, after all server processes
165
- * have exited (e.g. a reporter's `onEnd`). Best-effort.
163
+ * a spec that opened more than one workspace — get a `-2`, `-3`, … suffix. Run
164
+ * once, after all server processes have exited (e.g. a reporter's `onEnd`).
165
+ * Best-effort.
166
+ *
167
+ * Returns the workspace tokens left UNATTRIBUTED — a log the capture wrote that
168
+ * no test claimed, because that spec never reached
169
+ * {@link markServerLog} (no `serverLog` fixture, no `beforeEach` call).
170
+ *
171
+ * **Returned rather than ignored, because the unattributed state is the
172
+ * misleading one.** Such a log still exists, still has content, and is named by
173
+ * an opaque token with no test boundaries in it — so a capture run that covered
174
+ * nothing looks exactly like one that covered everything, and the logs are
175
+ * there to be read. A caller that can warn should warn.
166
176
  */
167
- export function renameServerLogs(dir: string): void {
177
+ export function renameServerLogs(dir: string): string[] {
168
178
  let entries: string[];
169
179
  try {
170
180
  entries = readdirSync(dir);
171
181
  } catch {
172
- return;
182
+ // An unreadable capture dir has nothing to report — including nothing
183
+ // unattributed, since no log was observed at all.
184
+ return [];
173
185
  }
174
186
  // Per suffix (`.log`, `.browser.log`) a `taken` set guards collisions, so the
175
187
  // backend and browser logs for one spec share the same friendly base name.
@@ -190,6 +202,11 @@ export function renameServerLogs(dir: string): void {
190
202
  // ignore
191
203
  }
192
204
  };
205
+ // Every token the capture produced a backend log for, so the ones no sidecar
206
+ // claims can be reported rather than silently left behind.
207
+ const unattributed = new Set(
208
+ entries.filter(entry => entry.endsWith('.log') && !entry.endsWith('.browser.log')).map(entry => entry.slice(0, -'.log'.length))
209
+ );
193
210
  for (const entry of entries) {
194
211
  if (!entry.endsWith('.spec')) {
195
212
  continue;
@@ -210,9 +227,11 @@ export function renameServerLogs(dir: string): void {
210
227
  if (!name) {
211
228
  continue;
212
229
  }
230
+ unattributed.delete(token);
213
231
  rename(token, name, '.log');
214
232
  rename(token, name, '.browser.log');
215
233
  }
234
+ return [...unattributed];
216
235
  }
217
236
 
218
237
  /**
@@ -24,6 +24,17 @@ export default class ServerLogRenameReporter {
24
24
  }
25
25
 
26
26
  onEnd(): void {
27
- renameServerLogs(this.dir);
27
+ const unattributed = renameServerLogs(this.dir);
28
+ if (unattributed.length > 0) {
29
+ // Loud, because the alternative is a capture run that reads as
30
+ // complete: these logs exist and have content, but are named by an
31
+ // opaque workspace token and carry no test boundaries, so nothing
32
+ // about them says which spec they belong to.
33
+ console.warn(
34
+ `[server-log] ${unattributed.length} captured log(s) belong to no test: ${unattributed.join(', ')}.\n` +
35
+ '[server-log] Those specs never marked a boundary — apply the `serverLog` fixture, ' +
36
+ 'or call `markServerLog`/`attachServerLog` from the suite, or their logs cannot be read per test.'
37
+ );
38
+ }
28
39
  }
29
40
  }