@hydranium/conformance 1.0.0-next.11 → 1.0.0-next.112

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 {
@@ -51,10 +64,20 @@ export interface LspConformanceDiagnostic {
51
64
  readonly message: string | { readonly value: string };
52
65
  }
53
66
 
54
- /** True when `diagnostic` carries a message in either LSP shape (plain string or markup). */
67
+ /**
68
+ * True when `diagnostic` carries a NON-EMPTY message in either LSP shape
69
+ * (plain string or markup).
70
+ *
71
+ * Emptiness is part of the claim, not a refinement of it: `message: ''` is a
72
+ * diagnostic a user cannot act on, and a type-only test admits it — which
73
+ * makes "every diagnostic carries a message" pass for a head that publishes
74
+ * none. LSP declares the field required without forbidding the empty string,
75
+ * so nothing upstream rules it out either.
76
+ */
55
77
  function hasTextMessage(diagnostic: LspConformanceDiagnostic): boolean {
56
78
  const { message } = diagnostic;
57
- return typeof message === 'string' || typeof message?.value === 'string';
79
+ const text = typeof message === 'string' ? message : message?.value;
80
+ return typeof text === 'string' && text.length > 0;
58
81
  }
59
82
 
60
83
  /**
@@ -66,8 +89,17 @@ function hasTextMessage(diagnostic: LspConformanceDiagnostic): boolean {
66
89
  * gives the kit the universal `dispose()` teardown.
67
90
  */
68
91
  export interface LspConformanceDriver extends Harness {
69
- /** Drive the `initialize` → `initialized` handshake; resolve with the (structurally-minimal) result. */
70
- 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>;
71
103
  /** Send `didOpen` for `uri` with full `text` under `languageId`. */
72
104
  openDocument(uri: string, text: string, languageId: string, version?: number): void;
73
105
  /** Send `didChange` for `uri` as a single full-text replacement at `version`. */
@@ -167,7 +199,7 @@ export function buildLspChecks(options: LspConformanceOptions): ConformanceCheck
167
199
  });
168
200
 
169
201
  for (const language of options.languages) {
170
- const { valid, invalid, completionPosition } = language;
202
+ const { valid, invalid, completionPosition, renderedDiagnostic } = language;
171
203
  const tag = `[${valid.languageId}]`;
172
204
 
173
205
  checks.push({
@@ -199,7 +231,7 @@ export function buildLspChecks(options: LspConformanceOptions): ConformanceCheck
199
231
  driver.openDocument(model.uri, model.text, model.languageId);
200
232
  const published = await diagnostics;
201
233
  assert.ok(published.length >= 1, 'didOpen(invalid) published no diagnostics');
202
- assert.ok(published.every(hasTextMessage), 'a published diagnostic was missing a message');
234
+ assert.ok(published.every(hasTextMessage), 'a published diagnostic was missing a non-empty message');
203
235
  } finally {
204
236
  driver.dispose();
205
237
  }
@@ -263,7 +295,88 @@ export function buildLspChecks(options: LspConformanceOptions): ConformanceCheck
263
295
  skipReason: 'fixture supplied no completionPosition'
264
296
  });
265
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
+ }
266
353
  }
267
354
 
268
355
  return checks;
269
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
  *
@@ -82,6 +84,46 @@ export interface EditSpec {
82
84
  readonly expect: (root: unknown) => boolean;
83
85
  }
84
86
 
87
+ /**
88
+ * A reference-picker query for an element that does not exist yet, plus the
89
+ * candidate the adopter expects it to offer.
90
+ *
91
+ * **The URI a create-element flow holds is a FOLDER**, because the file is not
92
+ * written until the dialog is confirmed. A folder URI names no file and so
93
+ * carries no extension, which is the one shape a head cannot route to a grammar
94
+ * by URI alone — it has to resolve the language some other way. That makes this
95
+ * the create dialog's load-bearing precondition and the reason the query is
96
+ * worth a conformance check of its own: a head that gets it wrong answers no
97
+ * candidates or throws, and the dialog never opens.
98
+ */
99
+ export interface ReferenceQuerySpec {
100
+ /** AST type of the element being created — the synthetic source's own type. */
101
+ readonly type: string;
102
+ /** The reference property on the source (or on `syntheticPath`'s leaf) whose candidates the picker fills. */
103
+ readonly property: string;
104
+ /**
105
+ * Steps from the synthetic source down to the node holding `property`, when
106
+ * the reference is not on the source itself. Each step is
107
+ * `[containerProperty, type]` — the kit builds the `SyntheticStep`s, so the
108
+ * fixture names no protocol type.
109
+ */
110
+ readonly path?: ReadonlyArray<readonly [containerProperty: string, type: string]>;
111
+ /**
112
+ * Folder the create flow asks at. {@link Deferred} because a fixture may name
113
+ * a workspace the driver's `connect` only just created. Defaults to the parent
114
+ * of `valid.uri`, which is the folder a sibling of the valid model would go
115
+ * into — the common case, so most fixtures supply only `type` + `property`.
116
+ */
117
+ readonly folderUri?: Deferred<string>;
118
+ /**
119
+ * A candidate label the query MUST offer. Without it an empty result passes,
120
+ * and empty is exactly what the defect this check exists for produces — so
121
+ * the expectation is what makes the check discriminating rather than a
122
+ * smoke test.
123
+ */
124
+ readonly expectCandidate: string;
125
+ }
126
+
85
127
  /**
86
128
  * The per-language fixture. `valid` and `invalid` are defined once and reused
87
129
  * across heads; the two extras are per-head opt-ins.
@@ -98,6 +140,9 @@ export interface EditSpec {
98
140
  * uses `invalid.text` and never calls `edit.expect`, so an LSP-only adopter
99
141
  * has nothing to supply here.
100
142
  * - `completionPosition` — read by the **LSP slice only**.
143
+ * - `referenceQuery` — read by the **data slice only**, and only when the
144
+ * driver supplies `references` (the reference surface is opt-in on the head
145
+ * too, so both halves have to be present for the check to run).
101
146
  *
102
147
  * Both extras are optional and their checks report *skipped* when absent,
103
148
  * rather than silently not running. Making either mandatory would defeat the
@@ -119,4 +164,67 @@ export interface LanguageFixture {
119
164
  readonly edit?: EditSpec;
120
165
  /** Optional: a position at which the LSP completion check requests completion. */
121
166
  readonly completionPosition?: { readonly line: number; readonly character: number };
167
+ /**
168
+ * Optional: the create-dialog reference query. Read by the **data slice
169
+ * only**, and only when the driver exposes the opt-in reference surface.
170
+ */
171
+ readonly referenceQuery?: ReferenceQuerySpec;
172
+ /**
173
+ * Optional: a second document that REFERENCES {@link valid}, so the data
174
+ * slice can provoke a CASCADE — a rebuild of this document caused by
175
+ * editing the one it points at, with its own text never touched.
176
+ *
177
+ * Opt-in because a grammar need not have cross-document references at all,
178
+ * and because only the adopter knows which pair of documents forms one.
179
+ * Supplying it IS the claim that editing `valid` rebuilds this document; the
180
+ * check then holds the head to reporting that on `onDocumentsBuilt`, which
181
+ * is the only channel that can carry it — the document has no subscriber and
182
+ * its file did not change, so neither the update channel nor a filesystem
183
+ * watcher can.
184
+ */
185
+ readonly dependent?: ConformanceModel;
186
+ /**
187
+ * Optional: a locale plus the sentence the server must publish in it. Read
188
+ * by the **LSP slice only**.
189
+ */
190
+ readonly renderedDiagnostic?: RenderedDiagnosticSpec;
191
+ }
192
+
193
+ /**
194
+ * A locale, and one sentence the server must produce in it for the `invalid`
195
+ * fixture.
196
+ *
197
+ * **Opt-in, and it has to be.** The framework ships no catalogue and selects no
198
+ * locale, so a server that installs no renderer correctly publishes English —
199
+ * mandating this check would fail every adopter without i18n for doing the right
200
+ * thing. Supplying the field is the adopter saying "I render server-side, hold me
201
+ * to it".
202
+ *
203
+ * `expected` is a SUBSTRING, not the whole message. The kit owns no grammar, so
204
+ * it cannot know how many diagnostics `invalid` produces or in what order, and
205
+ * an adopter should be able to pin the translated fragment without restating a
206
+ * sentence they may reword. A substring long enough to be wrong if the render
207
+ * did not happen is the whole requirement.
208
+ *
209
+ * `absentWithoutLocale` is what makes the check a pair rather than a single
210
+ * assertion: "the message contains X" also passes for a server whose English
211
+ * happens to contain X, and for one that renders regardless of locale. Naming
212
+ * the fragment that must DISAPPEAR when no locale is declared is what
213
+ * distinguishes those.
214
+ */
215
+ export interface RenderedDiagnosticSpec {
216
+ /** The locale to declare at `initialize` — the tag whose catalogue the server has. */
217
+ readonly locale: Locale;
218
+ /** A fragment of the translated sentence, present in some diagnostic of the `invalid` fixture. */
219
+ readonly expected: string;
220
+ /**
221
+ * A fragment that must be absent once `locale` is declared, and present
222
+ * without it — normally a piece of the server's own English.
223
+ *
224
+ * Optional only because a catalogue may translate a message whose English
225
+ * shares no distinctive fragment with it. Omitting it drops the second half
226
+ * of the pair and leaves a check that a render-nothing server can pass; the
227
+ * kit reports that rather than pretending otherwise.
228
+ */
229
+ readonly absentWithLocale?: string;
122
230
  }