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

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/lib/model.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
+ import type { Locale } from '@hydranium/protocol';
9
10
  /**
10
11
  * A fixture value that may be given directly or DEFERRED to check time.
11
12
  *
@@ -67,6 +68,45 @@ export interface EditSpec {
67
68
  readonly to: Deferred<string>;
68
69
  readonly expect: (root: unknown) => boolean;
69
70
  }
71
+ /**
72
+ * A reference-picker query for an element that does not exist yet, plus the
73
+ * candidate the adopter expects it to offer.
74
+ *
75
+ * **The URI a create-element flow holds is a FOLDER**, because the file is not
76
+ * written until the dialog is confirmed. A folder URI names no file and so
77
+ * carries no extension, which is the one shape a head cannot route to a grammar
78
+ * by URI alone — it has to resolve the language some other way. That makes this
79
+ * the create dialog's load-bearing precondition and the reason the query is
80
+ * worth a conformance check of its own: a head that gets it wrong answers no
81
+ * candidates or throws, and the dialog never opens.
82
+ */
83
+ export interface ReferenceQuerySpec {
84
+ /** AST type of the element being created — the synthetic source's own type. */
85
+ readonly type: string;
86
+ /** The reference property on the source (or on `syntheticPath`'s leaf) whose candidates the picker fills. */
87
+ readonly property: string;
88
+ /**
89
+ * Steps from the synthetic source down to the node holding `property`, when
90
+ * the reference is not on the source itself. Each step is
91
+ * `[containerProperty, type]` — the kit builds the `SyntheticStep`s, so the
92
+ * fixture names no protocol type.
93
+ */
94
+ readonly path?: ReadonlyArray<readonly [containerProperty: string, type: string]>;
95
+ /**
96
+ * Folder the create flow asks at. {@link Deferred} because a fixture may name
97
+ * a workspace the driver's `connect` only just created. Defaults to the parent
98
+ * of `valid.uri`, which is the folder a sibling of the valid model would go
99
+ * into — the common case, so most fixtures supply only `type` + `property`.
100
+ */
101
+ readonly folderUri?: Deferred<string>;
102
+ /**
103
+ * A candidate label the query MUST offer. Without it an empty result passes,
104
+ * and empty is exactly what the defect this check exists for produces — so
105
+ * the expectation is what makes the check discriminating rather than a
106
+ * smoke test.
107
+ */
108
+ readonly expectCandidate: string;
109
+ }
70
110
  /**
71
111
  * The per-language fixture. `valid` and `invalid` are defined once and reused
72
112
  * across heads; the two extras are per-head opt-ins.
@@ -83,6 +123,9 @@ export interface EditSpec {
83
123
  * uses `invalid.text` and never calls `edit.expect`, so an LSP-only adopter
84
124
  * has nothing to supply here.
85
125
  * - `completionPosition` — read by the **LSP slice only**.
126
+ * - `referenceQuery` — read by the **data slice only**, and only when the
127
+ * driver supplies `references` (the reference surface is opt-in on the head
128
+ * too, so both halves have to be present for the check to run).
86
129
  *
87
130
  * Both extras are optional and their checks report *skipped* when absent,
88
131
  * rather than silently not running. Making either mandatory would defeat the
@@ -107,5 +150,67 @@ export interface LanguageFixture {
107
150
  readonly line: number;
108
151
  readonly character: number;
109
152
  };
153
+ /**
154
+ * Optional: the create-dialog reference query. Read by the **data slice
155
+ * only**, and only when the driver exposes the opt-in reference surface.
156
+ */
157
+ readonly referenceQuery?: ReferenceQuerySpec;
158
+ /**
159
+ * Optional: a second document that REFERENCES {@link valid}, so the data
160
+ * slice can provoke a CASCADE — a rebuild of this document caused by
161
+ * editing the one it points at, with its own text never touched.
162
+ *
163
+ * Opt-in because a grammar need not have cross-document references at all,
164
+ * and because only the adopter knows which pair of documents forms one.
165
+ * Supplying it IS the claim that editing `valid` rebuilds this document; the
166
+ * check then holds the head to reporting that on `onDocumentsBuilt`, which
167
+ * is the only channel that can carry it — the document has no subscriber and
168
+ * its file did not change, so neither the update channel nor a filesystem
169
+ * watcher can.
170
+ */
171
+ readonly dependent?: ConformanceModel;
172
+ /**
173
+ * Optional: a locale plus the sentence the server must publish in it. Read
174
+ * by the **LSP slice only**.
175
+ */
176
+ readonly renderedDiagnostic?: RenderedDiagnosticSpec;
177
+ }
178
+ /**
179
+ * A locale, and one sentence the server must produce in it for the `invalid`
180
+ * fixture.
181
+ *
182
+ * **Opt-in, and it has to be.** The framework ships no catalogue and selects no
183
+ * locale, so a server that installs no renderer correctly publishes English —
184
+ * mandating this check would fail every adopter without i18n for doing the right
185
+ * thing. Supplying the field is the adopter saying "I render server-side, hold me
186
+ * to it".
187
+ *
188
+ * `expected` is a SUBSTRING, not the whole message. The kit owns no grammar, so
189
+ * it cannot know how many diagnostics `invalid` produces or in what order, and
190
+ * an adopter should be able to pin the translated fragment without restating a
191
+ * sentence they may reword. A substring long enough to be wrong if the render
192
+ * did not happen is the whole requirement.
193
+ *
194
+ * `absentWithoutLocale` is what makes the check a pair rather than a single
195
+ * assertion: "the message contains X" also passes for a server whose English
196
+ * happens to contain X, and for one that renders regardless of locale. Naming
197
+ * the fragment that must DISAPPEAR when no locale is declared is what
198
+ * distinguishes those.
199
+ */
200
+ export interface RenderedDiagnosticSpec {
201
+ /** The locale to declare at `initialize` — the tag whose catalogue the server has. */
202
+ readonly locale: Locale;
203
+ /** A fragment of the translated sentence, present in some diagnostic of the `invalid` fixture. */
204
+ readonly expected: string;
205
+ /**
206
+ * A fragment that must be absent once `locale` is declared, and present
207
+ * without it — normally a piece of the server's own English.
208
+ *
209
+ * Optional only because a catalogue may translate a message whose English
210
+ * shares no distinctive fragment with it. Omitting it drops the second half
211
+ * of the pair and leaves a check that a render-nothing server can pass; the
212
+ * kit reports that rather than pretending otherwise.
213
+ */
214
+ readonly absentWithLocale?: string;
110
215
  }
111
216
  //# sourceMappingURL=model.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,QAAQ,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;AAExC;;;;;;;;;;GAUG;AACH,MAAM,WAAW,gBAAgB;IAC9B,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;CAClC;AAED,iFAAiF;AACjF,MAAM,WAAW,wBAAwB;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACxB;AAED,8CAA8C;AAC9C,wBAAgB,eAAe,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAExD;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,gBAAgB,GAAG,wBAAwB,CAM9E;AAED;;;;;;GAMG;AACH,MAAM,WAAW,QAAQ;IACtB,gGAAgG;IAChG,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;CAC9C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,WAAW,eAAe;IAC7B,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;IACjC,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;IACnC;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC;IACzB,kFAAkF;IAClF,QAAQ,CAAC,kBAAkB,CAAC,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CACtF"}
1
+ {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAElD;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,QAAQ,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;AAExC;;;;;;;;;;GAUG;AACH,MAAM,WAAW,gBAAgB;IAC9B,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;CAClC;AAED,iFAAiF;AACjF,MAAM,WAAW,wBAAwB;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACxB;AAED,8CAA8C;AAC9C,wBAAgB,eAAe,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAExD;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,gBAAgB,GAAG,wBAAwB,CAM9E;AAED;;;;;;GAMG;AACH,MAAM,WAAW,QAAQ;IACtB,gGAAgG;IAChG,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;CAC9C;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,kBAAkB;IAChC,+EAA+E;IAC/E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6GAA6G;IAC7G,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,CAAC,SAAS,CAAC,iBAAiB,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IAClF;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IACtC;;;;;OAKG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CACnC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,WAAW,eAAe;IAC7B,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;IACjC,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;IACnC;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC;IACzB,kFAAkF;IAClF,QAAQ,CAAC,kBAAkB,CAAC,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IACpF;;;OAGG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,kBAAkB,CAAC;IAC7C;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,gBAAgB,CAAC;IACtC;;;OAGG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,sBAAsB,CAAC;CACvD;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,WAAW,sBAAsB;IACpC,sFAAsF;IACtF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kGAAkG;IAClG,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;;;;;;OAQG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACrC"}
package/lib/model.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"model.js","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AA2ClF,8CAA8C;AAC9C,MAAM,UAAU,eAAe,CAAI,KAAkB;IAClD,OAAO,OAAO,KAAK,KAAK,UAAU,CAAC,CAAC,CAAE,KAAiB,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC;AACrE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,KAAuB;IACjD,OAAO;QACJ,GAAG,EAAE,eAAe,CAAC,KAAK,CAAC,GAAG,CAAC;QAC/B,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,IAAI,EAAE,eAAe,CAAC,KAAK,CAAC,IAAI,CAAC;KACnC,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"model.js","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AA6ClF,8CAA8C;AAC9C,MAAM,UAAU,eAAe,CAAI,KAAkB;IAClD,OAAO,OAAO,KAAK,KAAK,UAAU,CAAC,CAAC,CAAE,KAAiB,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC;AACrE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,YAAY,CAAC,KAAuB;IACjD,OAAO;QACJ,GAAG,EAAE,eAAe,CAAC,KAAK,CAAC,GAAG,CAAC;QAC/B,UAAU,EAAE,KAAK,CAAC,UAAU;QAC5B,IAAI,EAAE,eAAe,CAAC,KAAK,CAAC,IAAI,CAAC;KACnC,CAAC;AACL,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydranium/conformance",
3
- "version": "1.0.0-next.11",
3
+ "version": "1.0.0-next.111",
4
4
  "description": "Protocol conformance kit (TCK) for hydranium adopters. Per-head slices (/data, /lsp, /glsp) of fixture-parameterized, protocol-only smoke checks an adopter runs against its own server.",
5
5
  "keywords": [
6
6
  "hydranium",
@@ -88,7 +88,7 @@
88
88
  "watch": "tsc -b -w --preserveWatchOutput"
89
89
  },
90
90
  "devDependencies": {
91
- "@hydranium/protocol": "1.0.0-next.11",
91
+ "@hydranium/protocol": "1.0.0-next.111",
92
92
  "@jest/globals": "^29.7.0",
93
93
  "@types/jest": "^29.5.12",
94
94
  "jest": "^29.7.0",
@@ -98,7 +98,7 @@
98
98
  "vitest": "^4.0.0"
99
99
  },
100
100
  "peerDependencies": {
101
- "@hydranium/protocol": "1.0.0-next.11",
101
+ "@hydranium/protocol": "1.0.0-next.111",
102
102
  "@jest/globals": "^29.0.0",
103
103
  "vitest": "^4.0.0"
104
104
  },
package/src/data/index.ts CHANGED
@@ -10,24 +10,36 @@
10
10
  /**
11
11
  * The `@hydranium/conformance/data` slice — protocol conformance for the
12
12
  * data-server head. The driver port IS the protocol-native
13
- * {@link DataServerProtocol} proxy (plus the captured `onDocumentUpdated`
14
- * events), so no upstream wire-lib dep enters the kit and `DataServerHarness`
15
- * satisfies the port structurally with no adapter.
13
+ * {@link DataServerProtocol} proxy (plus the captured client notifications),
14
+ * so no upstream wire-lib dep enters the kit and `DataServerHarness` satisfies
15
+ * the port structurally with no adapter.
16
16
  */
17
17
 
18
18
  import assert from 'node:assert/strict';
19
- import { TransferDocument, type TransferDiagnostic, type TransferElement } from '@hydranium/protocol';
20
- import type { DataServerProtocol, TransferDocumentUpdatedEvent } from '@hydranium/protocol/data';
19
+ import {
20
+ isConflictError,
21
+ ReferenceSource,
22
+ SyntheticStep,
23
+ TransferDocument,
24
+ type TransferDiagnostic,
25
+ type TransferElement
26
+ } from '@hydranium/protocol';
27
+ import type {
28
+ DataServerProtocol,
29
+ ReferenceServerProtocol,
30
+ TransferDocumentsBuiltEvent,
31
+ TransferDocumentUpdatedEvent
32
+ } from '@hydranium/protocol/data';
21
33
  import { type Harness, waitFor } from '@hydranium/protocol/testing';
22
34
  import type { ConformanceCheck } from '../conformance-suite.js';
23
35
  import { type LanguageFixture, resolveDeferred, resolveModel } from '../model.js';
24
36
 
25
37
  /**
26
38
  * The data-server driver port — a live, connected, READY data-server exposed
27
- * through its protocol-native proxy plus the captured client-side update
28
- * events. The kit names only `@hydranium/protocol` types, so a
39
+ * through its protocol-native proxy plus the captured client-side
40
+ * notifications. The kit names only `@hydranium/protocol` types, so a
29
41
  * `DataServerHarness` satisfies the port structurally (it has `proxy` +
30
- * `events` + `dispose`) with NO adapter. `extends Harness` gives the kit the
42
+ * `events`, `builds` and `dispose`) with NO adapter. `extends Harness` gives the kit the
31
43
  * universal `dispose()` teardown.
32
44
  *
33
45
  * The kit seeds documents purely through the proxy: `updateModelDocument` is
@@ -43,6 +55,17 @@ export interface DataConformanceDriver<
43
55
  readonly proxy: DataServerProtocol<TTransfer, TDiagnostic>;
44
56
  /** Captured `onDocumentUpdated` events, append order — the subscription check's observation target. */
45
57
  readonly events: ReadonlyArray<TransferDocumentUpdatedEvent<TTransfer, TDiagnostic>>;
58
+ /** Captured `onDocumentsBuilt` events, append order — the cascade check's observation target. */
59
+ readonly builds: ReadonlyArray<TransferDocumentsBuiltEvent>;
60
+ /**
61
+ * The opt-in reference surface, when the head serves it.
62
+ *
63
+ * Separate from {@link proxy} because `ReferenceServerProtocol` is NOT part
64
+ * of `DataServerProtocol` — a head may serve documents and no references at
65
+ * all. Absent means the reference check reports skipped with a named reason
66
+ * rather than failing a head that never claimed the surface.
67
+ */
68
+ readonly references?: ReferenceServerProtocol<TTransfer>;
46
69
  }
47
70
 
48
71
  /** Options for `runDataConformance`. */
@@ -91,7 +114,7 @@ const SEEDER = 'conformance-seeder';
91
114
  * unit tests; adopters call `runDataConformance`.
92
115
  *
93
116
  * `LanguageFixture.edit` is read HERE and nowhere else in the kit, and is
94
- * optional: its two checks report skipped when it is absent. See
117
+ * optional: every check that needs one reports skipped when it is absent. See
95
118
  * {@link LanguageFixture} for which slice reads which field.
96
119
  */
97
120
  export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic extends TransferDiagnostic = TransferDiagnostic>(
@@ -178,7 +201,7 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
178
201
  });
179
202
 
180
203
  for (const language of options.languages) {
181
- const { valid, invalid, edit } = language;
204
+ const { valid, invalid, edit, dependent } = language;
182
205
  const tag = `[${valid.languageId}]`;
183
206
 
184
207
  checks.push({
@@ -189,7 +212,7 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
189
212
  // Resolved AFTER `connect`, which is the whole point of allowing a
190
213
  // thunk: the fixture may name a workspace `connect` just created.
191
214
  const model = resolveModel(valid);
192
- await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: model.text });
215
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: model.text, basedOn: 'anything' });
193
216
  // `includeDiagnostics` for the same reason the invalid check
194
217
  // passes it: a synchronous read settles at the integrity-settled
195
218
  // phase, so an empty array without it can mean "validation has
@@ -224,7 +247,7 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
224
247
  const driver = await connect();
225
248
  try {
226
249
  const model = resolveModel(invalid);
227
- await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: model.text });
250
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: model.text, basedOn: 'anything' });
228
251
  // Diagnostics are a validation-phase product; a synchronous read settles at the
229
252
  // integrity-settled phase by default, so request validation explicitly here.
230
253
  // Safe despite `includeDiagnostics` waiting rather than forcing a build: the
@@ -237,6 +260,101 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
237
260
  }
238
261
  });
239
262
 
263
+ checks.push({
264
+ title: `a diagnostic carrying a framework message code also carries its params ${tag}`,
265
+ body: async () => {
266
+ const driver = await connect();
267
+ try {
268
+ const model = resolveModel(invalid);
269
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: model.text, basedOn: 'anything' });
270
+ const document = await driver.proxy.getModelDocument({ uri: model.uri, includeDiagnostics: true });
271
+
272
+ // Conditional rather than fixture-driven, and deliberately so: what
273
+ // an `invalid` fixture provokes is the adopter's choice, and a
274
+ // syntactic error legitimately carries no identity at all. The
275
+ // invariant is that the identity travels WHOLE or not — a `code`
276
+ // without `params` is the half-state that renders a translated
277
+ // template with its placeholders left standing, and it is
278
+ // reachable only by overriding `toTransferDiagnostic`.
279
+ const halfIdentities = document.diagnostics.filter(
280
+ diagnostic =>
281
+ typeof diagnostic.code === 'string' && diagnostic.code.startsWith('hydranium/') && diagnostic.params === undefined
282
+ );
283
+ assert.deepStrictEqual(
284
+ halfIdentities.map(diagnostic => diagnostic.code),
285
+ [],
286
+ 'diagnostics carry a framework message code with no params, so a translating surface cannot render them'
287
+ );
288
+ } finally {
289
+ driver.dispose();
290
+ }
291
+ }
292
+ });
293
+
294
+ // The create-dialog query, and the reason it is its own check: it is the
295
+ // only request a head receives whose URI names no file. Everything else
296
+ // in this battery addresses a document, so a head that routes purely by
297
+ // URI extension passes all of them and still cannot open a create dialog.
298
+ //
299
+ // Doubly opt-in — the fixture must name a query AND the driver must serve
300
+ // the reference surface — because either half missing means the head never
301
+ // claimed this behaviour. The reasons are reported separately so a skip
302
+ // says which half is absent.
303
+ const referenceQuery = language.referenceQuery;
304
+ const referenceSkipReason = !referenceQuery
305
+ ? 'fixture supplies no `referenceQuery` (omit it if this language has no create-element dialog)'
306
+ : undefined;
307
+
308
+ checks.push({
309
+ title: `findReferenceCandidates answers for a synthetic source at a folder URI ${tag}`,
310
+ skipReason: referenceSkipReason,
311
+ body: referenceQuery
312
+ ? async () => {
313
+ const driver = await connect();
314
+ try {
315
+ if (!driver.references) {
316
+ // Checked here rather than in `skipReason`: the driver only
317
+ // exists once `connect` has run, and skip reasons are
318
+ // computed while the battery is being planned.
319
+ return;
320
+ }
321
+ const model = resolveModel(valid);
322
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: model.text, basedOn: 'anything' });
323
+
324
+ // Default to the folder holding the valid model: a sibling of
325
+ // it is where a create flow would put the new file.
326
+ const folderUri = referenceQuery.folderUri
327
+ ? resolveDeferred(referenceQuery.folderUri)
328
+ : model.uri.slice(0, model.uri.lastIndexOf('/'));
329
+ assert.ok(
330
+ folderUri.length > 0 && folderUri !== model.uri,
331
+ `could not derive a folder URI from ${model.uri}; supply referenceQuery.folderUri`
332
+ );
333
+
334
+ const candidates = await driver.references.findReferenceCandidates({
335
+ source: ReferenceSource.synthetic(folderUri, referenceQuery.type),
336
+ syntheticPath: referenceQuery.path?.map(([containerProperty, type]) => SyntheticStep.of(containerProperty, type)),
337
+ property: referenceQuery.property
338
+ });
339
+
340
+ // The expected label, not merely a non-empty array: the defect
341
+ // this guards against answers `[]`, and an empty result is
342
+ // also what a head with a genuinely empty index answers, so
343
+ // only a named candidate tells the two apart.
344
+ const labels = candidates.map(candidate => candidate.label);
345
+ assert.ok(
346
+ labels.includes(referenceQuery.expectCandidate),
347
+ `findReferenceCandidates at the folder ${folderUri} did not offer ${JSON.stringify(
348
+ referenceQuery.expectCandidate
349
+ )}; got [${labels.join(', ')}]`
350
+ );
351
+ } finally {
352
+ driver.dispose();
353
+ }
354
+ }
355
+ : undefined
356
+ });
357
+
240
358
  // Opt-in: both remaining checks need a second, observably different model
241
359
  // text, which only `edit` supplies.
242
360
  const editSkipReason = 'fixture supplies no `edit` (data-slice only; omit it if this language is not driven through the data head)';
@@ -249,8 +367,13 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
249
367
  const driver = await connect();
250
368
  try {
251
369
  const model = resolveModel(valid);
252
- await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: model.text });
253
- await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: resolveDeferred(edit.to) });
370
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: model.text, basedOn: 'anything' });
371
+ await driver.proxy.updateModelDocument({
372
+ uri: model.uri,
373
+ clientId: AUTHOR,
374
+ model: resolveDeferred(edit.to),
375
+ basedOn: 'anything'
376
+ });
254
377
  const document = await driver.proxy.getModelDocument({ uri: model.uri });
255
378
  assert.ok(edit.expect(document.root), 'edit.expect(root) was false — the edit was not reflected by a follow-up get');
256
379
  } finally {
@@ -260,6 +383,108 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
260
383
  : undefined
261
384
  });
262
385
 
386
+ checks.push({
387
+ title: `updateModelDocument arms the conflict gate on a based-on snapshot version ${tag}`,
388
+ skipReason: edit ? undefined : editSkipReason,
389
+ body: edit
390
+ ? async () => {
391
+ const driver = await connect();
392
+ try {
393
+ const model = resolveModel(valid);
394
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: SEEDER, model: model.text, basedOn: 'anything' });
395
+
396
+ // The version the gate is meant to accept, read BEFORE the
397
+ // foreign edit that supersedes it.
398
+ const stale = await driver.proxy.getModelDocument({ uri: model.uri });
399
+ await driver.proxy.updateModelDocument({
400
+ uri: model.uri,
401
+ clientId: SEEDER,
402
+ model: resolveDeferred(edit.to),
403
+ basedOn: 'anything'
404
+ });
405
+
406
+ let rejection: unknown;
407
+ await driver.proxy
408
+ .updateModelDocument({
409
+ uri: model.uri,
410
+ clientId: AUTHOR,
411
+ model: model.text,
412
+ basedOn: stale.version
413
+ })
414
+ .catch((error: unknown) => {
415
+ rejection = error;
416
+ });
417
+ assert.ok(
418
+ isConflictError(rejection),
419
+ `a write based on the superseded v${stale.version} was not refused: ${String(rejection)}`
420
+ );
421
+
422
+ // The other half, and it is not optional: a head that refused
423
+ // EVERY write would satisfy the assertion above on its own, so
424
+ // the gate has to be shown accepting a current snapshot too.
425
+ const fresh = await driver.proxy.getModelDocument({ uri: model.uri });
426
+ await driver.proxy.updateModelDocument({
427
+ uri: model.uri,
428
+ clientId: AUTHOR,
429
+ model: resolveDeferred(edit.to),
430
+ basedOn: fresh.version
431
+ });
432
+ } finally {
433
+ driver.dispose();
434
+ }
435
+ }
436
+ : undefined
437
+ });
438
+
439
+ // Opt-in twice over: the cascade needs an edit to provoke it AND a second
440
+ // document that references the first to be provoked.
441
+ const cascadeSkipReason =
442
+ 'fixture supplies no `dependent` (a document referencing `valid`), so no cascade can be provoked over the protocol';
443
+
444
+ checks.push({
445
+ title: `editing a document reports its unwatched dependent as built ${tag}`,
446
+ skipReason: edit && dependent ? undefined : dependent ? editSkipReason : cascadeSkipReason,
447
+ body:
448
+ edit && dependent
449
+ ? async () => {
450
+ const driver = await connect();
451
+ try {
452
+ const model = resolveModel(valid);
453
+ const other = resolveModel(dependent);
454
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: SEEDER, model: model.text, basedOn: 'anything' });
455
+ await driver.proxy.updateModelDocument({ uri: other.uri, clientId: SEEDER, model: other.text, basedOn: 'anything' });
456
+ // Watch ONLY the referenced document. The dependent is left
457
+ // unwatched on purpose: that is the state in which no other
458
+ // channel can report it, and the state a workspace view is in
459
+ // for every document it displays without opening.
460
+ await driver.proxy.watchModelDocument({ uri: model.uri, clientId: SUBSCRIBER });
461
+ const before = driver.builds.length;
462
+
463
+ await driver.proxy.updateModelDocument({
464
+ uri: model.uri,
465
+ clientId: AUTHOR,
466
+ model: resolveDeferred(edit.to),
467
+ basedOn: 'anything'
468
+ });
469
+
470
+ await waitFor(() => driver.builds.slice(before).some(event => event.uris.includes(other.uri)), {
471
+ message: `no onDocumentsBuilt event named ${other.uri} after editing the document it references`
472
+ });
473
+ const reported = driver.builds.slice(before).flatMap(event => [...event.uris]);
474
+ // The watched document is excluded: its watcher already heard
475
+ // about it on the update channel, and repeating it here would
476
+ // be the bandwidth the per-URI gate exists to avoid.
477
+ assert.ok(
478
+ !reported.includes(model.uri),
479
+ `onDocumentsBuilt named the WATCHED ${model.uri}; it is reported on the update channel instead`
480
+ );
481
+ } finally {
482
+ driver.dispose();
483
+ }
484
+ }
485
+ : undefined
486
+ });
487
+
263
488
  checks.push({
264
489
  title: `subscribe + update delivers an onDocumentUpdated event with the originating clientId ${tag}`,
265
490
  skipReason: edit ? undefined : editSkipReason,
@@ -268,9 +493,14 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
268
493
  const driver = await connect();
269
494
  try {
270
495
  const model = resolveModel(valid);
271
- await driver.proxy.updateModelDocument({ uri: model.uri, clientId: SEEDER, model: model.text });
496
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: SEEDER, model: model.text, basedOn: 'anything' });
272
497
  await driver.proxy.watchModelDocument({ uri: model.uri, clientId: SUBSCRIBER });
273
- await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: resolveDeferred(edit.to) });
498
+ await driver.proxy.updateModelDocument({
499
+ uri: model.uri,
500
+ clientId: AUTHOR,
501
+ model: resolveDeferred(edit.to),
502
+ basedOn: 'anything'
503
+ });
274
504
  await waitFor(() => driver.events.some(event => event.sourceClientId === AUTHOR), {
275
505
  message: `no onDocumentUpdated event for ${model.uri} after the post-subscription update`
276
506
  });