@domainlang/language 0.8.0 → 0.10.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.
@@ -0,0 +1,213 @@
1
+ /**
2
+ * AST Serialization Utilities
3
+ *
4
+ * Converts Langium AST nodes to plain JSON objects suitable for:
5
+ * - LSP custom requests (JSON-RPC transport)
6
+ * - MCP tool responses (stdio JSON)
7
+ * - CLI output (JSON/YAML formats)
8
+ *
9
+ * ## Strategy
10
+ *
11
+ * Rather than maintaining a parallel DTO type hierarchy (DomainDto, BoundedContextDto, etc.),
12
+ * we use a **generic serializer** that:
13
+ * - Strips Langium internal properties ($container, $cstNode, $document)
14
+ * - Preserves $type for discriminated output
15
+ * - Resolves Reference<T> to referenced name strings
16
+ * - Resolves MultiReference<T> to arrays of names
17
+ * - Recursively serializes child AstNodes
18
+ * - Adds FQN for named elements via Query
19
+ *
20
+ * For types with SDK-augmented properties (computed values not on raw AST),
21
+ * use augmentation functions that enrich the generic output.
22
+ *
23
+ * @packageDocumentation
24
+ */
25
+
26
+ import type { AstNode, Reference } from 'langium';
27
+ import { isAstNode, isReference } from 'langium';
28
+ import type { Query, RelationshipView } from './types.js';
29
+
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+ // Types
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Canonical entity types that can be queried.
36
+ * Moved from CLI to SDK for sharing with LSP tools.
37
+ */
38
+ export type QueryEntityType =
39
+ | 'domains'
40
+ | 'bcs'
41
+ | 'teams'
42
+ | 'classifications'
43
+ | 'relationships'
44
+ | 'context-maps'
45
+ | 'domain-maps';
46
+
47
+ /**
48
+ * All accepted entity type names, including aliases.
49
+ * Aliases are normalized to canonical types before query execution.
50
+ */
51
+ export type QueryEntityInput = QueryEntityType
52
+ | 'bounded-contexts' | 'contexts'
53
+ | 'rels'
54
+ | 'cmaps'
55
+ | 'dmaps';
56
+
57
+ /**
58
+ * Query filter options.
59
+ * Moved from CLI to SDK for sharing with LSP tools.
60
+ */
61
+ export interface QueryFilters {
62
+ /** Filter by name (string or regex) */
63
+ name?: string;
64
+ /** Filter by fully qualified name */
65
+ fqn?: string;
66
+ /** Filter BCs by domain */
67
+ domain?: string;
68
+ /** Filter BCs by team */
69
+ team?: string;
70
+ /** Filter BCs by classification */
71
+ classification?: string;
72
+ /** Filter BCs by metadata key=value */
73
+ metadata?: string;
74
+ }
75
+
76
+ /**
77
+ * Map of entity type aliases to their canonical form.
78
+ */
79
+ export const ENTITY_ALIASES: Record<string, QueryEntityType> = {
80
+ 'bounded-contexts': 'bcs',
81
+ 'contexts': 'bcs',
82
+ 'rels': 'relationships',
83
+ 'cmaps': 'context-maps',
84
+ 'dmaps': 'domain-maps',
85
+ };
86
+
87
+ /**
88
+ * Normalize an entity type input (which may be an alias) to its canonical form.
89
+ */
90
+ export function normalizeEntityType(input: string): QueryEntityType {
91
+ if (input in ENTITY_ALIASES) {
92
+ return ENTITY_ALIASES[input];
93
+ }
94
+ return input as QueryEntityType;
95
+ }
96
+
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+ // Generic AST Serialization
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Serialize any Langium AST node to a plain JSON object.
103
+ *
104
+ * - Strips $-prefixed internal properties ($container, $cstNode, $document)
105
+ * - Preserves $type for discriminated output
106
+ * - Resolves Reference<T> to the referenced name (string)
107
+ * - Resolves MultiReference<T> to an array of names
108
+ * - Recursively serializes child AstNode properties
109
+ * - Serializes arrays of AstNodes/values
110
+ * - Adds FQN for named elements
111
+ *
112
+ * @param node - AST node to serialize
113
+ * @param query - Query instance for FQN resolution
114
+ * @returns Plain JSON object
115
+ */
116
+ export function serializeNode(node: AstNode, query: Query): Record<string, unknown> {
117
+ const result: Record<string, unknown> = { $type: node.$type };
118
+
119
+ for (const [key, value] of Object.entries(node)) {
120
+ // Skip Langium internals (but preserve $type)
121
+ if (key.startsWith('$') && key !== '$type') {
122
+ continue;
123
+ }
124
+
125
+ if (isReference(value)) {
126
+ // Reference<T> → name string
127
+ const ref = value.ref;
128
+ result[key] = (ref && 'name' in ref) ? (ref as { name?: string }).name : value.$refText;
129
+ } else if (isAstNode(value)) {
130
+ // Nested AstNode → recurse
131
+ result[key] = serializeNode(value, query);
132
+ } else if (Array.isArray(value)) {
133
+ // Array → map each item
134
+ result[key] = value.map(item => {
135
+ if (isReference(item)) {
136
+ const itemRef = item.ref;
137
+ return (itemRef && 'name' in itemRef) ? (itemRef as { name?: string }).name : item.$refText;
138
+ } else if (isAstNode(item)) {
139
+ return serializeNode(item, query);
140
+ } else {
141
+ return item; // primitive
142
+ }
143
+ });
144
+ } else {
145
+ // Primitives pass through
146
+ result[key] = value;
147
+ }
148
+ }
149
+
150
+ // Always include FQN for named elements
151
+ if ('name' in node && typeof (node as { name?: unknown }).name === 'string') {
152
+ result.fqn = query.fqn(node);
153
+ }
154
+
155
+ return result;
156
+ }
157
+
158
+ /**
159
+ * Augment a serialized RelationshipView with computed properties.
160
+ *
161
+ * RelationshipView is already a clean DTO (not an AstNode), but we format it
162
+ * consistently with other serialized types.
163
+ *
164
+ * @param view - RelationshipView from query.relationships()
165
+ * @returns Serialized relationship object
166
+ */
167
+ export function serializeRelationship(view: RelationshipView): Record<string, unknown> {
168
+ // RelationshipView.left and .right are BoundedContext (which have name property)
169
+ const leftName = view.left.name;
170
+ const rightName = view.right.name;
171
+ return {
172
+ $type: 'Relationship',
173
+ name: `${leftName} ${view.arrow} ${rightName}`,
174
+ left: leftName,
175
+ right: rightName,
176
+ arrow: view.arrow,
177
+ leftPatterns: view.leftPatterns,
178
+ rightPatterns: view.rightPatterns,
179
+ inferredType: view.inferredType,
180
+ };
181
+ }
182
+
183
+ // ─────────────────────────────────────────────────────────────────────────────
184
+ // Helper: Resolve Reference
185
+ // ─────────────────────────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Resolve a Reference<T> to its name string.
189
+ * Returns undefined if reference is unresolved.
190
+ *
191
+ * @param ref - Reference to resolve
192
+ * @returns Referenced name or undefined
193
+ */
194
+ export function resolveName<T extends AstNode & { name?: string }>(ref: Reference<T> | undefined): string | undefined {
195
+ if (!ref) return undefined;
196
+ return ref.ref?.name ?? ref.$refText;
197
+ }
198
+
199
+ /**
200
+ * Resolve a MultiReference (array of items with refs) to an array of names.
201
+ * Filters out unresolved references.
202
+ *
203
+ * @param multiRef - Array of items with ref property
204
+ * @returns Array of resolved names
205
+ */
206
+ export function resolveMultiReference<T extends { ref?: Reference<AstNode & { name?: string }> }>(
207
+ multiRef: T[] | undefined
208
+ ): string[] {
209
+ if (!multiRef) return [];
210
+ return multiRef
211
+ .map(item => item.ref?.ref?.name)
212
+ .filter((name): name is string => name !== undefined);
213
+ }
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Model validation utilities for Node.js environments.
3
+ *
4
+ * **WARNING: This module is NOT browser-compatible.**
5
+ *
6
+ * Provides validation capabilities that leverage the LSP infrastructure
7
+ * for workspace initialization, import resolution, and document building.
8
+ *
9
+ * @module sdk/validator
10
+ */
11
+
12
+ import { NodeFileSystem } from 'langium/node';
13
+ import { URI } from 'langium';
14
+ import { createDomainLangServices } from '../domain-lang-module.js';
15
+ import { ensureImportGraphFromDocument } from '../utils/import-utils.js';
16
+ import { isModel } from '../generated/ast.js';
17
+ import { dirname, resolve, join } from 'node:path';
18
+ import { existsSync } from 'node:fs';
19
+
20
+ /**
21
+ * Validation diagnostic with file context.
22
+ */
23
+ export interface ValidationDiagnostic {
24
+ /** Diagnostic severity (1=error, 2=warning, 3=info, 4=hint) */
25
+ severity: number;
26
+ /** Diagnostic message */
27
+ message: string;
28
+ /** File path where diagnostic occurred */
29
+ file: string;
30
+ /** Line number (1-based) */
31
+ line: number;
32
+ /** Column number (1-based) */
33
+ column: number;
34
+ }
35
+
36
+ /**
37
+ * Result of model validation.
38
+ */
39
+ export interface ValidationResult {
40
+ /** Whether the model is valid (no errors) */
41
+ valid: boolean;
42
+ /** Number of files validated */
43
+ fileCount: number;
44
+ /** Number of domains in the model */
45
+ domainCount: number;
46
+ /** Number of bounded contexts in the model */
47
+ bcCount: number;
48
+ /** Validation errors */
49
+ errors: ValidationDiagnostic[];
50
+ /** Validation warnings */
51
+ warnings: ValidationDiagnostic[];
52
+ }
53
+
54
+ /**
55
+ * Options for validation.
56
+ */
57
+ export interface ValidationOptions {
58
+ /** Workspace directory (defaults to file's directory) */
59
+ workspaceDir?: string;
60
+ }
61
+
62
+ /**
63
+ * Convert Langium diagnostic to ValidationDiagnostic.
64
+ */
65
+ function toValidationDiagnostic(
66
+ diagnostic: { severity?: number; message: string; range: { start: { line: number; character: number } } },
67
+ file: string
68
+ ): ValidationDiagnostic {
69
+ return {
70
+ severity: diagnostic.severity ?? 1,
71
+ message: diagnostic.message,
72
+ file,
73
+ line: diagnostic.range.start.line + 1,
74
+ column: diagnostic.range.start.character + 1,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Validates a DomainLang model file and all its imports.
80
+ *
81
+ * Uses the LSP infrastructure to:
82
+ * - Initialize the workspace
83
+ * - Resolve and load imports
84
+ * - Build and validate all documents
85
+ *
86
+ * @param filePath - Path to the entry .dlang file
87
+ * @param options - Validation options
88
+ * @returns Validation result with errors, warnings, and model statistics
89
+ * @throws Error if file doesn't exist or has invalid extension
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * import { validateFile } from '@domainlang/language/sdk';
94
+ *
95
+ * const result = await validateFile('./index.dlang');
96
+ *
97
+ * if (!result.valid) {
98
+ * for (const err of result.errors) {
99
+ * console.error(`${err.file}:${err.line}:${err.column}: ${err.message}`);
100
+ * }
101
+ * process.exit(1);
102
+ * }
103
+ *
104
+ * console.log(`✓ Validated ${result.fileCount} files`);
105
+ * console.log(` ${result.domainCount} domains, ${result.bcCount} bounded contexts`);
106
+ * ```
107
+ */
108
+ export async function validateFile(
109
+ filePath: string,
110
+ options: ValidationOptions = {}
111
+ ): Promise<ValidationResult> {
112
+ // Resolve absolute path
113
+ const absolutePath = resolve(filePath);
114
+
115
+ // Check file exists
116
+ if (!existsSync(absolutePath)) {
117
+ throw new Error(`File not found: ${filePath}`);
118
+ }
119
+
120
+ // Create services with workspace support
121
+ const servicesObj = createDomainLangServices(NodeFileSystem);
122
+ const shared = servicesObj.shared;
123
+ const services = servicesObj.DomainLang;
124
+
125
+ // Check file extension
126
+ const extensions = services.LanguageMetaData.fileExtensions;
127
+ if (!extensions.some(ext => absolutePath.endsWith(ext))) {
128
+ throw new Error(`Invalid file extension. Expected: ${extensions.join(', ')}`);
129
+ }
130
+
131
+ // Initialize workspace with the specified directory or file's directory
132
+ const workspaceDir = options.workspaceDir ?? dirname(absolutePath);
133
+ const workspaceManager = services.imports.WorkspaceManager;
134
+ await workspaceManager.initialize(workspaceDir);
135
+
136
+ // Load and parse the document
137
+ const uri = URI.file(absolutePath);
138
+ const document = await shared.workspace.LangiumDocuments.getOrCreateDocument(uri);
139
+
140
+ // Build document initially without validation to load imports
141
+ await shared.workspace.DocumentBuilder.build([document], { validation: false });
142
+
143
+ // Load all imported documents via the import graph
144
+ const importResolver = services.imports.ImportResolver;
145
+ await ensureImportGraphFromDocument(
146
+ document,
147
+ shared.workspace.LangiumDocuments,
148
+ importResolver
149
+ );
150
+
151
+ // Build all documents with validation enabled
152
+ const allDocuments = Array.from(shared.workspace.LangiumDocuments.all);
153
+ await shared.workspace.DocumentBuilder.build(allDocuments, { validation: true });
154
+
155
+ // Collect diagnostics from the entry document
156
+ const diagnostics = document.diagnostics ?? [];
157
+ const errors: ValidationDiagnostic[] = [];
158
+ const warnings: ValidationDiagnostic[] = [];
159
+
160
+ for (const diagnostic of diagnostics) {
161
+ const validationDiag = toValidationDiagnostic(diagnostic, absolutePath);
162
+ if (diagnostic.severity === 1) {
163
+ errors.push(validationDiag);
164
+ } else if (diagnostic.severity === 2) {
165
+ warnings.push(validationDiag);
166
+ }
167
+ }
168
+
169
+ // Count model elements across all documents
170
+ let domainCount = 0;
171
+ let bcCount = 0;
172
+
173
+ for (const doc of allDocuments) {
174
+ const model = doc.parseResult?.value;
175
+ if (isModel(model)) {
176
+ for (const element of model.children ?? []) {
177
+ if (element.$type === 'Domain') {
178
+ domainCount++;
179
+ } else if (element.$type === 'BoundedContext') {
180
+ bcCount++;
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ return {
187
+ valid: errors.length === 0,
188
+ fileCount: allDocuments.length,
189
+ domainCount,
190
+ bcCount,
191
+ errors,
192
+ warnings,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Workspace validation result with diagnostics grouped by file.
198
+ */
199
+ export interface WorkspaceValidationResult {
200
+ /** Whether the workspace is valid (no errors in any file) */
201
+ valid: boolean;
202
+ /** Number of files validated */
203
+ fileCount: number;
204
+ /** Number of domains across all files */
205
+ domainCount: number;
206
+ /** Number of bounded contexts across all files */
207
+ bcCount: number;
208
+ /** Validation errors grouped by file path */
209
+ errors: ValidationDiagnostic[];
210
+ /** Validation warnings grouped by file path */
211
+ warnings: ValidationDiagnostic[];
212
+ /** Total number of diagnostics across all files */
213
+ totalDiagnostics: number;
214
+ }
215
+
216
+ /**
217
+ * Validates an entire DomainLang workspace.
218
+ *
219
+ * Uses the LSP infrastructure to:
220
+ * - Initialize the workspace from a directory containing model.yaml
221
+ * - Load the entry file (from manifest or default index.dlang)
222
+ * - Resolve and load all imports
223
+ * - Build and validate all documents in the workspace
224
+ * - Collect diagnostics from ALL documents (like VS Code Problems pane)
225
+ *
226
+ * @param workspaceDir - Path to the workspace directory (containing model.yaml)
227
+ * @returns Validation result with diagnostics from all files
228
+ * @throws Error if workspace directory doesn't exist or cannot be loaded
229
+ *
230
+ * @example
231
+ * ```typescript
232
+ * import { validateWorkspace } from '@domainlang/language/sdk';
233
+ *
234
+ * const result = await validateWorkspace('./my-workspace');
235
+ *
236
+ * if (!result.valid) {
237
+ * console.error(`Found ${result.errors.length} errors in ${result.fileCount} files`);
238
+ *
239
+ * for (const err of result.errors) {
240
+ * console.error(`${err.file}:${err.line}:${err.column}: ${err.message}`);
241
+ * }
242
+ * process.exit(1);
243
+ * }
244
+ *
245
+ * console.log(`✓ Validated ${result.fileCount} files`);
246
+ * console.log(` ${result.domainCount} domains, ${result.bcCount} bounded contexts`);
247
+ * console.log(` 0 errors, ${result.warnings.length} warnings`);
248
+ * ```
249
+ */
250
+ export async function validateWorkspace(
251
+ workspaceDir: string
252
+ ): Promise<WorkspaceValidationResult> {
253
+ // Resolve absolute path
254
+ const absolutePath = resolve(workspaceDir);
255
+
256
+ // Check directory exists
257
+ if (!existsSync(absolutePath)) {
258
+ throw new Error(`Workspace directory not found: ${workspaceDir}`);
259
+ }
260
+
261
+ // Create services with workspace support
262
+ const servicesObj = createDomainLangServices(NodeFileSystem);
263
+ const shared = servicesObj.shared;
264
+ const services = servicesObj.DomainLang;
265
+ const workspaceManager = services.imports.WorkspaceManager;
266
+
267
+ try {
268
+ // Initialize workspace - this will find and load model.yaml
269
+ await workspaceManager.initialize(absolutePath);
270
+ } catch (error) {
271
+ const message = error instanceof Error ? error.message : String(error);
272
+ throw new Error(`Failed to initialize workspace at ${workspaceDir}: ${message}`);
273
+ }
274
+
275
+ // Get the manifest to find the entry file
276
+ const manifest = await workspaceManager.getManifest();
277
+ let entryFile = 'index.dlang';
278
+
279
+ if (manifest?.model?.entry) {
280
+ entryFile = manifest.model.entry;
281
+ }
282
+
283
+ const entryPath = join(absolutePath, entryFile);
284
+
285
+ // Check if entry file exists
286
+ if (!existsSync(entryPath)) {
287
+ throw new Error(
288
+ `Entry file not found: ${entryFile}\n` +
289
+ `Expected at: ${entryPath}\n` +
290
+ (manifest ? `Specified in manifest` : `Using default entry file`)
291
+ );
292
+ }
293
+
294
+ // Load and parse the entry document
295
+ const uri = URI.file(entryPath);
296
+ const document = await shared.workspace.LangiumDocuments.getOrCreateDocument(uri);
297
+
298
+ // Build document initially without validation to load imports
299
+ await shared.workspace.DocumentBuilder.build([document], { validation: false });
300
+
301
+ // Load all imported documents via the import graph
302
+ const importResolver = services.imports.ImportResolver;
303
+ await ensureImportGraphFromDocument(
304
+ document,
305
+ shared.workspace.LangiumDocuments,
306
+ importResolver
307
+ );
308
+
309
+ // Build all documents with validation enabled
310
+ const allDocuments = Array.from(shared.workspace.LangiumDocuments.all);
311
+ await shared.workspace.DocumentBuilder.build(allDocuments, { validation: true });
312
+
313
+ // Collect diagnostics from ALL documents (not just entry)
314
+ const errors: ValidationDiagnostic[] = [];
315
+ const warnings: ValidationDiagnostic[] = [];
316
+
317
+ for (const doc of allDocuments) {
318
+ const diagnostics = doc.diagnostics ?? [];
319
+ const docPath = doc.uri.fsPath;
320
+
321
+ for (const diagnostic of diagnostics) {
322
+ const validationDiag = toValidationDiagnostic(diagnostic, docPath);
323
+
324
+ if (diagnostic.severity === 1) {
325
+ errors.push(validationDiag);
326
+ } else if (diagnostic.severity === 2) {
327
+ warnings.push(validationDiag);
328
+ }
329
+ }
330
+ }
331
+
332
+ // Count model elements across all documents
333
+ let domainCount = 0;
334
+ let bcCount = 0;
335
+
336
+ for (const doc of allDocuments) {
337
+ const model = doc.parseResult?.value;
338
+ if (isModel(model)) {
339
+ for (const element of model.children ?? []) {
340
+ if (element.$type === 'Domain') {
341
+ domainCount++;
342
+ } else if (element.$type === 'BoundedContext') {
343
+ bcCount++;
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ return {
350
+ valid: errors.length === 0,
351
+ fileCount: allDocuments.length,
352
+ domainCount,
353
+ bcCount,
354
+ errors,
355
+ warnings,
356
+ totalDiagnostics: errors.length + warnings.length,
357
+ };
358
+ }