@cratis/screenplay-language 4.17.0 → 4.19.0

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 (58) hide show
  1. package/completion-items.ts +7 -1
  2. package/diagnostic-codes.ts +5 -0
  3. package/dist/completion-items.d.ts.map +1 -1
  4. package/dist/completion-items.js +6 -1
  5. package/dist/completion-items.js.map +1 -1
  6. package/dist/diagnostic-codes.d.ts +5 -0
  7. package/dist/diagnostic-codes.d.ts.map +1 -1
  8. package/dist/diagnostic-codes.js +5 -0
  9. package/dist/diagnostic-codes.js.map +1 -1
  10. package/dist/event-context-catalog.d.ts +14 -0
  11. package/dist/event-context-catalog.d.ts.map +1 -0
  12. package/dist/event-context-catalog.js +116 -0
  13. package/dist/event-context-catalog.js.map +1 -0
  14. package/dist/event-context.d.ts +38 -0
  15. package/dist/event-context.d.ts.map +1 -0
  16. package/dist/event-context.js +88 -0
  17. package/dist/event-context.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/keyword-docs.d.ts.map +1 -1
  23. package/dist/keyword-docs.js +3 -1
  24. package/dist/keyword-docs.js.map +1 -1
  25. package/dist/sub-languages/projection/CompletionProvider.d.ts +1 -0
  26. package/dist/sub-languages/projection/CompletionProvider.d.ts.map +1 -1
  27. package/dist/sub-languages/projection/CompletionProvider.js +39 -69
  28. package/dist/sub-languages/projection/CompletionProvider.js.map +1 -1
  29. package/dist/sub-languages/projection/HoverProvider.d.ts +1 -0
  30. package/dist/sub-languages/projection/HoverProvider.d.ts.map +1 -1
  31. package/dist/sub-languages/projection/HoverProvider.js +36 -4
  32. package/dist/sub-languages/projection/HoverProvider.js.map +1 -1
  33. package/dist/sub-languages/projection/Validator.d.ts.map +1 -1
  34. package/dist/sub-languages/projection/Validator.js +14 -0
  35. package/dist/sub-languages/projection/Validator.js.map +1 -1
  36. package/dist/sub-languages/projection/language.d.ts.map +1 -1
  37. package/dist/sub-languages/projection/language.js +0 -2
  38. package/dist/sub-languages/projection/language.js.map +1 -1
  39. package/dist/validation.d.ts +1 -0
  40. package/dist/validation.d.ts.map +1 -1
  41. package/dist/validation.js +52 -0
  42. package/dist/validation.js.map +1 -1
  43. package/event-context-catalog.ts +135 -0
  44. package/event-context.ts +119 -0
  45. package/for_eventContext/when_resolving_generated_catalog_vectors.ts +15 -0
  46. package/for_projectionCompletionProvider/when_completing_an_event_context_path.ts +102 -0
  47. package/for_projectionHoverProvider/when_hovering_an_event_context_path.ts +44 -0
  48. package/for_projectionValidator/when_validating_event_context_paths.ts +31 -0
  49. package/for_validateLines/when_validating_a_document/with_a_problem_of_every_coded_kind.ts +7 -0
  50. package/for_validateLines/when_validating_event_context_paths/in_a_projection.ts +97 -0
  51. package/index.ts +2 -0
  52. package/keyword-docs.ts +4 -1
  53. package/package.json +1 -1
  54. package/sub-languages/projection/CompletionProvider.ts +47 -72
  55. package/sub-languages/projection/HoverProvider.ts +40 -4
  56. package/sub-languages/projection/Validator.ts +15 -0
  57. package/sub-languages/projection/language.ts +0 -2
  58. package/validation.ts +59 -0
@@ -0,0 +1,97 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { describe, beforeEach, it } from 'vitest';
5
+ import { diagnosticCodes } from '../../diagnostic-codes';
6
+ import { eventContextPaths } from '../../event-context';
7
+ import { ValidationIssue, validateLines } from '../../validation';
8
+
9
+ function validate(...mappings: string[]): ValidationIssue[] {
10
+ return validateLines([
11
+ 'projection Statistics => StatisticsReadModel',
12
+ ' from Happened',
13
+ ...mappings.map((mapping) => ` ${mapping}`),
14
+ ]);
15
+ }
16
+
17
+ describe('when validating every event context path the catalog lists', () => {
18
+ let issues: ValidationIssue[];
19
+
20
+ beforeEach(() => {
21
+ issues = validate(
22
+ ...eventContextPaths.map((path, index) => `value${index} = $eventContext.${path.path}`),
23
+ ...eventContextPaths.map((path) => `count countBy.$eventContext.${path.path}`),
24
+ 'week = $eventContext.occurred.Week()',
25
+ 'type = $eventContext.EventType.Id',
26
+ );
27
+ });
28
+
29
+ it('should report nothing', () => {
30
+ issues.should.be.empty;
31
+ });
32
+ });
33
+
34
+ describe('when validating an unknown event context member', () => {
35
+ let issues: ValidationIssue[];
36
+
37
+ beforeEach(() => {
38
+ issues = validate('cause = $eventContext.causationId');
39
+ });
40
+
41
+ it('should warn with the compiler code', () => {
42
+ issues.map((issue) => [issue.code, issue.severity]).should.deep.equal([[diagnosticCodes.unknownEventContextMember, 'warning']]);
43
+ });
44
+
45
+ it('should squiggle the whole path', () => {
46
+ const [issue] = issues;
47
+ [issue.startColumn, issue.endColumn].should.deep.equal([13, 13 + '$eventContext.causationId'.length]);
48
+ });
49
+ });
50
+
51
+ describe('when validating an unknown event context sub-path', () => {
52
+ let issues: ValidationIssue[];
53
+
54
+ beforeEach(() => {
55
+ issues = validate('week = $eventContext.occurred.week');
56
+ });
57
+
58
+ it('should warn with the compiler code', () => {
59
+ issues.map((issue) => [issue.code, issue.severity]).should.deep.equal([[diagnosticCodes.unknownEventContextPath, 'warning']]);
60
+ });
61
+ });
62
+
63
+ describe('when validating a path below an event context collection', () => {
64
+ let issues: ValidationIssue[];
65
+
66
+ beforeEach(() => {
67
+ issues = validate('count byTag.$eventContext.tags.value');
68
+ });
69
+
70
+ it('should report an error with the compiler code', () => {
71
+ issues.map((issue) => [issue.code, issue.severity]).should.deep.equal([[diagnosticCodes.eventContextPathBelowCollection, 'error']]);
72
+ });
73
+ });
74
+
75
+ describe('when validating a dynamic key naming no event context member', () => {
76
+ let issues: ValidationIssue[];
77
+
78
+ beforeEach(() => {
79
+ issues = validate('count byNothing.$eventContext');
80
+ });
81
+
82
+ it('should report an error with the compiler code', () => {
83
+ issues.map((issue) => [issue.code, issue.severity]).should.deep.equal([[diagnosticCodes.missingEventContextPath, 'error']]);
84
+ });
85
+ });
86
+
87
+ describe('when validating a dynamic key naming another source', () => {
88
+ let issues: ValidationIssue[];
89
+
90
+ beforeEach(() => {
91
+ issues = validate('count byUser.$causedBy.subject');
92
+ });
93
+
94
+ it('should warn with the compiler code', () => {
95
+ issues.map((issue) => [issue.code, issue.severity]).should.deep.equal([[diagnosticCodes.unresolvedDynamicKeySource, 'warning']]);
96
+ });
97
+ });
package/index.ts CHANGED
@@ -100,6 +100,8 @@ export type {
100
100
  TypeSymbol,
101
101
  } from './symbols';
102
102
  export { attributeDocs, contextVariableDocs, keywordDocs } from './keyword-docs';
103
+ export { eventContextMemberAt, eventContextMembers, eventContextMembersAfter, eventContextPaths, namesEventContextMember } from './event-context';
104
+ export type { EventContextMember, EventContextPath } from './event-context';
103
105
  export { contextVariableItems, producesItems } from './completion-items';
104
106
  export type { CompletionEntry } from './completion-items';
105
107
  export { completionEntriesFor, planCompletions } from './completion-planner';
package/keyword-docs.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // Copyright (c) Cratis. All rights reserved.
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
+ import { eventContextPaths } from './event-context';
5
+
4
6
  export const keywordDocs: Record<string, string> = {
5
7
  domain: 'The domain the file belongs to — optional, at most one per file, before everything else.',
6
8
  import: 'Imports a type from another module; it becomes available by its short name.',
@@ -128,5 +130,6 @@ export const contextVariableDocs: Record<string, string> = {
128
130
  '$env': 'An environment variable, e.g. `$env.SERVICE_NAME`.',
129
131
  '$secrets': 'An encrypted secret resolved at runtime, e.g. `$secrets.azureAdClientId` — stays symbolic in the compiled tree.',
130
132
  '$strings': 'A localized string resolved from the `.strings` file of the active locale, e.g. `$strings.invoices.title`.',
131
- '$eventContext.occurred': 'The timestamp of the event being projected.',
133
+ // Every $eventContext path the event-context catalog lists, for projections.
134
+ ...Object.fromEntries(eventContextPaths.map((path) => [`$eventContext.${path.path}`, path.description])),
132
135
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/screenplay-language",
3
- "version": "4.17.0",
3
+ "version": "4.19.0",
4
4
  "description": "Monaco language service for the Cratis Screenplay DSL — syntax highlighting, IntelliSense, hover and diagnostics for .play files",
5
5
  "keywords": [
6
6
  "cratis",
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { editor, languages, IRange, Position } from 'monaco-editor';
5
5
  import type { JsonSchema, JsonSchemaProperty, ReadModelInfo } from './index';
6
+ import { eventContextMembers, eventContextMembersAfter } from '../../event-context';
6
7
 
7
8
  interface CompletionContext {
8
9
  lineContent: string;
@@ -13,6 +14,10 @@ interface CompletionContext {
13
14
  lineNumber: number;
14
15
  }
15
16
 
17
+ // The path typed so far after $eventContext. - on its own or as the dynamic key of a mapping target - and the
18
+ // partial segment under the cursor.
19
+ const EVENT_CONTEXT_PATH = /\$eventContext\.((?:\w+\.)*)(\w*)$/;
20
+
16
21
  export class CompletionProvider implements languages.CompletionItemProvider {
17
22
  private readModels: ReadModelInfo[] = [];
18
23
  private eventSchemas: Record<string, JsonSchema> = {};
@@ -177,7 +182,10 @@ export class CompletionProvider implements languages.CompletionItemProvider {
177
182
  }
178
183
 
179
184
  private isPropertyDotAccess(context: CompletionContext): boolean {
180
- return /(\w+|\$\w+)\.(\w*)$/.test(context.textBeforeCursor);
185
+ // A member of an event property or a $-expression, or any depth of an event-context path - written as an
186
+ // expression or as the dynamic key of a mapping target (propertyName.$eventContext.<path>).
187
+ return /(\w+|\$\w+)\.(\w*)$/.test(context.textBeforeCursor) ||
188
+ EVENT_CONTEXT_PATH.test(context.textBeforeCursor);
181
189
  }
182
190
 
183
191
  private addProjectionLineCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void {
@@ -501,37 +509,14 @@ export class CompletionProvider implements languages.CompletionItemProvider {
501
509
  {
502
510
  label: '$causedBy',
503
511
  insertText: '$causedBy',
504
- documentation: 'Information about who/what caused the event',
505
- detail: 'Identity Expression',
506
- children: [
507
- { name: 'subject', doc: 'The subject identifier' },
508
- { name: 'name', doc: 'The display name' },
509
- { name: 'userName', doc: 'The username' },
510
- ]
512
+ documentation: 'The identity that caused the event. Chronicle cannot yet evaluate this short form (Cratis/Chronicle#4119) - prefer $eventContext.causedBy.<property>.',
513
+ detail: 'Identity Expression'
511
514
  },
512
515
  {
513
516
  label: '$eventContext',
514
517
  insertText: '$eventContext.',
515
- documentation: 'Access event context properties',
516
- detail: 'Event Context Expression',
517
- children: [
518
- { name: 'sequenceNumber', doc: 'The event sequence number' },
519
- { name: 'occurred', doc: 'When the event occurred' },
520
- { name: 'eventSourceId', doc: 'The event source identifier' },
521
- { name: 'namespace', doc: 'The event namespace' },
522
- ]
523
- },
524
- {
525
- label: '$occurred',
526
- insertText: '$occurred',
527
- documentation: 'Timestamp when the event occurred',
528
- detail: 'Occurred Expression'
529
- },
530
- {
531
- label: '$namespace',
532
- insertText: '$namespace',
533
- documentation: 'The namespace of the event',
534
- detail: 'Namespace Expression'
518
+ documentation: `Metadata about the event: ${eventContextMembers.map((member) => member.name).join(', ')}`,
519
+ detail: 'Event Context Expression'
535
520
  },
536
521
  ];
537
522
 
@@ -550,55 +535,24 @@ export class CompletionProvider implements languages.CompletionItemProvider {
550
535
  }
551
536
 
552
537
  private addPropertyMemberCompletions(suggestions: languages.CompletionItem[], context: CompletionContext): void {
538
+ // Any depth of an event-context path, as an expression or as a dynamic key (propertyName.$eventContext.<path>)
539
+ const eventContextMatch = context.textBeforeCursor.match(EVENT_CONTEXT_PATH);
540
+ if (eventContextMatch) {
541
+ const segments = eventContextMatch[1].split('.').filter((segment) => segment.length > 0);
542
+ this.addEventContextMemberCompletions(suggestions, context, segments, eventContextMatch[2]);
543
+ return;
544
+ }
545
+
553
546
  const match = context.textBeforeCursor.match(/(\w+|\$\w+)\.(\w*)$/);
554
547
  if (!match) return;
555
548
 
556
549
  const [, objectName, partialProp] = match;
557
550
 
558
- // Handle $causedBy properties
559
- if (objectName === '$causedBy') {
560
- const causedByProps = [
561
- { name: 'subject', doc: 'The subject identifier', type: 'string' },
562
- { name: 'name', doc: 'The display name', type: 'string' },
563
- { name: 'userName', doc: 'The username', type: 'string' },
564
- ];
565
-
566
- causedByProps.forEach((prop) => {
567
- if (!partialProp || prop.name.startsWith(partialProp)) {
568
- suggestions.push({
569
- label: prop.name,
570
- kind: 9,
571
- insertText: prop.name,
572
- documentation: prop.doc,
573
- detail: prop.type,
574
- range: this.getRangeForWord(context),
575
- });
576
- }
577
- });
578
- return;
579
- }
580
-
581
- // Handle $eventContext properties
582
- if (objectName === '$eventContext') {
583
- const contextProps = [
584
- { name: 'sequenceNumber', doc: 'The event sequence number', type: 'EventSequenceNumber' },
585
- { name: 'occurred', doc: 'When the event occurred', type: 'DateTimeOffset' },
586
- { name: 'eventSourceId', doc: 'The event source identifier', type: 'string' },
587
- { name: 'namespace', doc: 'The event namespace', type: 'string' },
588
- ];
589
-
590
- contextProps.forEach((prop) => {
591
- if (!partialProp || prop.name.startsWith(partialProp)) {
592
- suggestions.push({
593
- label: prop.name,
594
- kind: 9,
595
- insertText: prop.name,
596
- documentation: prop.doc,
597
- detail: prop.type,
598
- range: this.getRangeForWord(context),
599
- });
600
- }
601
- });
551
+ // $causedBy is the short form of $eventContext.causedBy, at any depth
552
+ const causedByMatch = context.textBeforeCursor.match(/\$causedBy\.((?:\w+\.)*)(\w*)$/);
553
+ if (causedByMatch) {
554
+ const segments = causedByMatch[1].split('.').filter((segment) => segment.length > 0);
555
+ this.addEventContextMemberCompletions(suggestions, context, ['causedBy', ...segments], causedByMatch[2]);
602
556
  return;
603
557
  }
604
558
 
@@ -620,6 +574,27 @@ export class CompletionProvider implements languages.CompletionItemProvider {
620
574
  }
621
575
  }
622
576
 
577
+ private addEventContextMemberCompletions(
578
+ suggestions: languages.CompletionItem[],
579
+ context: CompletionContext,
580
+ segments: readonly string[],
581
+ partial: string
582
+ ): void {
583
+ const members = eventContextMembersAfter(segments) ?? [];
584
+ members
585
+ .filter((member) => !partial || member.name.startsWith(partial))
586
+ .forEach((member) => {
587
+ suggestions.push({
588
+ label: member.name,
589
+ kind: member.kind === 'function' ? 1 : 9,
590
+ insertText: member.name,
591
+ documentation: member.description,
592
+ detail: member.type,
593
+ range: this.getRangeForWord(context),
594
+ });
595
+ });
596
+ }
597
+
623
598
  private getCurrentEventType(model: editor.ITextModel, lineNumber: number): string | null {
624
599
  // Search backwards to find the event type declaration
625
600
  for (let i = lineNumber; i >= 1; i--) {
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { editor, languages, Position } from 'monaco-editor';
5
5
  import type { JsonSchema, JsonSchemaProperty, ReadModelInfo, DraftReadModelInfo } from './index';
6
+ import { describeEventContext, describeEventContextMember, eventContextMemberAt } from '../../event-context';
6
7
 
7
8
  export class HoverProvider implements languages.HoverProvider {
8
9
  private readModels: ReadModelInfo[] = [];
@@ -67,6 +68,20 @@ export class HoverProvider implements languages.HoverProvider {
67
68
  };
68
69
  }
69
70
 
71
+ // A segment of an $eventContext path - before keywords, since a member such as 'id' is also a keyword
72
+ const eventContextInfo = this.getEventContextInfo(lineContent, word.startColumn);
73
+ if (eventContextInfo) {
74
+ return {
75
+ contents: [{ value: eventContextInfo }],
76
+ range: {
77
+ startLineNumber: position.lineNumber,
78
+ startColumn: word.startColumn,
79
+ endLineNumber: position.lineNumber,
80
+ endColumn: word.endColumn,
81
+ },
82
+ };
83
+ }
84
+
70
85
  // Check if it's a keyword
71
86
  const keywordInfo = this.getKeywordInfo(wordText);
72
87
  if (keywordInfo) {
@@ -224,15 +239,36 @@ export class HoverProvider implements languages.HoverProvider {
224
239
  private getExpressionInfo(word: string): string | null {
225
240
  const expressions: Record<string, string> = {
226
241
  '$eventSourceId': '**$eventSourceId**\n\nThe identifier of the event source (aggregate/entity) that generated the event.',
227
- '$causedBy': '**$causedBy**\n\nInformation about who/what caused the event.\n\n**Properties:**\n- `subject` - The subject identifier\n- `name` - The display name\n- `userName` - The username',
228
- '$eventContext': '**$eventContext**\n\nAccess to event context properties.\n\n**Properties:**\n- `sequenceNumber` - The event sequence number\n- `occurred` - When the event occurred\n- `eventSourceId` - The event source identifier\n- `namespace` - The event namespace',
229
- '$occurred': '**$occurred**\n\nThe timestamp when the event occurred.',
230
- '$namespace': '**$namespace**\n\nThe namespace of the event.',
242
+ '$causedBy': '**$causedBy**\n\nThe identity that caused the event - `subject`, `name`, `userName` or `onBehalfOf`, itself an identity.\n\nChronicle cannot yet evaluate this short form (Cratis/Chronicle#4119); prefer **$eventContext.causedBy**.*property*.',
243
+ '$eventContext': describeEventContext(),
231
244
  };
232
245
 
233
246
  return expressions[word] || null;
234
247
  }
235
248
 
249
+ // Describes the $eventContext path segment starting at the given column, if the word there is one.
250
+ private getEventContextInfo(lineContent: string, startColumn: number): string | null {
251
+ for (const match of lineContent.matchAll(/\$eventContext((?:\.\w+(?:\(\))?)*)/g)) {
252
+ let column = (match.index ?? 0) + 1;
253
+ if (startColumn === column) return describeEventContext();
254
+
255
+ column += '$eventContext'.length;
256
+ const segments = match[1].split('.').slice(1);
257
+ for (let index = 0; index < segments.length; index++) {
258
+ column += 1;
259
+ if (startColumn === column) {
260
+ const path = segments.slice(0, index + 1);
261
+ const member = eventContextMemberAt(path);
262
+ return member ? describeEventContextMember(path.join('.'), member) : null;
263
+ }
264
+
265
+ column += segments[index].length;
266
+ }
267
+ }
268
+
269
+ return null;
270
+ }
271
+
236
272
  private getPropertyInfo(model: editor.ITextModel, position: Position, propertyName: string): string | null {
237
273
  // Try to find the active read model
238
274
  const firstLine = model.getLineContent(1).trim();
@@ -3,6 +3,8 @@
3
3
 
4
4
  import type { editor } from 'monaco-editor';
5
5
  import type { JsonSchema, ReadModelInfo, DraftReadModelInfo } from './index';
6
+ import { fenceMap } from '../../document-context';
7
+ import { validateEventContextPaths } from '../../validation';
6
8
 
7
9
  export class Validator {
8
10
  private readModels: ReadModelInfo[] = [];
@@ -349,6 +351,19 @@ export class Validator {
349
351
  }
350
352
  }
351
353
 
354
+ // Share the host's event-context path checks and catalog resolution, including dynamic dictionary keys.
355
+ for (const issue of validateEventContextPaths(lines, fenceMap(lines))) {
356
+ markers.push({
357
+ severity: issue.severity === 'error' ? 8 : 4,
358
+ startLineNumber: issue.line + 1,
359
+ endLineNumber: issue.line + 1,
360
+ startColumn: issue.startColumn,
361
+ endColumn: issue.endColumn,
362
+ message: issue.message,
363
+ code: issue.code,
364
+ });
365
+ }
366
+
352
367
  return markers;
353
368
  }
354
369
 
@@ -45,8 +45,6 @@ const KEYWORDS = [
45
45
  const BUILTINS = [
46
46
  '$eventSourceId',
47
47
  '$causedBy',
48
- '$occurred',
49
- '$namespace',
50
48
  '$eventContext',
51
49
  ] as const;
52
50
 
package/validation.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  import { causedByProperties, contextRoots, identityProperties, primitiveTypes, sliceTypes } from './language';
5
5
  import { DiagnosticCode, diagnosticCodes } from './diagnostic-codes';
6
6
  import { fenceMap, indentOf } from './document-context';
7
+ import { resolveEventContextPath } from './event-context';
7
8
  import {
8
9
  DocumentSymbols,
9
10
  PropertySymbol,
@@ -305,6 +306,8 @@ export function validateLines(lines: string[]): ValidationIssue[] {
305
306
  }
306
307
  }
307
308
 
309
+ issues.push(...validateEventContextPaths(lines, fences));
310
+
308
311
  const fenceLines = lines
309
312
  .map((line, index) => ({ line, index }))
310
313
  .filter(({ line }) => /^\s*```\s*$/.test(line));
@@ -324,3 +327,59 @@ export function validateLines(lines: string[]): ValidationIssue[] {
324
327
 
325
328
  return issues;
326
329
  }
330
+
331
+ // A mapping target: the first word of a mapping line, after the keyword that opens it if there is one.
332
+ const mappingTarget = /^(?:(?:increment|decrement|count|clear|add|subtract|set)\s+)?@?([$\w.]+)(?=\s|=|$)/;
333
+
334
+ // Every $eventContext.<path> - in an expression or as the dynamic key of a mapping target - checked against the
335
+ // event-context catalog the way the compiler checks it (Parsing/EventContextPathValidator.cs).
336
+ export function validateEventContextPaths(lines: string[], fences: boolean[]): ValidationIssue[] {
337
+ const issues: ValidationIssue[] = [];
338
+ const names = (members: readonly { name: string }[]) => members.map((member) => member.name).join(', ');
339
+
340
+ for (let index = 0; index < lines.length; index++) {
341
+ const line = lines[index];
342
+ if (fences[index]) continue;
343
+
344
+ for (const match of line.matchAll(/(\.)?\$eventContext((?:\.(?:\w+(?:\(\))?)?)*)/g)) {
345
+ const token = match[0].substring(match[1]?.length ?? 0);
346
+ const written = match[2];
347
+ if (written.length === 0) {
348
+ // A bare $eventContext names nothing only as a dynamic key; in an expression the compiler rejects it as invalid.
349
+ if (match[1]) {
350
+ issues.push(issue('error', index, (match.index ?? 0) + 2, token.length, `'$eventContext' names no member - expected one of ${names(resolveEventContextPath('').expected)}.`, diagnosticCodes.missingEventContextPath));
351
+ }
352
+ continue;
353
+ }
354
+
355
+ const path = written.substring(1);
356
+ const resolution = resolveEventContextPath(path);
357
+ const column = (match.index ?? 0) + (match[1]?.length ?? 0) + 1;
358
+ switch (resolution.status) {
359
+ case 'missing':
360
+ issues.push(issue('error', index, column, token.length, `'$eventContext.${path}' names no member - expected one of ${names(resolution.expected)}.`, diagnosticCodes.missingEventContextPath));
361
+ break;
362
+ case 'unknownMember':
363
+ issues.push(issue('warning', index, column, token.length, `Unknown event context member '${resolution.segment}' in '$eventContext.${path}' - expected one of ${names(resolution.expected)}.`, diagnosticCodes.unknownEventContextMember));
364
+ break;
365
+ case 'unknownSubPath': {
366
+ const has = resolution.expected.length === 0 ? 'no members to address' : `members ${names(resolution.expected)}`;
367
+ issues.push(issue('warning', index, column, token.length, `Unknown event context path '$eventContext.${path}' - '${resolution.member?.name}' (${resolution.member?.type}) has ${has}.`, diagnosticCodes.unknownEventContextPath));
368
+ break;
369
+ }
370
+ case 'belowCollection':
371
+ issues.push(issue('error', index, column, token.length, `'$eventContext.${path}' cannot resolve - '${resolution.member?.name}' is a collection (${resolution.member?.type}) and cannot be addressed below.`, diagnosticCodes.eventContextPathBelowCollection));
372
+ break;
373
+ }
374
+ }
375
+
376
+ // Only $eventContext resolves in a dynamic key; any other $ source is kept as the literal key text.
377
+ const target = line.trim().match(mappingTarget)?.[1];
378
+ const source = target?.match(/\.\$(\w+)/)?.[1];
379
+ if (target && source && source !== 'eventContext') {
380
+ issues.push(tokenIssue('warning', index, line, target, `Dynamic dictionary key '$${source}' in '${target}' is never resolved - only '$eventContext.<path>' is, so the literal text '$${source}' becomes the key.`, diagnosticCodes.unresolvedDynamicKeySource));
381
+ }
382
+ }
383
+
384
+ return issues;
385
+ }