@cratis/screenplay-language 1.6.4 → 1.7.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 (45) hide show
  1. package/completion-items.ts +45 -3
  2. package/completion-planner.ts +12 -1
  3. package/completions.ts +7 -0
  4. package/dist/completion-items.d.ts +4 -0
  5. package/dist/completion-items.d.ts.map +1 -1
  6. package/dist/completion-items.js +41 -3
  7. package/dist/completion-items.js.map +1 -1
  8. package/dist/completion-planner.d.ts.map +1 -1
  9. package/dist/completion-planner.js +12 -1
  10. package/dist/completion-planner.js.map +1 -1
  11. package/dist/completions.d.ts.map +1 -1
  12. package/dist/completions.js +1 -0
  13. package/dist/completions.js.map +1 -1
  14. package/dist/hover-content.d.ts.map +1 -1
  15. package/dist/hover-content.js +18 -1
  16. package/dist/hover-content.js.map +1 -1
  17. package/dist/index.d.ts +3 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/keyword-docs.d.ts.map +1 -1
  22. package/dist/keyword-docs.js +21 -6
  23. package/dist/keyword-docs.js.map +1 -1
  24. package/dist/language.d.ts +2 -0
  25. package/dist/language.d.ts.map +1 -1
  26. package/dist/language.js +18 -2
  27. package/dist/language.js.map +1 -1
  28. package/dist/symbols.d.ts +15 -1
  29. package/dist/symbols.d.ts.map +1 -1
  30. package/dist/symbols.js +71 -9
  31. package/dist/symbols.js.map +1 -1
  32. package/dist/tokens.d.ts.map +1 -1
  33. package/dist/tokens.js +1 -0
  34. package/dist/tokens.js.map +1 -1
  35. package/dist/validation.d.ts.map +1 -1
  36. package/dist/validation.js +50 -3
  37. package/dist/validation.js.map +1 -1
  38. package/hover-content.ts +23 -1
  39. package/index.ts +5 -1
  40. package/keyword-docs.ts +21 -6
  41. package/language.ts +20 -2
  42. package/package.json +1 -1
  43. package/symbols.ts +94 -10
  44. package/tokens.ts +1 -0
  45. package/validation.ts +90 -3
package/keyword-docs.ts CHANGED
@@ -4,7 +4,10 @@
4
4
  export const keywordDocs: Record<string, string> = {
5
5
  domain: 'The domain the file belongs to — optional, at most one per file, before everything else.',
6
6
  import: 'Imports a type from another module; it becomes available by its short name.',
7
- concept: 'A formalized value type wrapping a primitive. Attributes such as `@pii` travel with every usage.',
7
+ concept: 'A formalized value type wrapping a primitive. Attributes such as `@pii` travel with every usage, optionally with a `<attribute> reason "..."` line saying why.',
8
+ type: 'A composite value type — a named shape built from several properties, referenced by events, commands and other types the same way a concept is.',
9
+ reason: 'Records why a concept attribute applies — the purpose, the lawful basis, whose subject the value lives under. Written as `pii reason "..."` in the concept body.',
10
+ identifier: 'Marks the command property a runtime resolves the event source id from. At most one per command; without it a new Uuid is generated.',
8
11
  policy: 'A named authorization rule referenced by commands and queries. Multiple policies AND together.',
9
12
  persona: 'A named role interacting with the application, with an optional description and its associated policies.',
10
13
  authentication: 'Declares the identity providers of the application — at most one block per file.',
@@ -17,10 +20,12 @@ export const keywordDocs: Record<string, string> = {
17
20
  slice: 'The atomic unit of behavior, aligned with Event Modeling. Has a type and a name.',
18
21
  event: 'An event type — an immutable, past-tense fact. Properties use concepts or primitives.',
19
22
  command: 'An imperative intent. Declares authorization, validation, and what events it produces — declaratively, or via a full handler.',
20
- query: 'A read-side entry point mapping to a return type.',
23
+ query: 'A read-side entry point mapping to a return type, with an optional description, parameters and performer.',
24
+ performer: 'The code that performs a query — a `file <Path>` reference or an inline `csharp`/`sql` block. The query\'s counterpart to a command handler.',
25
+ from: 'Fills a query parameter from the query context instead of the caller, as in `filter tenantId TenantId from $context.tenant`.',
21
26
  projection: 'Declares how events project into a read model, using the embedded Projection Declaration Language (PDL).',
22
27
  capture: 'Converts external data into events, using the embedded Change Data Capture Language (CDL).',
23
- reactor: 'Reacts to events and produces side effects. Lives inside Automation slices.',
28
+ reactor: 'Reacts to events and produces side effects. Lives inside Automation slices. A trigger with no body states intent on its own — `file`/code is optional realization metadata.',
24
29
  screen: 'A UI declaration. Supports intent, structure, and layout-with-code levels, or a full file reference.',
25
30
  constraint: 'A server-side rule enforced in the Chronicle kernel before events are committed.',
26
31
  authorize: 'References the policies that must all pass for the construct to execute.',
@@ -70,6 +75,7 @@ export const keywordDocs: Record<string, string> = {
70
75
  and: 'Both conditions must hold.',
71
76
  or: 'Either condition may hold.',
72
77
  csharp: 'An inline C# block between triple backticks.',
78
+ sql: 'An inline SQL block between triple backticks — a query performer written against a relational store.',
73
79
  typescript: 'An inline TypeScript block between triple backticks.',
74
80
  react: 'An inline React/TSX block; receives the surrounding data contract as `Props`.',
75
81
  html: 'An inline static HTML block.',
@@ -88,12 +94,21 @@ export const keywordDocs: Record<string, string> = {
88
94
  };
89
95
 
90
96
  export const attributeDocs: Record<string, string> = {
91
- pii: '`@pii` — the value is personally identifiable information; Chronicle manages and erases it automatically wherever the concept is used.',
92
- sensitive: '`@sensitive` — the value is sensitive; Chronicle applies its sensitivity rules automatically wherever the concept is used.',
97
+ pii: '`@pii` — the value is personally identifiable information; Chronicle manages and erases it automatically wherever the concept is used. Add `pii reason "..."` in the concept body to record why.',
98
+ sensitive: '`@sensitive` — the value is sensitive; Chronicle applies its sensitivity rules automatically wherever the concept is used. Add `sensitive reason "..."` in the concept body to record why.',
93
99
  };
94
100
 
95
101
  export const contextVariableDocs: Record<string, string> = {
96
- '$context.occurred': 'The timestamp of the event.',
102
+ '$context.occurred': 'When the command or query was received.',
103
+ '$context.tenant': 'The tenant the command or query is executing for.',
104
+ '$context.command': 'The command being handled, shaped by its `command` declaration.',
105
+ '$context.arguments': 'The arguments of the query being performed.',
106
+ '$context.causedBy': 'The identity that caused the command or query — `subject`, `name`, `userName`.',
107
+ '$context.causedBy.subject': 'The subject of the identity that caused the command or query.',
108
+ '$context.causedBy.name': 'The display name of the identity that caused the command or query.',
109
+ '$context.causedBy.userName': 'The user name of the identity that caused the command or query.',
110
+ '$context.causation': 'What caused the command or query — `type`, `occurred`, `properties`.',
111
+ '$context.causation.type': 'What caused the command or query — a command, a reactor, a schedule.',
97
112
  '$context.identity.id': 'The subject of the caller from the auth token.',
98
113
  '$env': 'An environment variable, e.g. `$env.SERVICE_NAME`.',
99
114
  '$secrets': 'An encrypted secret resolved at runtime, e.g. `$secrets.azureAdClientId` — stays symbolic in the compiled tree.',
package/language.ts CHANGED
@@ -18,6 +18,7 @@ export const constructKeywords = [
18
18
  'domain',
19
19
  'import',
20
20
  'concept',
21
+ 'type',
21
22
  'policy',
22
23
  'persona',
23
24
  'authentication',
@@ -47,6 +48,10 @@ export const clauseKeywords = [
47
48
  'validate',
48
49
  'produces',
49
50
  'handler',
51
+ 'performer',
52
+ 'identifier',
53
+ 'reason',
54
+ 'from',
50
55
  'when',
51
56
  'given',
52
57
  'then',
@@ -92,7 +97,20 @@ export const clauseKeywords = [
92
97
  'provider',
93
98
  ];
94
99
 
95
- export const codeBlockTags = ['csharp', 'typescript', 'react', 'html'];
100
+ export const codeBlockTags = ['csharp', 'typescript', 'react', 'html', 'sql'];
101
+
102
+ // The roots a `$context.` path can start with — the members of CommandContext and QueryContext.
103
+ export const contextRoots = [
104
+ 'command',
105
+ 'arguments',
106
+ 'tenant',
107
+ 'causedBy',
108
+ 'causation',
109
+ 'occurred',
110
+ 'identity',
111
+ ];
112
+
113
+ export const causedByProperties = ['subject', 'name', 'userName'];
96
114
 
97
115
  export const sliceTypes = ['StateChange', 'StateView', 'Automation', 'Translate'];
98
116
 
@@ -123,7 +141,7 @@ export const languageConfiguration: languages.LanguageConfiguration = {
123
141
  },
124
142
  indentationRules: {
125
143
  increaseIndentPattern:
126
- /^\s*(module|feature|slice|policy|persona|authentication|provider|event|command|screen|projection|capture|reactor|constraint|layout|template|validate|produces|handler|section|concurrency|seed|for)\b.*$/,
144
+ /^\s*(module|feature|slice|policy|persona|authentication|provider|event|command|query|type|screen|projection|capture|reactor|constraint|layout|template|validate|produces|handler|performer|section|concurrency|seed|for)\b.*$/,
127
145
  // Dedents are always explicit in an offside language — never auto-dedent.
128
146
  decreaseIndentPattern: /(?!)/,
129
147
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/screenplay-language",
3
- "version": "1.6.4",
3
+ "version": "1.7.0",
4
4
  "description": "Monaco language service for the Cratis Screenplay DSL — syntax highlighting, IntelliSense, hover and diagnostics for .play files",
5
5
  "license": "MIT",
6
6
  "repository": {
package/symbols.ts CHANGED
@@ -6,16 +6,25 @@ import { fenceMap, indentOf } from './document-context';
6
6
  export interface PropertySymbol {
7
7
  name: string;
8
8
  type: string;
9
+ isIdentifier: boolean;
10
+ line: number;
9
11
  }
10
12
 
11
13
  export interface ConceptSymbol {
12
14
  name: string;
13
15
  primitive: string;
14
16
  attributes: string[];
17
+ attributeReasons: Record<string, string>;
15
18
  enumValues: string[];
16
19
  line: number;
17
20
  }
18
21
 
22
+ export interface TypeSymbol {
23
+ name: string;
24
+ properties: PropertySymbol[];
25
+ line: number;
26
+ }
27
+
19
28
  export interface PolicySymbol {
20
29
  name: string;
21
30
  requires: string[];
@@ -28,6 +37,10 @@ export interface EventSymbol {
28
37
  line: number;
29
38
  }
30
39
 
40
+ export interface CommandSymbol extends NamedSymbol {
41
+ properties: PropertySymbol[];
42
+ }
43
+
31
44
  export interface NamedSymbol {
32
45
  name: string;
33
46
  line: number;
@@ -35,6 +48,7 @@ export interface NamedSymbol {
35
48
 
36
49
  export interface QuerySymbol extends NamedSymbol {
37
50
  returnType: string;
51
+ parameters: PropertySymbol[];
38
52
  }
39
53
 
40
54
  export interface ImportSymbol {
@@ -46,15 +60,31 @@ export interface ImportSymbol {
46
60
  export interface DocumentSymbols {
47
61
  imports: ImportSymbol[];
48
62
  concepts: ConceptSymbol[];
63
+ types: TypeSymbol[];
49
64
  policies: PolicySymbol[];
50
65
  events: EventSymbol[];
51
- commands: NamedSymbol[];
66
+ commands: CommandSymbol[];
52
67
  queries: QuerySymbol[];
53
68
  screens: NamedSymbol[];
54
69
  }
55
70
 
56
71
  const conceptPattern = /^concept\s+(\w+)\s*:\s*(\w+)((?:\s+@\w+)*)\s*$/;
57
- const propertyPattern = /^\s*([a-z_]\w*)\s+([\w.]+(?:\[\])?\??)\s*$/;
72
+ const propertyPattern = /^\s*(@?[a-z_]\w*)\s+([\w.]+(?:\[\])?\??)(\s+identifier)?\s*$/;
73
+ const attributeReasonPattern = /^([a-z_]\w*)\s+reason\s+"((?:[^"\\]|\\.)*)"\s*$/;
74
+ const queryParameterPattern =
75
+ /^\s*(?:by|filter)\s+([a-z_]\w*)\s+([\w.]+(?:\[\])?\??)(?:\s+from\s+.+)?\s*$/;
76
+
77
+ function propertiesIn(lines: string[], body: number[]): PropertySymbol[] {
78
+ return body
79
+ .map((index) => ({ index, match: lines[index].match(propertyPattern) }))
80
+ .filter((entry): entry is { index: number; match: RegExpMatchArray } => entry.match !== null)
81
+ .map(({ index, match }) => ({
82
+ name: match[1].replace(/^@/, ''),
83
+ type: match[2],
84
+ isIdentifier: match[3] !== undefined,
85
+ line: index,
86
+ }));
87
+ }
58
88
 
59
89
  function collectBody(lines: string[], fences: boolean[], start: number, indent: number): number[] {
60
90
  const body: number[] = [];
@@ -75,6 +105,7 @@ export function scanDocument(lines: string[]): DocumentSymbols {
75
105
  const symbols: DocumentSymbols = {
76
106
  imports: [],
77
107
  concepts: [],
108
+ types: [],
78
109
  policies: [],
79
110
  events: [],
80
111
  commands: [],
@@ -99,20 +130,39 @@ export function scanDocument(lines: string[]): DocumentSymbols {
99
130
 
100
131
  const conceptMatch = trimmed.match(conceptPattern);
101
132
  if (conceptMatch) {
133
+ const body = collectBody(lines, fences, index, indent).map((i) => lines[i].trim());
134
+ const attributeReasons: Record<string, string> = {};
135
+ for (const line of body) {
136
+ const reason = line.match(attributeReasonPattern);
137
+ if (reason && attributeReasons[reason[1]] === undefined) {
138
+ attributeReasons[reason[1]] = reason[2];
139
+ }
140
+ }
102
141
  const enumValues =
103
142
  conceptMatch[2] === 'Enum'
104
- ? collectBody(lines, fences, index, indent).map((i) => lines[i].trim())
143
+ ? body.filter((line) => !attributeReasonPattern.test(line))
105
144
  : [];
106
145
  symbols.concepts.push({
107
146
  name: conceptMatch[1],
108
147
  primitive: conceptMatch[2],
109
148
  attributes: conceptMatch[3].trim().split(/\s+/).filter(Boolean),
149
+ attributeReasons,
110
150
  enumValues,
111
151
  line: index,
112
152
  });
113
153
  continue;
114
154
  }
115
155
 
156
+ const typeMatch = trimmed.match(/^type\s+(\w+)\s*$/);
157
+ if (typeMatch) {
158
+ symbols.types.push({
159
+ name: typeMatch[1],
160
+ properties: propertiesIn(lines, collectBody(lines, fences, index, indent)),
161
+ line: index,
162
+ });
163
+ continue;
164
+ }
165
+
116
166
  const policyMatch = trimmed.match(/^policy\s+(\w+)\s*$/);
117
167
  if (policyMatch) {
118
168
  const requires = collectBody(lines, fences, index, indent)
@@ -124,23 +174,46 @@ export function scanDocument(lines: string[]): DocumentSymbols {
124
174
 
125
175
  const eventMatch = trimmed.match(/^event\s+(\w+)\s*$/);
126
176
  if (eventMatch) {
127
- const properties = collectBody(lines, fences, index, indent)
128
- .map((i) => lines[i].match(propertyPattern))
129
- .filter((match): match is RegExpMatchArray => match !== null)
130
- .map((match) => ({ name: match[1], type: match[2] }));
131
- symbols.events.push({ name: eventMatch[1], properties, line: index });
177
+ symbols.events.push({
178
+ name: eventMatch[1],
179
+ properties: propertiesIn(lines, collectBody(lines, fences, index, indent)),
180
+ line: index,
181
+ });
132
182
  continue;
133
183
  }
134
184
 
135
185
  const commandMatch = trimmed.match(/^command\s+(\w+)\s*$/);
136
186
  if (commandMatch) {
137
- symbols.commands.push({ name: commandMatch[1], line: index });
187
+ symbols.commands.push({
188
+ name: commandMatch[1],
189
+ properties: propertiesIn(lines, collectBody(lines, fences, index, indent)),
190
+ line: index,
191
+ });
138
192
  continue;
139
193
  }
140
194
 
141
195
  const queryMatch = trimmed.match(/^query\s+(\w+)\s*=>\s*([\w[\]]+)\s*$/);
142
196
  if (queryMatch) {
143
- symbols.queries.push({ name: queryMatch[1], returnType: queryMatch[2], line: index });
197
+ // The return type names a read model, which no construct declares — only the
198
+ // 'by' and 'filter' parameters resolve against the document's own types.
199
+ const parameters = collectBody(lines, fences, index, indent)
200
+ .map((i) => ({ index: i, match: lines[i].match(queryParameterPattern) }))
201
+ .filter(
202
+ (entry): entry is { index: number; match: RegExpMatchArray } =>
203
+ entry.match !== null,
204
+ )
205
+ .map(({ index: line, match }) => ({
206
+ name: match[1],
207
+ type: match[2],
208
+ isIdentifier: false,
209
+ line,
210
+ }));
211
+ symbols.queries.push({
212
+ name: queryMatch[1],
213
+ returnType: queryMatch[2],
214
+ parameters,
215
+ line: index,
216
+ });
144
217
  continue;
145
218
  }
146
219
 
@@ -159,3 +232,14 @@ export function knownEventNames(symbols: DocumentSymbols): string[] {
159
232
  ...symbols.imports.map((imported) => imported.shortName),
160
233
  ];
161
234
  }
235
+
236
+ // Everything a property type reference can resolve to — the primitives, the declared
237
+ // concepts and types, and the short names of the imports.
238
+ export function knownTypeNames(symbols: DocumentSymbols, primitives: readonly string[]): string[] {
239
+ return [
240
+ ...primitives,
241
+ ...symbols.concepts.map((concept) => concept.name),
242
+ ...symbols.types.map((type) => type.name),
243
+ ...symbols.imports.map((imported) => imported.shortName),
244
+ ];
245
+ }
package/tokens.ts CHANGED
@@ -17,6 +17,7 @@ const embeddedLanguages: Record<string, string> = {
17
17
  typescript: 'typescript',
18
18
  react: 'typescript',
19
19
  html: 'html',
20
+ sql: 'sql',
20
21
  };
21
22
 
22
23
  function subLanguageState(keyword: string): string {
package/validation.ts CHANGED
@@ -1,9 +1,15 @@
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 { primitiveTypes, sliceTypes } from './language';
4
+ import { causedByProperties, contextRoots, primitiveTypes, sliceTypes } from './language';
5
5
  import { fenceMap, indentOf } from './document-context';
6
- import { knownEventNames, scanDocument } from './symbols';
6
+ import {
7
+ DocumentSymbols,
8
+ PropertySymbol,
9
+ knownEventNames,
10
+ knownTypeNames,
11
+ scanDocument,
12
+ } from './symbols';
7
13
 
8
14
  export type ValidationSeverity = 'error' | 'warning';
9
15
 
@@ -36,6 +42,69 @@ function tokenIssue(
36
42
  return issue(severity, line, column, token.length, message);
37
43
  }
38
44
 
45
+ // Reports every property whose type reference names nothing the document declares, and
46
+ // every command that marks more than one property as its identifier.
47
+ function validateDeclarations(lines: string[], symbols: DocumentSymbols): ValidationIssue[] {
48
+ const issues: ValidationIssue[] = [];
49
+ const types = new Set(knownTypeNames(symbols, primitiveTypes));
50
+
51
+ const checkProperties = (properties: PropertySymbol[], owner: string) => {
52
+ for (const property of properties.filter(
53
+ (candidate) => !types.has(candidate.type.replace(/[[\]?]/g, '')),
54
+ )) {
55
+ const bare = property.type.replace(/[[\]?]/g, '');
56
+ issues.push(
57
+ tokenIssue(
58
+ 'warning',
59
+ property.line,
60
+ lines[property.line],
61
+ property.type,
62
+ `Unknown type '${bare}' on '${property.name}' of ${owner} — declare it with 'concept ${bare} : <Primitive>' or 'type ${bare}'.`,
63
+ ),
64
+ );
65
+ }
66
+ };
67
+
68
+ for (const type of symbols.types) checkProperties(type.properties, `type '${type.name}'`);
69
+ for (const event of symbols.events) checkProperties(event.properties, `event '${event.name}'`);
70
+ for (const query of symbols.queries) checkProperties(query.parameters, `query '${query.name}'`);
71
+ for (const command of symbols.commands) {
72
+ checkProperties(command.properties, `command '${command.name}'`);
73
+ const identifiers = command.properties.filter((property) => property.isIdentifier);
74
+ for (const extra of identifiers.slice(1)) {
75
+ issues.push(
76
+ tokenIssue(
77
+ 'error',
78
+ extra.line,
79
+ lines[extra.line],
80
+ 'identifier',
81
+ `Command '${command.name}' already marks '${identifiers[0].name}' as identifier — only one property can be the identifier.`,
82
+ ),
83
+ );
84
+ }
85
+ }
86
+
87
+ const declared = new Map<string, number>();
88
+ for (const declaration of [...symbols.concepts, ...symbols.types]) {
89
+ const first = declared.get(declaration.name);
90
+ if (first === undefined) {
91
+ declared.set(declaration.name, declaration.line);
92
+ continue;
93
+ }
94
+ issues.push(
95
+ tokenIssue(
96
+ 'error',
97
+ declaration.line,
98
+ lines[declaration.line],
99
+ declaration.name,
100
+ `Duplicate declaration of '${declaration.name}' — concept and type names must be unique.`,
101
+ ),
102
+ );
103
+ }
104
+
105
+ return issues;
106
+ }
107
+
39
108
  // Validates a Screenplay document without any editor dependency — both the Monaco
40
109
  // service and the VSCode extension adapt these issues to their marker/diagnostic APIs.
41
110
  export function validateLines(lines: string[]): ValidationIssue[] {
@@ -43,7 +112,7 @@ export function validateLines(lines: string[]): ValidationIssue[] {
43
112
  const symbols = scanDocument(lines);
44
113
  const events = new Set(knownEventNames(symbols));
45
114
  const policies = new Set(symbols.policies.map((policy) => policy.name));
46
- const issues: ValidationIssue[] = [];
115
+ const issues: ValidationIssue[] = validateDeclarations(lines, symbols);
47
116
 
48
117
  const checkEvent = (line: number, text: string, name: string) => {
49
118
  if (!events.has(name)) {
@@ -131,6 +200,24 @@ export function validateLines(lines: string[]): ValidationIssue[] {
131
200
 
132
201
  const uniqueProperty = trimmed.match(/^unique\s+[a-z_]\w*\s+on\s+([A-Z]\w*)/);
133
202
  if (uniqueProperty) checkEvent(index, line, uniqueProperty[1]);
203
+
204
+ // Every $context. path must name something CommandContext or QueryContext carries.
205
+ for (const path of line.match(/\$context\.[\w.]+/g) ?? []) {
206
+ const segments = path.substring('$context.'.length).split('.');
207
+ if (!contextRoots.includes(segments[0])) {
208
+ issues.push(
209
+ tokenIssue('warning', index, line, path, `Unknown $context path '${segments.join('.')}' — expected one of ${contextRoots.join(', ')}.`),
210
+ );
211
+ } else if (
212
+ segments[0] === 'causedBy' &&
213
+ segments.length > 1 &&
214
+ !causedByProperties.includes(segments[1])
215
+ ) {
216
+ issues.push(
217
+ tokenIssue('warning', index, line, path, `Unknown $context.causedBy property '${segments[1]}' — expected ${causedByProperties.join(', ')}.`),
218
+ );
219
+ }
220
+ }
134
221
  }
135
222
 
136
223
  const fenceLines = lines