@domainlang/language 0.10.0 → 0.11.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 (65) hide show
  1. package/README.md +44 -102
  2. package/out/domain-lang-module.d.ts +2 -2
  3. package/out/domain-lang-module.js +2 -2
  4. package/out/domain-lang-module.js.map +1 -1
  5. package/out/index.d.ts +3 -0
  6. package/out/index.js +3 -0
  7. package/out/index.js.map +1 -1
  8. package/out/lsp/domain-lang-completion.js +1 -1
  9. package/out/lsp/domain-lang-completion.js.map +1 -1
  10. package/out/lsp/domain-lang-index-manager.d.ts +149 -5
  11. package/out/lsp/domain-lang-index-manager.js +388 -52
  12. package/out/lsp/domain-lang-index-manager.js.map +1 -1
  13. package/out/lsp/domain-lang-refresh.d.ts +35 -0
  14. package/out/lsp/domain-lang-refresh.js +129 -0
  15. package/out/lsp/domain-lang-refresh.js.map +1 -0
  16. package/out/lsp/domain-lang-workspace-manager.d.ts +10 -0
  17. package/out/lsp/domain-lang-workspace-manager.js +35 -0
  18. package/out/lsp/domain-lang-workspace-manager.js.map +1 -1
  19. package/out/main.js +30 -190
  20. package/out/main.js.map +1 -1
  21. package/out/sdk/loader-node.js +1 -1
  22. package/out/sdk/loader-node.js.map +1 -1
  23. package/out/sdk/validator.js +17 -14
  24. package/out/sdk/validator.js.map +1 -1
  25. package/out/services/import-resolver.d.ts +67 -17
  26. package/out/services/import-resolver.js +146 -65
  27. package/out/services/import-resolver.js.map +1 -1
  28. package/out/services/lsp-logger.d.ts +42 -0
  29. package/out/services/lsp-logger.js +50 -0
  30. package/out/services/lsp-logger.js.map +1 -0
  31. package/out/services/lsp-runtime-settings.d.ts +20 -0
  32. package/out/services/lsp-runtime-settings.js +20 -0
  33. package/out/services/lsp-runtime-settings.js.map +1 -0
  34. package/out/services/performance-optimizer.d.ts +9 -9
  35. package/out/services/performance-optimizer.js +17 -41
  36. package/out/services/performance-optimizer.js.map +1 -1
  37. package/out/services/workspace-manager.d.ts +22 -1
  38. package/out/services/workspace-manager.js +57 -9
  39. package/out/services/workspace-manager.js.map +1 -1
  40. package/out/utils/import-utils.js +6 -6
  41. package/out/utils/import-utils.js.map +1 -1
  42. package/out/validation/constants.d.ts +6 -0
  43. package/out/validation/constants.js +7 -0
  44. package/out/validation/constants.js.map +1 -1
  45. package/out/validation/import.d.ts +13 -3
  46. package/out/validation/import.js +54 -10
  47. package/out/validation/import.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/domain-lang-module.ts +3 -3
  50. package/src/index.ts +3 -0
  51. package/src/lsp/domain-lang-completion.ts +3 -3
  52. package/src/lsp/domain-lang-index-manager.ts +438 -56
  53. package/src/lsp/domain-lang-refresh.ts +205 -0
  54. package/src/lsp/domain-lang-workspace-manager.ts +45 -0
  55. package/src/main.ts +36 -244
  56. package/src/sdk/loader-node.ts +1 -1
  57. package/src/sdk/validator.ts +17 -13
  58. package/src/services/import-resolver.ts +196 -89
  59. package/src/services/lsp-logger.ts +89 -0
  60. package/src/services/lsp-runtime-settings.ts +34 -0
  61. package/src/services/performance-optimizer.ts +18 -57
  62. package/src/services/workspace-manager.ts +62 -10
  63. package/src/utils/import-utils.ts +6 -6
  64. package/src/validation/constants.ts +9 -0
  65. package/src/validation/import.ts +67 -12
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
3
4
  import YAML from 'yaml';
4
5
  import { getGlobalOptimizer } from './performance-optimizer.js';
5
6
  import { fileExists as checkFileExists, findWorkspaceRoot as findWorkspaceRootUtil } from '../utils/manifest-utils.js';
@@ -24,7 +25,8 @@ const DEFAULT_LOCK_FILES = [
24
25
  interface ManifestCache {
25
26
  readonly manifest: ModelManifest;
26
27
  readonly path: string;
27
- readonly mtimeMs: number;
28
+ /** SHA-256 content hash for reliable change detection (PRS-017 R5) */
29
+ readonly contentHash: string;
28
30
  }
29
31
 
30
32
  interface LoadedLockFile {
@@ -65,7 +67,7 @@ interface WorkspaceContext {
65
67
  * - Read manifest configuration (path aliases, dependencies)
66
68
  * - Read lock file (to resolve cached package locations)
67
69
  */
68
- export class WorkspaceManager {
70
+ export class ManifestManager {
69
71
  private readonly manifestFiles: readonly string[];
70
72
  private readonly lockFiles: readonly string[];
71
73
 
@@ -80,6 +82,14 @@ export class WorkspaceManager {
80
82
  * Avoids repeated directory tree walking for the same paths.
81
83
  */
82
84
  private readonly pathToRootCache = new Map<string, string>();
85
+
86
+ /**
87
+ * PRS-017 R11: Cached set of directories known to contain a manifest file.
88
+ * Populated during `findWorkspaceRoot()` walks and updated incrementally
89
+ * when manifest creation/deletion events arrive via `onManifestEvent()`.
90
+ * Prevents redundant filesystem walks for paths already explored.
91
+ */
92
+ private readonly knownManifestDirs = new Set<string>();
83
93
 
84
94
  /**
85
95
  * The currently active workspace root (set by last initialize() call).
@@ -158,7 +168,7 @@ export class WorkspaceManager {
158
168
  */
159
169
  getWorkspaceRoot(): string {
160
170
  if (!this.activeRoot) {
161
- throw new Error('WorkspaceManager not initialized. Call initialize() first.');
171
+ throw new Error('ManifestManager not initialized. Call initialize() first.');
162
172
  }
163
173
  return this.activeRoot;
164
174
  }
@@ -172,7 +182,7 @@ export class WorkspaceManager {
172
182
  */
173
183
  getCacheDir(): string {
174
184
  if (!this.activeRoot) {
175
- throw new Error('WorkspaceManager not initialized. Call initialize() first.');
185
+ throw new Error('ManifestManager not initialized. Call initialize() first.');
176
186
  }
177
187
 
178
188
  // If workspace root is inside .dlang/packages, find the project root
@@ -320,6 +330,28 @@ export class WorkspaceManager {
320
330
  }
321
331
  }
322
332
 
333
+ /**
334
+ * PRS-017 R11: Incrementally updates the workspace layout cache
335
+ * when a manifest file is created or deleted.
336
+ *
337
+ * @param manifestDir - Directory where the manifest was created/deleted
338
+ * @param created - true if manifest was created, false if deleted
339
+ */
340
+ onManifestEvent(manifestDir: string, created: boolean): void {
341
+ const normalized = path.resolve(manifestDir);
342
+ if (created) {
343
+ this.knownManifestDirs.add(normalized);
344
+ } else {
345
+ this.knownManifestDirs.delete(normalized);
346
+ // Invalidate path-to-root cache entries that pointed to this dir
347
+ for (const [startPath, root] of this.pathToRootCache) {
348
+ if (root === normalized) {
349
+ this.pathToRootCache.delete(startPath);
350
+ }
351
+ }
352
+ }
353
+ }
354
+
323
355
  /**
324
356
  * Returns the path aliases from the manifest, if present.
325
357
  */
@@ -437,7 +469,7 @@ export class WorkspaceManager {
437
469
  }
438
470
  }
439
471
 
440
- throw new Error('WorkspaceManager not initialized. Call initialize() first.');
472
+ throw new Error('ManifestManager not initialized. Call initialize() first.');
441
473
  }
442
474
 
443
475
  private async loadLockFileFromDisk(root?: string): Promise<LoadedLockFile | undefined> {
@@ -498,13 +530,16 @@ export class WorkspaceManager {
498
530
  manifestPath: string,
499
531
  context: WorkspaceContext | undefined
500
532
  ): Promise<ModelManifest> {
501
- const stat = await fs.stat(manifestPath);
533
+ // PRS-017 R5: Use content hash instead of mtime for reliable change detection.
534
+ // Content hashing is immune to mtime skew after git operations or on NFS.
535
+ const content = await fs.readFile(manifestPath, 'utf-8');
536
+ const contentHash = this.computeHash(content);
537
+
502
538
  if (context?.manifestCache?.path === manifestPath &&
503
- context.manifestCache.mtimeMs === stat.mtimeMs) {
539
+ context.manifestCache.contentHash === contentHash) {
504
540
  return context.manifestCache.manifest;
505
541
  }
506
542
 
507
- const content = await fs.readFile(manifestPath, 'utf-8');
508
543
  const manifest = (YAML.parse(content) ?? {}) as ModelManifest;
509
544
 
510
545
  // Validate manifest structure
@@ -514,7 +549,7 @@ export class WorkspaceManager {
514
549
  context.manifestCache = {
515
550
  manifest,
516
551
  path: manifestPath,
517
- mtimeMs: stat.mtimeMs,
552
+ contentHash,
518
553
  };
519
554
  }
520
555
  return manifest;
@@ -690,11 +725,14 @@ export class WorkspaceManager {
690
725
  /**
691
726
  * Finds workspace root by walking up from startPath looking for model.yaml.
692
727
  * Uses configurable manifest files if specified in constructor options.
728
+ * PRS-017 R11: Consults `knownManifestDirs` before hitting the filesystem.
693
729
  */
694
730
  private async findWorkspaceRoot(startPath: string): Promise<string | undefined> {
695
731
  // Use shared utility for default case (single manifest file)
696
732
  if (this.manifestFiles.length === 1 && this.manifestFiles[0] === 'model.yaml') {
697
- return findWorkspaceRootUtil(startPath);
733
+ const result = await findWorkspaceRootUtil(startPath);
734
+ if (result) this.knownManifestDirs.add(result);
735
+ return result;
698
736
  }
699
737
 
700
738
  // Custom logic for multiple or non-default manifest files
@@ -702,7 +740,13 @@ export class WorkspaceManager {
702
740
  const { root } = path.parse(current);
703
741
 
704
742
  while (true) {
743
+ // R11: Check cached knowledge first
744
+ if (this.knownManifestDirs.has(current)) {
745
+ return current;
746
+ }
747
+
705
748
  if (await this.containsManifest(current)) {
749
+ this.knownManifestDirs.add(current);
706
750
  return current;
707
751
  }
708
752
 
@@ -727,4 +771,12 @@ export class WorkspaceManager {
727
771
  }
728
772
  return false;
729
773
  }
774
+
775
+ /**
776
+ * Computes a SHA-256 hex digest of the given content.
777
+ * Used for content-hash based cache validation (PRS-017 R5).
778
+ */
779
+ private computeHash(content: string): string {
780
+ return createHash('sha256').update(content).digest('hex');
781
+ }
730
782
  }
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { URI, type LangiumDocument, type LangiumDocuments } from 'langium';
3
3
  import type { Model } from '../generated/ast.js';
4
- import { WorkspaceManager } from '../services/workspace-manager.js';
4
+ import { ManifestManager } from '../services/workspace-manager.js';
5
5
  import { ImportResolver } from '../services/import-resolver.js';
6
6
  import type { DomainLangServices } from '../domain-lang-module.js';
7
7
 
@@ -13,13 +13,13 @@ import type { DomainLangServices } from '../domain-lang-module.js';
13
13
  * These singletons exist only for backwards compatibility with callers
14
14
  * that haven't been updated to pass through DI services.
15
15
  */
16
- let standaloneWorkspaceManager: WorkspaceManager | undefined;
16
+ let standaloneManifestManager: ManifestManager | undefined;
17
17
  let standaloneImportResolver: ImportResolver | undefined;
18
18
  let lastInitializedDir: string | undefined;
19
19
 
20
20
  /**
21
21
  * Gets or creates a standalone import resolver for non-LSP contexts.
22
- * Creates its own WorkspaceManager if not previously initialized for this directory.
22
+ * Creates its own ManifestManager if not previously initialized for this directory.
23
23
  *
24
24
  * @deprecated Prefer using services.imports.ImportResolver directly.
25
25
  * @param startDir - Directory to start workspace search from
@@ -28,14 +28,14 @@ let lastInitializedDir: string | undefined;
28
28
  async function getStandaloneImportResolver(startDir: string): Promise<ImportResolver> {
29
29
  // Re-initialize if directory changed (workspace boundary)
30
30
  if (lastInitializedDir !== startDir || !standaloneImportResolver) {
31
- standaloneWorkspaceManager = new WorkspaceManager();
31
+ standaloneManifestManager = new ManifestManager();
32
32
  try {
33
- await standaloneWorkspaceManager.initialize(startDir);
33
+ await standaloneManifestManager.initialize(startDir);
34
34
  } catch (error) {
35
35
  console.warn(`Failed to initialize workspace: ${error instanceof Error ? error.message : String(error)}`);
36
36
  }
37
37
  const services = {
38
- imports: { WorkspaceManager: standaloneWorkspaceManager }
38
+ imports: { ManifestManager: standaloneManifestManager }
39
39
  } as DomainLangServices;
40
40
  standaloneImportResolver = new ImportResolver(services);
41
41
  lastInitializedDir = startDir;
@@ -35,6 +35,7 @@ export const IssueCodes = {
35
35
  ImportAbsolutePath: 'import-absolute-path',
36
36
  ImportEscapesWorkspace: 'import-escapes-workspace',
37
37
  ImportUnresolved: 'import-unresolved',
38
+ ImportCycleDetected: 'import-cycle-detected',
38
39
 
39
40
  // Domain Issues
40
41
  DomainNoVision: 'domain-no-vision',
@@ -283,6 +284,14 @@ export const ValidationMessages = {
283
284
  `Cannot resolve import '${uri}'.\n` +
284
285
  `Hint: Check that the file exists and the path is correct.`,
285
286
 
287
+ /**
288
+ * Error when an import creates a cycle in the dependency graph.
289
+ * @param cycleDisplay - Human-readable cycle path (e.g., "a.dlang → b.dlang → a.dlang")
290
+ */
291
+ IMPORT_CYCLE_DETECTED: (cycleDisplay: string) =>
292
+ `Import cycle detected: ${cycleDisplay}.\n` +
293
+ `Hint: Break the cycle by extracting shared definitions into a separate file.`,
294
+
286
295
  // ========================================================================
287
296
  // Context Map & Domain Map Validation
288
297
  // ========================================================================
@@ -4,8 +4,10 @@ import type { ValidationAcceptor, ValidationChecks, LangiumDocument } from 'lang
4
4
  import { Cancellation } from 'langium';
5
5
  import type { DomainLangAstType, ImportStatement } from '../generated/ast.js';
6
6
  import type { DomainLangServices } from '../domain-lang-module.js';
7
- import type { WorkspaceManager } from '../services/workspace-manager.js';
7
+ import type { ManifestManager } from '../services/workspace-manager.js';
8
8
  import type { ImportResolver } from '../services/import-resolver.js';
9
+ import { ImportResolutionError } from '../services/import-resolver.js';
10
+ import type { DomainLangIndexManager } from '../lsp/domain-lang-index-manager.js';
9
11
  import type { ExtendedDependencySpec, ModelManifest, LockFile } from '../services/types.js';
10
12
  import { ValidationMessages, buildCodeDescription, IssueCodes } from './constants.js';
11
13
 
@@ -13,28 +15,35 @@ import { ValidationMessages, buildCodeDescription, IssueCodes } from './constant
13
15
  * Validates import statements in DomainLang.
14
16
  *
15
17
  * Uses async validators (Langium 4.x supports MaybePromise<void>) to leverage
16
- * the shared WorkspaceManager service with its cached manifest/lock file reading.
18
+ * the shared ManifestManager service with its cached manifest/lock file reading.
17
19
  *
18
20
  * Checks:
19
21
  * - All import URIs resolve to existing files
20
22
  * - External imports require manifest + alias
21
23
  * - Local path dependencies stay inside workspace
22
24
  * - Lock file exists for external dependencies
25
+ * - Import cycles are detected and reported (PRS-017 R3)
23
26
  */
24
27
  export class ImportValidator {
25
- private readonly workspaceManager: WorkspaceManager;
28
+ private readonly workspaceManager: ManifestManager;
26
29
  private readonly importResolver: ImportResolver;
30
+ private readonly indexManager: DomainLangIndexManager | undefined;
27
31
 
28
32
  constructor(services: DomainLangServices) {
29
- this.workspaceManager = services.imports.WorkspaceManager;
33
+ this.workspaceManager = services.imports.ManifestManager;
30
34
  this.importResolver = services.imports.ImportResolver;
35
+ // IndexManager is in shared services — cast to DomainLangIndexManager for cycle detection
36
+ const indexMgr = services.shared.workspace.IndexManager;
37
+ this.indexManager = 'getCycleForDocument' in indexMgr
38
+ ? indexMgr as DomainLangIndexManager
39
+ : undefined;
31
40
  }
32
41
 
33
42
  /**
34
43
  * Validates an import statement asynchronously.
35
44
  *
36
45
  * Langium validators can return MaybePromise<void>, enabling async operations
37
- * like reading manifests via the shared, cached WorkspaceManager.
46
+ * like reading manifests via the shared, cached ManifestManager.
38
47
  */
39
48
  async checkImportPath(
40
49
  imp: ImportStatement,
@@ -52,6 +61,9 @@ export class ImportValidator {
52
61
  return;
53
62
  }
54
63
 
64
+ // PRS-017 R3: Check for import cycles detected during indexing
65
+ this.checkImportCycle(document, imp, accept);
66
+
55
67
  // First, verify the import resolves to a valid file
56
68
  // This catches renamed/moved/deleted files immediately
57
69
  const resolveError = await this.validateImportResolves(imp, document, accept);
@@ -162,13 +174,21 @@ export class ImportValidator {
162
174
  }
163
175
 
164
176
  return false;
165
- } catch {
166
- // Resolution failed - report as unresolved import
167
- accept('error', ValidationMessages.IMPORT_UNRESOLVED(imp.uri), {
177
+ } catch (error: unknown) {
178
+ // R8: Use structured error properties for precise diagnostics
179
+ const message = error instanceof ImportResolutionError && error.hint
180
+ ? `${ValidationMessages.IMPORT_UNRESOLVED(imp.uri)}: ${error.hint}`
181
+ : ValidationMessages.IMPORT_UNRESOLVED(imp.uri);
182
+
183
+ accept('error', message, {
168
184
  node: imp,
169
185
  property: 'uri',
170
186
  codeDescription: buildCodeDescription('language.md', 'imports'),
171
- data: { code: IssueCodes.ImportUnresolved, uri: imp.uri }
187
+ data: {
188
+ code: IssueCodes.ImportUnresolved,
189
+ uri: imp.uri,
190
+ ...(error instanceof ImportResolutionError && { reason: error.reason }),
191
+ }
172
192
  });
173
193
  return true;
174
194
  }
@@ -316,7 +336,7 @@ export class ImportValidator {
316
336
  });
317
337
  }
318
338
  } catch (error) {
319
- // WorkspaceManager not initialized - skip workspace boundary check
339
+ // ManifestManager not initialized - skip workspace boundary check
320
340
  // This can happen for standalone files without model.yaml
321
341
  console.warn(`Could not validate workspace boundary for path dependency: ${error}`);
322
342
  }
@@ -360,7 +380,7 @@ export class ImportValidator {
360
380
  });
361
381
  }
362
382
  } catch (error) {
363
- // WorkspaceManager not initialized - log warning but continue
383
+ // ManifestManager not initialized - log warning but continue
364
384
  console.warn(`Could not validate cached package for ${alias}: ${error}`);
365
385
  }
366
386
  }
@@ -385,12 +405,47 @@ export class ImportValidator {
385
405
  return false;
386
406
  }
387
407
  }
408
+
409
+ // --- PRS-017 R3: Import cycle detection ---
410
+
411
+ /**
412
+ * Reports a warning if the current document is part of an import cycle.
413
+ *
414
+ * Cycle data is populated during indexing by DomainLangIndexManager.
415
+ * This method reads the pre-computed cycles and emits a diagnostic
416
+ * on the import statement contributing to the cycle.
417
+ */
418
+ private checkImportCycle(
419
+ document: LangiumDocument,
420
+ imp: ImportStatement,
421
+ accept: ValidationAcceptor
422
+ ): void {
423
+ if (!this.indexManager) return;
424
+
425
+ const cycle = this.indexManager.getCycleForDocument(document.uri.toString());
426
+ if (!cycle || cycle.length === 0) return;
427
+
428
+ // Build human-readable cycle display using basenames
429
+ const cycleDisplay = cycle
430
+ .map(uri => {
431
+ const parts = uri.split('/');
432
+ return parts.at(-1) ?? uri;
433
+ })
434
+ .join(' → ');
435
+
436
+ accept('warning', ValidationMessages.IMPORT_CYCLE_DETECTED(cycleDisplay), {
437
+ node: imp,
438
+ property: 'uri',
439
+ codeDescription: buildCodeDescription('language.md', 'imports'),
440
+ data: { code: IssueCodes.ImportCycleDetected }
441
+ });
442
+ }
388
443
  }
389
444
 
390
445
  /**
391
446
  * Creates validation checks for import statements.
392
447
  *
393
- * Returns async validators that leverage the shared WorkspaceManager
448
+ * Returns async validators that leverage the shared ManifestManager
394
449
  * for cached manifest/lock file reading.
395
450
  */
396
451
  export function createImportChecks(services: DomainLangServices): ValidationChecks<DomainLangAstType> {