@hydranium/conformance 1.0.0-next.24 → 1.0.0-next.241

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lsp/index.ts CHANGED
@@ -11,10 +11,11 @@
11
11
  * The `@hydranium/conformance/lsp` slice — protocol conformance for the LSP
12
12
  * head. The driver port is a STRUCTURAL minimum (`LspConformanceDriver`) that
13
13
  * `@hydranium/core/testing/node`'s `LspHarness` satisfies with NO adapter: the kit
14
- * names only `string`, plain coordinates, and tiny structural minima
14
+ * names only strings, plain coordinates, and tiny structural minima
15
15
  * (`{ message }`, `{ items }`, `{ capabilities }`), never
16
16
  * `vscode-languageserver-protocol` wire types, so the harness's richer return
17
- * types are assignable to the narrower port.
17
+ * types are assignable to the narrower port. An alias of `string` is still a
18
+ * string for that purpose and stays inside the rule; a wire type does not.
18
19
  *
19
20
  * **This slice does not read `LanguageFixture.edit` at all.** The didChange
20
21
  * check drives `valid → invalid` using `invalid.text`, so there is no
@@ -23,9 +24,21 @@
23
24
  */
24
25
 
25
26
  import assert from 'node:assert/strict';
27
+ import type { Locale } from '@hydranium/protocol';
26
28
  import type { Harness } from '@hydranium/protocol/testing';
27
29
  import type { ConformanceCheck } from '../conformance-suite.js';
28
- import { type LanguageFixture, resolveDeferred, resolveModel } from '../model.js';
30
+ import { type ConformanceModel, type LanguageFixture, resolveDeferred, resolveModel } from '../model.js';
31
+
32
+ /**
33
+ * Structural minimum of the `initialize` params the kit sends — only `locale`,
34
+ * which is the one field a check needs to vary.
35
+ *
36
+ * A real `InitializeParams` is assignable to this, so a driver typed against
37
+ * upstream's shape satisfies the port with no adapter.
38
+ */
39
+ export interface LspConformanceInitializeParams {
40
+ readonly locale?: Locale;
41
+ }
29
42
 
30
43
  /** Structural minimum of an LSP `InitializeResult` — only the baseline capabilities the kit asserts. */
31
44
  export interface LspConformanceInitializeResult {
@@ -76,8 +89,17 @@ function hasTextMessage(diagnostic: LspConformanceDiagnostic): boolean {
76
89
  * gives the kit the universal `dispose()` teardown.
77
90
  */
78
91
  export interface LspConformanceDriver extends Harness {
79
- /** Drive the `initialize` → `initialized` handshake; resolve with the (structurally-minimal) result. */
80
- initialize(): Promise<LspConformanceInitializeResult>;
92
+ /**
93
+ * Drive the `initialize` → `initialized` handshake; resolve with the
94
+ * (structurally-minimal) result.
95
+ *
96
+ * `params` is optional and every field in it is too, so a driver that ignores
97
+ * the argument entirely still satisfies this port — which is what keeps
98
+ * adding a field here from being a breaking change. `locale` is the reading
99
+ * user's language, declared by the client because only the client knows it;
100
+ * the render check below is the only caller that passes anything.
101
+ */
102
+ initialize(params?: LspConformanceInitializeParams): Promise<LspConformanceInitializeResult>;
81
103
  /** Send `didOpen` for `uri` with full `text` under `languageId`. */
82
104
  openDocument(uri: string, text: string, languageId: string, version?: number): void;
83
105
  /** Send `didChange` for `uri` as a single full-text replacement at `version`. */
@@ -177,7 +199,7 @@ export function buildLspChecks(options: LspConformanceOptions): ConformanceCheck
177
199
  });
178
200
 
179
201
  for (const language of options.languages) {
180
- const { valid, invalid, completionPosition } = language;
202
+ const { valid, invalid, completionPosition, renderedDiagnostic } = language;
181
203
  const tag = `[${valid.languageId}]`;
182
204
 
183
205
  checks.push({
@@ -273,7 +295,88 @@ export function buildLspChecks(options: LspConformanceOptions): ConformanceCheck
273
295
  skipReason: 'fixture supplied no completionPosition'
274
296
  });
275
297
  }
298
+
299
+ // Opt-in: server-side rendering runs only when the fixture names a locale
300
+ // and the sentence it expects in it. The framework ships no catalogue, so
301
+ // a server that renders nothing is CORRECT and must not be failed.
302
+ const renderTitle = `a diagnostic is published rendered in the locale initialize declared ${tag}`;
303
+ const renderControlTitle = `the same diagnostic is NOT rendered when no locale is declared ${tag}`;
304
+ if (renderedDiagnostic) {
305
+ checks.push({
306
+ title: renderTitle,
307
+ body: async () => {
308
+ const published = await publishedMessagesFor(connect, invalid, { locale: renderedDiagnostic.locale });
309
+ assert.ok(
310
+ published.some(message => message.includes(renderedDiagnostic.expected)),
311
+ `no published diagnostic contained ${JSON.stringify(renderedDiagnostic.expected)}: ${JSON.stringify(published)}`
312
+ );
313
+ }
314
+ });
315
+
316
+ // The second half of the pair. "Contains X" also passes for a server
317
+ // whose English contains X, and for one that renders whatever the
318
+ // locale — so the discriminating read is the fragment that must
319
+ // disappear. Reported as skipped rather than silently dropped when the
320
+ // fixture omits it, because a lone containment check IS weaker and a
321
+ // reader has to be able to see that from the report.
322
+ if (renderedDiagnostic.absentWithLocale) {
323
+ const { absentWithLocale } = renderedDiagnostic;
324
+ checks.push({
325
+ title: renderControlTitle,
326
+ body: async () => {
327
+ const withLocale = await publishedMessagesFor(connect, invalid, { locale: renderedDiagnostic.locale });
328
+ assert.ok(
329
+ !withLocale.some(message => message.includes(absentWithLocale)),
330
+ `a diagnostic still contained ${JSON.stringify(absentWithLocale)} with the locale declared: ${JSON.stringify(withLocale)}`
331
+ );
332
+
333
+ // And present without it, which is what rules out a fragment
334
+ // that never appears in either state — a typo in the fixture
335
+ // would otherwise make the assertion above pass for free.
336
+ const withoutLocale = await publishedMessagesFor(connect, invalid);
337
+ assert.ok(
338
+ withoutLocale.some(message => message.includes(absentWithLocale)),
339
+ `no diagnostic contained ${JSON.stringify(absentWithLocale)} with no locale declared, so it cannot witness the render: ${JSON.stringify(withoutLocale)}`
340
+ );
341
+ }
342
+ });
343
+ } else {
344
+ checks.push({
345
+ title: renderControlTitle,
346
+ skipReason: 'fixture supplied no absentWithLocale, so the render check is a containment test only'
347
+ });
348
+ }
349
+ } else {
350
+ checks.push({ title: renderTitle, skipReason: 'fixture supplied no renderedDiagnostic (server-side rendering is opt-in)' });
351
+ checks.push({ title: renderControlTitle, skipReason: 'fixture supplied no renderedDiagnostic (server-side rendering is opt-in)' });
352
+ }
276
353
  }
277
354
 
278
355
  return checks;
279
356
  }
357
+
358
+ /**
359
+ * Open `model` on a fresh driver and return the messages of the diagnostics
360
+ * published for it, as plain strings.
361
+ *
362
+ * A fresh driver per call because `initialize` is once-only per connection and
363
+ * the locale rides it — so the two states this compares cannot share one.
364
+ */
365
+ async function publishedMessagesFor(
366
+ connect: LspConformanceOptions['connect'],
367
+ model: ConformanceModel,
368
+ params?: LspConformanceInitializeParams
369
+ ): Promise<string[]> {
370
+ const driver = await connect();
371
+ try {
372
+ await driver.initialize(params);
373
+ const resolved = resolveModel(model);
374
+ const diagnostics = driver.nextDiagnostics(resolved.uri);
375
+ driver.openDocument(resolved.uri, resolved.text, resolved.languageId);
376
+ return (await diagnostics).map(diagnostic =>
377
+ typeof diagnostic.message === 'string' ? diagnostic.message : (diagnostic.message?.value ?? '')
378
+ );
379
+ } finally {
380
+ driver.dispose();
381
+ }
382
+ }
package/src/model.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
+ import type { Locale } from '@hydranium/protocol';
11
+
10
12
  /**
11
13
  * A fixture value that may be given directly or DEFERRED to check time.
12
14
  *
@@ -79,12 +81,53 @@ export function resolveModel(model: ConformanceModel): ResolvedConformanceModel
79
81
  export interface EditSpec {
80
82
  /** Replacement model text; {@link Deferred} for the same reasons as `ConformanceModel.text`. */
81
83
  readonly to: Deferred<string>;
84
+ /** True of the root after the edit and false of `valid`'s root, so a check can tell the two apart. */
82
85
  readonly expect: (root: unknown) => boolean;
83
86
  }
84
87
 
88
+ /**
89
+ * A reference-picker query for an element that does not exist yet, plus the
90
+ * candidate the adopter expects it to offer.
91
+ *
92
+ * **The URI a create-element flow holds is a FOLDER**, because the file is not
93
+ * written until the dialog is confirmed. A folder URI names no file and so
94
+ * carries no extension, which is the one shape a head cannot route to a grammar
95
+ * by URI alone — it has to resolve the language some other way. That makes this
96
+ * the create dialog's load-bearing precondition and the reason the query is
97
+ * worth a conformance check of its own: a head that gets it wrong answers no
98
+ * candidates or throws, and the dialog never opens.
99
+ */
100
+ export interface ReferenceQuerySpec {
101
+ /** AST type of the element being created — the synthetic source's own type. */
102
+ readonly type: string;
103
+ /** The reference property on the source (or on `syntheticPath`'s leaf) whose candidates the picker fills. */
104
+ readonly property: string;
105
+ /**
106
+ * Steps from the synthetic source down to the node holding `property`, when
107
+ * the reference is not on the source itself. Each step is
108
+ * `[containerProperty, type]` — the kit builds the `SyntheticStep`s, so the
109
+ * fixture names no protocol type.
110
+ */
111
+ readonly path?: ReadonlyArray<readonly [containerProperty: string, type: string]>;
112
+ /**
113
+ * Folder the create flow asks at. {@link Deferred} because a fixture may name
114
+ * a workspace the driver's `connect` only just created. Defaults to the parent
115
+ * of `valid.uri`, which is the folder a sibling of the valid model would go
116
+ * into — the common case, so most fixtures supply only `type` + `property`.
117
+ */
118
+ readonly folderUri?: Deferred<string>;
119
+ /**
120
+ * A candidate label the query MUST offer. Without it an empty result passes,
121
+ * and empty is exactly what the defect this check exists for produces — so
122
+ * the expectation is what makes the check discriminating rather than a
123
+ * smoke test.
124
+ */
125
+ readonly expectCandidate: string;
126
+ }
127
+
85
128
  /**
86
129
  * The per-language fixture. `valid` and `invalid` are defined once and reused
87
- * across heads; the two extras are per-head opt-ins.
130
+ * across heads; every other field is a per-head opt-in.
88
131
  *
89
132
  * Requiring BOTH a valid and an invalid model is the false-green guard — a
90
133
  * vacuous "invalid" model that actually parses clean fails the diagnostics
@@ -98,13 +141,18 @@ export interface EditSpec {
98
141
  * uses `invalid.text` and never calls `edit.expect`, so an LSP-only adopter
99
142
  * has nothing to supply here.
100
143
  * - `completionPosition` — read by the **LSP slice only**.
144
+ * - `referenceQuery` — read by the **data slice only**, and only when the
145
+ * driver supplies `references` (the reference surface is opt-in on the head
146
+ * too, so both halves have to be present for the check to run).
147
+ * - `dependent` and `breakingEdit` — read by the **data slice only**.
148
+ * - `renderedDiagnostic` — read by the **LSP slice only**.
101
149
  *
102
- * Both extras are optional and their checks report *skipped* when absent,
103
- * rather than silently not running. Making either mandatory would defeat the
104
- * shared fixture: the head that ignores the field has to invent a value for it,
105
- * and the two heads' inventions drift apart until one fixture contradicts the
106
- * other. A required field that one head ignores does not produce a shared
107
- * fixture; it produces a misleading one.
150
+ * The opt-ins are optional and their checks report *skipped* when absent,
151
+ * rather than silently not running. Making any of them mandatory would defeat
152
+ * the shared fixture: the head that ignores the field has to invent a value
153
+ * for it, and the two heads' inventions drift apart until one fixture
154
+ * contradicts the other. A required field that one head ignores does not
155
+ * produce a shared fixture; it produces a misleading one.
108
156
  *
109
157
  * The GLSP fixture is separate (per-diagram-type, generic over the adopter
110
158
  * action type) — see `./glsp`.
@@ -114,9 +162,87 @@ export interface LanguageFixture {
114
162
  readonly invalid: ConformanceModel;
115
163
  /**
116
164
  * Optional: an edit plus its observability assertion. The data slice's
117
- * round-trip and subscription-delivery checks run only when it is supplied.
165
+ * checks that need a second, observably different text run only when it is
166
+ * supplied.
118
167
  */
119
168
  readonly edit?: EditSpec;
120
169
  /** Optional: a position at which the LSP completion check requests completion. */
121
170
  readonly completionPosition?: { readonly line: number; readonly character: number };
171
+ /**
172
+ * Optional: the create-dialog reference query. Read by the **data slice
173
+ * only**, and only when the driver exposes the opt-in reference surface.
174
+ */
175
+ readonly referenceQuery?: ReferenceQuerySpec;
176
+ /**
177
+ * Optional: a second document that REFERENCES {@link valid}, so the data
178
+ * slice can provoke a CASCADE — a rebuild of this document caused by
179
+ * editing the one it points at, with its own text never touched.
180
+ *
181
+ * Opt-in because a grammar need not have cross-document references at all,
182
+ * and because only the adopter knows which pair of documents forms one.
183
+ * Supplying it IS the claim that editing `valid` rebuilds this document; the
184
+ * check then holds the head to reporting that on `onDocumentsBuilt`, which
185
+ * is the only channel that can carry it — the document has no subscriber and
186
+ * its file did not change, so neither the update channel nor a filesystem
187
+ * watcher can.
188
+ */
189
+ readonly dependent?: ConformanceModel;
190
+ /**
191
+ * Optional: a replacement text for {@link valid} that changes what
192
+ * {@link dependent} shows, typically by breaking its reference into
193
+ * `valid`, so that the dependent reports a diagnostic. Read by the **data
194
+ * slice only**, and only with `dependent`.
195
+ *
196
+ * {@link edit} does not serve here: an edit that leaves the dependent as it
197
+ * was may send a watcher of the dependent nothing, as the framework's head
198
+ * suppresses an update that changes nothing observable. Supplying this is
199
+ * the claim that writing it makes a watched `dependent` receive an update
200
+ * event, which the check then holds to being a rebuild that names no
201
+ * client.
202
+ */
203
+ readonly breakingEdit?: Deferred<string>;
204
+ /**
205
+ * Optional: a locale plus the sentence the server must publish in it. Read
206
+ * by the **LSP slice only**.
207
+ */
208
+ readonly renderedDiagnostic?: RenderedDiagnosticSpec;
209
+ }
210
+
211
+ /**
212
+ * A locale, and one sentence the server must produce in it for the `invalid`
213
+ * fixture.
214
+ *
215
+ * **Opt-in, and it has to be.** The framework ships no catalogue and selects no
216
+ * locale, so a server that installs no renderer correctly publishes English —
217
+ * mandating this check would fail every adopter without i18n for doing the right
218
+ * thing. Supplying the field is the adopter saying "I render server-side, hold me
219
+ * to it".
220
+ *
221
+ * `expected` is a SUBSTRING, not the whole message. The kit owns no grammar, so
222
+ * it cannot know how many diagnostics `invalid` produces or in what order, and
223
+ * an adopter should be able to pin the translated fragment without restating a
224
+ * sentence they may reword. A substring long enough to be wrong if the render
225
+ * did not happen is the whole requirement.
226
+ *
227
+ * `absentWithLocale` is what makes the check a pair rather than a single
228
+ * assertion: "the message contains X" also passes for a server whose English
229
+ * happens to contain X, and for one that renders regardless of locale. Naming
230
+ * the fragment that must disappear once the locale is declared is what
231
+ * distinguishes those.
232
+ */
233
+ export interface RenderedDiagnosticSpec {
234
+ /** The locale to declare at `initialize` — the tag whose catalogue the server has. */
235
+ readonly locale: Locale;
236
+ /** A fragment of the translated sentence, present in some diagnostic of the `invalid` fixture. */
237
+ readonly expected: string;
238
+ /**
239
+ * A fragment that must be absent once `locale` is declared, and present
240
+ * without it — normally a piece of the server's own English.
241
+ *
242
+ * Optional only because a catalogue may translate a message whose English
243
+ * shares no distinctive fragment with it. Omitting it drops the second half
244
+ * of the pair and leaves a check that a render-nothing server can pass; the
245
+ * kit reports that rather than pretending otherwise.
246
+ */
247
+ readonly absentWithLocale?: string;
122
248
  }