@domainlang/language 0.10.0 → 0.12.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 (87) hide show
  1. package/README.md +44 -102
  2. package/out/diagram/context-map-diagram-generator.d.ts +65 -0
  3. package/out/diagram/context-map-diagram-generator.js +356 -0
  4. package/out/diagram/context-map-diagram-generator.js.map +1 -0
  5. package/out/diagram/context-map-layout-configurator.d.ts +15 -0
  6. package/out/diagram/context-map-layout-configurator.js +39 -0
  7. package/out/diagram/context-map-layout-configurator.js.map +1 -0
  8. package/out/diagram/elk-layout-factory.d.ts +43 -0
  9. package/out/diagram/elk-layout-factory.js +64 -0
  10. package/out/diagram/elk-layout-factory.js.map +1 -0
  11. package/out/domain-lang-module.d.ts +9 -2
  12. package/out/domain-lang-module.js +13 -4
  13. package/out/domain-lang-module.js.map +1 -1
  14. package/out/index.d.ts +6 -0
  15. package/out/index.js +7 -0
  16. package/out/index.js.map +1 -1
  17. package/out/lsp/domain-lang-code-lens-provider.d.ts +8 -0
  18. package/out/lsp/domain-lang-code-lens-provider.js +48 -0
  19. package/out/lsp/domain-lang-code-lens-provider.js.map +1 -0
  20. package/out/lsp/domain-lang-completion.js +1 -1
  21. package/out/lsp/domain-lang-completion.js.map +1 -1
  22. package/out/lsp/domain-lang-document-symbol-provider.js +5 -5
  23. package/out/lsp/domain-lang-document-symbol-provider.js.map +1 -1
  24. package/out/lsp/domain-lang-index-manager.d.ts +149 -5
  25. package/out/lsp/domain-lang-index-manager.js +388 -52
  26. package/out/lsp/domain-lang-index-manager.js.map +1 -1
  27. package/out/lsp/domain-lang-refresh.d.ts +35 -0
  28. package/out/lsp/domain-lang-refresh.js +129 -0
  29. package/out/lsp/domain-lang-refresh.js.map +1 -0
  30. package/out/lsp/domain-lang-workspace-manager.d.ts +10 -0
  31. package/out/lsp/domain-lang-workspace-manager.js +35 -0
  32. package/out/lsp/domain-lang-workspace-manager.js.map +1 -1
  33. package/out/lsp/tool-handlers.js +63 -57
  34. package/out/lsp/tool-handlers.js.map +1 -1
  35. package/out/main.js +30 -190
  36. package/out/main.js.map +1 -1
  37. package/out/sdk/loader-node.js +1 -1
  38. package/out/sdk/loader-node.js.map +1 -1
  39. package/out/sdk/validator.js +17 -14
  40. package/out/sdk/validator.js.map +1 -1
  41. package/out/services/import-resolver.d.ts +67 -17
  42. package/out/services/import-resolver.js +146 -65
  43. package/out/services/import-resolver.js.map +1 -1
  44. package/out/services/lsp-logger.d.ts +42 -0
  45. package/out/services/lsp-logger.js +50 -0
  46. package/out/services/lsp-logger.js.map +1 -0
  47. package/out/services/lsp-runtime-settings.d.ts +20 -0
  48. package/out/services/lsp-runtime-settings.js +20 -0
  49. package/out/services/lsp-runtime-settings.js.map +1 -0
  50. package/out/services/performance-optimizer.d.ts +9 -9
  51. package/out/services/performance-optimizer.js +17 -41
  52. package/out/services/performance-optimizer.js.map +1 -1
  53. package/out/services/workspace-manager.d.ts +22 -1
  54. package/out/services/workspace-manager.js +57 -9
  55. package/out/services/workspace-manager.js.map +1 -1
  56. package/out/utils/import-utils.js +6 -6
  57. package/out/utils/import-utils.js.map +1 -1
  58. package/out/validation/constants.d.ts +6 -0
  59. package/out/validation/constants.js +7 -0
  60. package/out/validation/constants.js.map +1 -1
  61. package/out/validation/import.d.ts +13 -3
  62. package/out/validation/import.js +54 -10
  63. package/out/validation/import.js.map +1 -1
  64. package/package.json +5 -2
  65. package/src/diagram/context-map-diagram-generator.ts +451 -0
  66. package/src/diagram/context-map-layout-configurator.ts +43 -0
  67. package/src/diagram/elk-layout-factory.ts +83 -0
  68. package/src/domain-lang-module.ts +22 -5
  69. package/src/index.ts +8 -0
  70. package/src/lsp/domain-lang-code-lens-provider.ts +54 -0
  71. package/src/lsp/domain-lang-completion.ts +3 -3
  72. package/src/lsp/domain-lang-document-symbol-provider.ts +5 -5
  73. package/src/lsp/domain-lang-index-manager.ts +438 -56
  74. package/src/lsp/domain-lang-refresh.ts +205 -0
  75. package/src/lsp/domain-lang-workspace-manager.ts +45 -0
  76. package/src/lsp/tool-handlers.ts +61 -47
  77. package/src/main.ts +36 -244
  78. package/src/sdk/loader-node.ts +1 -1
  79. package/src/sdk/validator.ts +17 -13
  80. package/src/services/import-resolver.ts +196 -89
  81. package/src/services/lsp-logger.ts +89 -0
  82. package/src/services/lsp-runtime-settings.ts +34 -0
  83. package/src/services/performance-optimizer.ts +18 -57
  84. package/src/services/workspace-manager.ts +62 -10
  85. package/src/utils/import-utils.ts +6 -6
  86. package/src/validation/constants.ts +9 -0
  87. package/src/validation/import.ts +67 -12
@@ -6,11 +6,17 @@ import type { Model } from '../generated/ast.js';
6
6
  import type { ImportResolver } from '../services/import-resolver.js';
7
7
  import type { DomainLangServices } from '../domain-lang-module.js';
8
8
  import type { ImportInfo } from '../services/types.js';
9
+ import { createLogger } from '../services/lsp-logger.js';
10
+
11
+ const log = createLogger('IndexManager');
9
12
 
10
13
  /**
11
14
  * Custom IndexManager that extends Langium's default to:
12
15
  * 1. Automatically load imported documents during indexing
13
16
  * 2. Track import dependencies for cross-file revalidation
17
+ * 3. Export-signature diffing to prevent unnecessary cascading (PRS-017 R2)
18
+ * 4. Import cycle detection with diagnostics (PRS-017 R3)
19
+ * 5. Targeted ImportResolver cache invalidation (PRS-017 R1)
14
20
  *
15
21
  * **Why this exists:**
16
22
  * Langium's `DefaultIndexManager.isAffected()` only checks cross-references
@@ -52,6 +58,35 @@ export class DomainLangIndexManager extends DefaultIndexManager {
52
58
  * Cleared on workspace config changes.
53
59
  */
54
60
  private readonly importsLoaded = new Set<string>();
61
+
62
+ /**
63
+ * Per-cycle cache for the transitive affected set computation.
64
+ * Uses `changedUris` Set identity as cache key — Langium creates a fresh Set
65
+ * for each `DocumentBuilder.update()` cycle, so reference equality naturally
66
+ * invalidates the cache between cycles.
67
+ */
68
+ private transitiveAffectedCache: { key: Set<string>; result: Set<string> } | undefined;
69
+
70
+ /**
71
+ * Export snapshot cache (PRS-017 R2): maps document URI to its exported symbol
72
+ * signatures. Used to detect whether a document's public interface actually
73
+ * changed, preventing cascading revalidation for implementation-only changes.
74
+ * Signature = "nodeType:qualifiedName" for each exported symbol.
75
+ */
76
+ private readonly exportSnapshots = new Map<string, Set<string>>();
77
+
78
+ /**
79
+ * Tracks which URIs had their exports actually change during the current
80
+ * update cycle. Reset before each updateContent() call. Used by isAffected()
81
+ * to skip transitive invalidation when exports are unchanged.
82
+ */
83
+ private readonly changedExports = new Set<string>();
84
+
85
+ /**
86
+ * Detected import cycles (PRS-017 R3): maps document URI to the cycle path.
87
+ * Populated during trackImportDependencies(). Consumed by ImportValidator.
88
+ */
89
+ private readonly detectedCycles = new Map<string, string[]>();
55
90
 
56
91
  /**
57
92
  * Reference to shared services for accessing LangiumDocuments.
@@ -95,19 +130,48 @@ export class DomainLangIndexManager extends DefaultIndexManager {
95
130
 
96
131
  /**
97
132
  * Extends the default content update to:
98
- * 1. Ensure all imported documents are loaded
99
- * 2. Track import dependencies for change propagation
133
+ * 1. Capture export snapshot before update (PRS-017 R2)
134
+ * 2. Ensure all imported documents are loaded
135
+ * 3. Track import dependencies for change propagation
136
+ * 4. Compare export snapshot to detect interface changes (PRS-017 R2)
137
+ * 5. Detect import cycles (PRS-017 R3)
138
+ * 6. Trigger targeted ImportResolver cache invalidation (PRS-017 R1)
100
139
  *
101
140
  * Called by Langium during the IndexedContent build phase.
102
141
  * This is BEFORE linking/validation, so imports are available for resolution.
103
142
  */
104
143
  override async updateContent(document: LangiumDocument, cancelToken = CancellationToken.None): Promise<void> {
105
- // First, do the standard content indexing
144
+ const uri = document.uri.toString();
145
+
146
+ // R2: Capture export snapshot BEFORE re-indexing
147
+ const oldExports = this.exportSnapshots.get(uri);
148
+
149
+ // Standard content indexing
106
150
  await super.updateContent(document, cancelToken);
107
151
 
108
- // Then, ensure imports are loaded and track dependencies
152
+ // R2: Capture new export snapshot and compare
153
+ const newExports = this.captureExportSnapshot(uri);
154
+ this.exportSnapshots.set(uri, newExports);
155
+ const exportsChanged = !oldExports || !this.setsEqual(oldExports, newExports);
156
+ if (exportsChanged) {
157
+ this.changedExports.add(uri);
158
+ log.info('exports changed', { uri });
159
+ } else {
160
+ // R2: Remove from changedExports when exports stabilize.
161
+ // Without this, the set accumulates indefinitely and the
162
+ // anyExportsChanged() gate stays permanently open.
163
+ this.changedExports.delete(uri);
164
+ }
165
+
166
+ // Ensure imports are loaded and track dependencies
109
167
  await this.ensureImportsLoaded(document);
110
168
  await this.trackImportDependencies(document);
169
+
170
+ // R3: Detect import cycles after tracking dependencies
171
+ this.detectAndStoreCycles(uri);
172
+
173
+ // R1: Targeted ImportResolver cache invalidation
174
+ this.invalidateImportResolverCache(uri);
111
175
  }
112
176
 
113
177
  /**
@@ -131,11 +195,19 @@ export class DomainLangIndexManager extends DefaultIndexManager {
131
195
  }
132
196
 
133
197
  /**
134
- * Extends `isAffected` to also check import dependencies.
198
+ * Extends `isAffected` to check import dependencies — direct, transitive,
199
+ * and specifier-sensitive.
135
200
  *
136
201
  * A document is affected if:
137
202
  * 1. It has cross-references to any changed document (default Langium behavior)
138
- * 2. It imports any of the changed documents (our extension)
203
+ * 2. It directly or transitively imports any changed document whose exports
204
+ * actually changed (PRS-017 R2 — export-signature diffing)
205
+ * 3. Its import specifiers match changed file paths (handles renames/moves)
206
+ *
207
+ * The transitive affected set is computed once per `update()` cycle and cached
208
+ * using `changedUris` Set identity (Langium creates a fresh Set per cycle).
209
+ * This avoids redundant BFS walks when `isAffected()` is called for every
210
+ * loaded document in the workspace.
139
211
  */
140
212
  override isAffected(document: LangiumDocument, changedUris: Set<string>): boolean {
141
213
  // First check Langium's default: cross-references
@@ -143,16 +215,84 @@ export class DomainLangIndexManager extends DefaultIndexManager {
143
215
  return true;
144
216
  }
145
217
 
146
- // Then check our import dependencies
147
- const docUri = document.uri.toString();
148
- for (const changedUri of changedUris) {
149
- const dependents = this.importDependencies.get(changedUri);
150
- if (dependents?.has(docUri)) {
151
- return true;
218
+ // R2: If no changed URIs had their exports change, skip transitive check.
219
+ // This prevents cascading revalidation for implementation-only changes
220
+ // (e.g., editing a domain's vision string).
221
+ const hasExportChanges = this.anyExportsChanged(changedUris);
222
+ if (!hasExportChanges) {
223
+ // Still check specifier matches for file renames/moves
224
+ const changedPaths = this.extractPathSegments(changedUris);
225
+ for (const changedPath of changedPaths) {
226
+ const infos = this.documentImportInfo.get(document.uri.toString());
227
+ if (infos && this.hasMatchingSpecifierOrResolvedUri(infos, new Set([changedPath]))) {
228
+ return true;
229
+ }
152
230
  }
231
+ return false;
153
232
  }
154
233
 
155
- return false;
234
+ // Then check our import dependency graph (direct + transitive + specifier)
235
+ const affectedSet = this.computeAffectedSet(changedUris);
236
+ return affectedSet.has(document.uri.toString());
237
+ }
238
+
239
+ /**
240
+ * Computes the full set of document URIs affected by changes.
241
+ * Cached per `changedUris` identity to avoid recomputation across multiple
242
+ * `isAffected()` calls within the same `DocumentBuilder.update()` cycle.
243
+ *
244
+ * Combines two dependency strategies:
245
+ * 1. **Reverse graph walk** — direct and transitive importers via `importDependencies`
246
+ * 2. **Specifier matching** — documents whose import specifiers match changed file
247
+ * paths (handles file renames/moves that change how imports resolve)
248
+ */
249
+ private computeAffectedSet(changedUris: Set<string>): Set<string> {
250
+ // Cache hit: same changedUris Set reference means same update() cycle
251
+ if (this.transitiveAffectedCache?.key === changedUris) {
252
+ return this.transitiveAffectedCache.result;
253
+ }
254
+
255
+ const affected = new Set<string>();
256
+ this.addTransitiveDependents(changedUris, affected);
257
+ this.addSpecifierMatches(changedUris, affected);
258
+
259
+ this.transitiveAffectedCache = { key: changedUris, result: affected };
260
+ return affected;
261
+ }
262
+
263
+ /**
264
+ * BFS through the reverse dependency graph to find all transitive importers.
265
+ * If C changes and B imports C and A imports B, both A and B are added.
266
+ */
267
+ private addTransitiveDependents(changedUris: Set<string>, affected: Set<string>): void {
268
+ const toProcess = [...changedUris];
269
+ let uri: string | undefined;
270
+ while ((uri = toProcess.pop()) !== undefined) {
271
+ const dependents = this.importDependencies.get(uri);
272
+ if (!dependents) {
273
+ continue;
274
+ }
275
+ for (const dep of dependents) {
276
+ if (!affected.has(dep) && !changedUris.has(dep)) {
277
+ affected.add(dep);
278
+ toProcess.push(dep);
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Finds documents whose import specifiers fuzzy-match changed file paths.
286
+ * Handles file renames/moves where the resolved URI hasn't been updated yet.
287
+ */
288
+ private addSpecifierMatches(changedUris: Set<string>, affected: Set<string>): void {
289
+ const changedPaths = this.extractPathSegments(changedUris);
290
+ for (const [docUri, importInfoList] of this.documentImportInfo) {
291
+ if (!affected.has(docUri) && !changedUris.has(docUri)
292
+ && this.hasMatchingSpecifierOrResolvedUri(importInfoList, changedPaths)) {
293
+ affected.add(docUri);
294
+ }
295
+ }
156
296
  }
157
297
 
158
298
  /**
@@ -183,33 +323,8 @@ export class DomainLangIndexManager extends DefaultIndexManager {
183
323
 
184
324
  for (const imp of model.imports) {
185
325
  if (!imp.uri) continue;
186
-
187
- try {
188
- const resolvedUri = await this.resolveImport(document, imp.uri);
189
- const importedUri = resolvedUri.toString();
190
-
191
- // Track the full import info (specifier, alias, resolved URI)
192
- importInfoList.push({
193
- specifier: imp.uri,
194
- alias: imp.alias,
195
- resolvedUri: importedUri
196
- });
197
-
198
- // Add to reverse dependency graph: importedUri → importingUri
199
- let dependents = this.importDependencies.get(importedUri);
200
- if (!dependents) {
201
- dependents = new Set();
202
- this.importDependencies.set(importedUri, dependents);
203
- }
204
- dependents.add(importingUri);
205
- } catch {
206
- // Import resolution failed - still track the specifier with empty resolution
207
- importInfoList.push({
208
- specifier: imp.uri,
209
- alias: imp.alias,
210
- resolvedUri: ''
211
- });
212
- }
326
+ const info = await this.resolveAndTrackImport(document, imp, importingUri);
327
+ importInfoList.push(info);
213
328
  }
214
329
 
215
330
  if (importInfoList.length > 0) {
@@ -217,6 +332,45 @@ export class DomainLangIndexManager extends DefaultIndexManager {
217
332
  }
218
333
  }
219
334
 
335
+ /**
336
+ * Resolves a single import and registers it in the reverse dependency graph.
337
+ * Falls back to searching loaded documents when the filesystem resolver fails.
338
+ */
339
+ private async resolveAndTrackImport(
340
+ document: LangiumDocument,
341
+ imp: { uri?: string; alias?: string },
342
+ importingUri: string
343
+ ): Promise<ImportInfo> {
344
+ const specifier = imp.uri ?? '';
345
+
346
+ try {
347
+ const resolvedUri = await this.resolveImport(document, specifier);
348
+ const importedUri = resolvedUri.toString();
349
+ this.addToDependencyGraph(importedUri, importingUri);
350
+ return { specifier, alias: imp.alias, resolvedUri: importedUri };
351
+ } catch {
352
+ // Filesystem resolution failed (e.g., unsaved file, EmptyFileSystem).
353
+ // Try to find a loaded document whose URI path matches the specifier.
354
+ const matchedUri = this.findLoadedDocumentByPath(specifier, importingUri);
355
+ if (matchedUri) {
356
+ this.addToDependencyGraph(matchedUri, importingUri);
357
+ }
358
+ return { specifier, alias: imp.alias, resolvedUri: matchedUri };
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Adds an edge to the reverse dependency graph: importedUri → importingUri.
364
+ */
365
+ private addToDependencyGraph(importedUri: string, importingUri: string): void {
366
+ let dependents = this.importDependencies.get(importedUri);
367
+ if (!dependents) {
368
+ dependents = new Set();
369
+ this.importDependencies.set(importedUri, dependents);
370
+ }
371
+ dependents.add(importingUri);
372
+ }
373
+
220
374
  /**
221
375
  * Ensures all imported documents are loaded and available.
222
376
  * This is called during indexing, BEFORE linking/validation,
@@ -285,11 +439,22 @@ export class DomainLangIndexManager extends DefaultIndexManager {
285
439
  * Called when a document is deleted.
286
440
  */
287
441
  private removeImportDependencies(uri: string): void {
288
- // Remove as an imported document
442
+ // Remove as an imported document (reverse graph entry)
289
443
  this.importDependencies.delete(uri);
290
444
 
291
- // Remove from all dependency sets (as an importer)
445
+ // Remove import info for this document (forward graph entry)
446
+ this.documentImportInfo.delete(uri);
447
+
448
+ // Remove from all dependency sets (as an importer of other files)
292
449
  this.removeDocumentFromDependencies(uri);
450
+
451
+ // Clean up PRS-017 caches
452
+ this.exportSnapshots.delete(uri);
453
+ this.changedExports.delete(uri);
454
+ this.detectedCycles.delete(uri);
455
+
456
+ // Invalidate the per-cycle cache since the graph changed
457
+ this.transitiveAffectedCache = undefined;
293
458
  }
294
459
 
295
460
  /**
@@ -310,6 +475,29 @@ export class DomainLangIndexManager extends DefaultIndexManager {
310
475
  this.importDependencies.clear();
311
476
  this.documentImportInfo.clear();
312
477
  this.importsLoaded.clear();
478
+ this.transitiveAffectedCache = undefined;
479
+ this.exportSnapshots.clear();
480
+ this.changedExports.clear();
481
+ this.detectedCycles.clear();
482
+ }
483
+
484
+ /**
485
+ * Fallback for import resolution: searches loaded documents for one whose
486
+ * URI path matches the import specifier. Used when the filesystem-based
487
+ * resolver fails (e.g., unsaved files, EmptyFileSystem in tests).
488
+ */
489
+ private findLoadedDocumentByPath(specifier: string, excludeUri: string): string {
490
+ const langiumDocuments = this.sharedServices.workspace.LangiumDocuments;
491
+ for (const doc of langiumDocuments.all) {
492
+ const docUri = doc.uri.toString();
493
+ if (docUri === excludeUri) {
494
+ continue;
495
+ }
496
+ if (doc.uri.path === specifier || doc.uri.path.endsWith(`/${specifier}`)) {
497
+ return docUri;
498
+ }
499
+ }
500
+ return '';
313
501
  }
314
502
 
315
503
  /**
@@ -468,7 +656,11 @@ export class DomainLangIndexManager extends DefaultIndexManager {
468
656
  }
469
657
 
470
658
  /**
471
- * Checks if any specifier OR its resolved URI matches the changed paths.
659
+ * Checks if any specifier OR its resolved URI matches the changed paths (PRS-017 R4).
660
+ *
661
+ * Uses exact filename matching instead of substring matching to prevent
662
+ * false positives (e.g., changing `sales.dlang` should NOT trigger
663
+ * revalidation of a file importing `pre-sales.dlang`).
472
664
  *
473
665
  * This handles both regular imports and path aliases:
474
666
  * - Regular: `./domains/sales.dlang` matches path `sales.dlang`
@@ -479,21 +671,211 @@ export class DomainLangIndexManager extends DefaultIndexManager {
479
671
  */
480
672
  private hasMatchingSpecifierOrResolvedUri(importInfoList: ImportInfo[], changedPaths: Set<string>): boolean {
481
673
  for (const info of importInfoList) {
482
- const normalizedSpecifier = info.specifier.replace(/^[.@/]+/, '');
483
-
484
- for (const changedPath of changedPaths) {
485
- // Check the raw specifier (handles relative imports)
486
- if (info.specifier.includes(changedPath) || changedPath.endsWith(normalizedSpecifier)) {
487
- return true;
488
- }
489
-
490
- // Check the resolved URI (handles path aliases like @domains/...)
491
- // The resolved URI contains the full file path which matches moved files
492
- if (info.resolvedUri?.includes(changedPath)) {
493
- return true;
494
- }
674
+ if (this.matchesAnyChangedPath(info, changedPaths)) {
675
+ return true;
495
676
  }
496
677
  }
497
678
  return false;
498
679
  }
680
+
681
+ /**
682
+ * Checks if a single import info matches any of the changed paths.
683
+ * Extracted to reduce cognitive complexity of hasMatchingSpecifierOrResolvedUri.
684
+ */
685
+ private matchesAnyChangedPath(info: ImportInfo, changedPaths: Set<string>): boolean {
686
+ for (const changedPath of changedPaths) {
687
+ if (this.matchesChangedPath(info, changedPath)) {
688
+ return true;
689
+ }
690
+ }
691
+ return false;
692
+ }
693
+
694
+ /**
695
+ * Checks if a single import info matches a single changed path.
696
+ */
697
+ private matchesChangedPath(info: ImportInfo, changedPath: string): boolean {
698
+ const changedFileName = this.extractFileName(changedPath);
699
+ if (!changedFileName) return false;
700
+
701
+ // Check the resolved URI first (most reliable — already normalized)
702
+ if (info.resolvedUri && this.matchesResolvedUri(info.resolvedUri, changedFileName, changedPath)) {
703
+ return true;
704
+ }
705
+
706
+ // Check the specifier (handles relative imports)
707
+ return this.matchesSpecifier(info.specifier, changedFileName, changedPath);
708
+ }
709
+
710
+ /**
711
+ * Checks if a resolved URI matches a changed path by exact filename comparison.
712
+ */
713
+ private matchesResolvedUri(resolvedUri: string, changedFileName: string, changedPath: string): boolean {
714
+ const resolvedFileName = this.extractFileName(resolvedUri);
715
+ if (resolvedFileName && changedFileName === resolvedFileName) {
716
+ return this.pathEndsWith(resolvedUri, changedPath);
717
+ }
718
+ return false;
719
+ }
720
+
721
+ /**
722
+ * Checks if an import specifier matches a changed path by exact filename comparison.
723
+ */
724
+ private matchesSpecifier(specifier: string, changedFileName: string, changedPath: string): boolean {
725
+ const specifierFileName = this.extractFileName(specifier);
726
+ if (specifierFileName && changedFileName === specifierFileName) {
727
+ const normalizedSpecifier = specifier.replace(/^[.@/]+/, '');
728
+ return this.pathEndsWith(changedPath, normalizedSpecifier) ||
729
+ this.pathEndsWith(normalizedSpecifier, changedPath);
730
+ }
731
+ return false;
732
+ }
733
+
734
+ /**
735
+ * Extracts the filename (without extension) from a path or URI string.
736
+ */
737
+ private extractFileName(pathOrUri: string): string | undefined {
738
+ // Handle URI paths and regular paths
739
+ const lastSlash = Math.max(pathOrUri.lastIndexOf('/'), pathOrUri.lastIndexOf('\\'));
740
+ const fileName = lastSlash >= 0 ? pathOrUri.slice(lastSlash + 1) : pathOrUri;
741
+ return fileName.replace(/\.dlang$/, '') || undefined;
742
+ }
743
+
744
+ /**
745
+ * Checks if longPath ends with shortPath, comparing path segments.
746
+ * Prevents substring false positives (e.g., "pre-sales" matching "sales").
747
+ */
748
+ private pathEndsWith(longPath: string, shortPath: string): boolean {
749
+ const normalizedLong = longPath.replaceAll('\\', '/').replace(/\.dlang$/, '');
750
+ const normalizedShort = shortPath.replaceAll('\\', '/').replace(/\.dlang$/, '');
751
+ return normalizedLong === normalizedShort ||
752
+ normalizedLong.endsWith(`/${normalizedShort}`);
753
+ }
754
+
755
+ // --- PRS-017 R2: Export-signature diffing ---
756
+
757
+ /**
758
+ * Captures a snapshot of exported symbol signatures for a document.
759
+ * Signature = "nodeType:qualifiedName" for each exported symbol.
760
+ * Used to detect whether a document's public interface actually changed.
761
+ */
762
+ private captureExportSnapshot(uri: string): Set<string> {
763
+ const descriptions = this.symbolIndex.get(uri) ?? [];
764
+ const signatures = new Set<string>();
765
+ for (const desc of descriptions) {
766
+ signatures.add(`${desc.type}:${desc.name}`);
767
+ }
768
+ return signatures;
769
+ }
770
+
771
+ /**
772
+ * Checks if two sets of strings are equal (same size and same elements).
773
+ */
774
+ private setsEqual(a: Set<string>, b: Set<string>): boolean {
775
+ if (a.size !== b.size) return false;
776
+ for (const item of a) {
777
+ if (!b.has(item)) return false;
778
+ }
779
+ return true;
780
+ }
781
+
782
+ /**
783
+ * Returns true if any of the changed URIs had their exports actually change.
784
+ * Used by isAffected() to skip transitive invalidation when only
785
+ * implementation details changed (e.g., editing a vision string).
786
+ */
787
+ private anyExportsChanged(changedUris: Set<string>): boolean {
788
+ for (const uri of changedUris) {
789
+ if (this.changedExports.has(uri)) {
790
+ return true;
791
+ }
792
+ }
793
+ return false;
794
+ }
795
+
796
+ // --- PRS-017 R3: Import cycle detection ---
797
+
798
+ /**
799
+ * Detects import cycles starting from a given document URI.
800
+ * Uses DFS with a recursion stack to find back-edges in the import graph.
801
+ * Stores detected cycles for reporting by ImportValidator.
802
+ */
803
+ private detectAndStoreCycles(startUri: string): void {
804
+ // Clear any previous cycle for this document
805
+ this.detectedCycles.delete(startUri);
806
+
807
+ const cycle = this.findCycle(startUri);
808
+ if (cycle) {
809
+ // Store the cycle for each participant (skip last element which is the
810
+ // duplicate that closes the cycle, e.g. [A, B, C, A] → store for A, B, C)
811
+ for (let i = 0; i < cycle.length - 1; i++) {
812
+ this.detectedCycles.set(cycle[i], cycle);
813
+ }
814
+ }
815
+ }
816
+
817
+ /**
818
+ * DFS to find a cycle in the forward import graph starting from startUri.
819
+ * Returns the cycle path (e.g., [A, B, C, A]) if found, undefined otherwise.
820
+ */
821
+ private findCycle(startUri: string): string[] | undefined {
822
+ const visited = new Set<string>();
823
+ const stack = new Set<string>();
824
+ const path: string[] = [];
825
+
826
+ const dfs = (uri: string): string[] | undefined => {
827
+ if (stack.has(uri)) {
828
+ // Found cycle — extract the cycle path from the stack
829
+ const cycleStart = path.indexOf(uri);
830
+ return [...path.slice(cycleStart), uri];
831
+ }
832
+ if (visited.has(uri)) return undefined;
833
+
834
+ visited.add(uri);
835
+ stack.add(uri);
836
+ path.push(uri);
837
+
838
+ const imports = this.documentImportInfo.get(uri);
839
+ if (imports) {
840
+ for (const imp of imports) {
841
+ if (imp.resolvedUri) {
842
+ const cycle = dfs(imp.resolvedUri);
843
+ if (cycle) return cycle;
844
+ }
845
+ }
846
+ }
847
+
848
+ stack.delete(uri);
849
+ path.pop();
850
+ return undefined;
851
+ };
852
+
853
+ return dfs(startUri);
854
+ }
855
+
856
+ /**
857
+ * Gets the detected import cycle for a document, if any.
858
+ * Returns the cycle path as an array of URIs, or undefined if no cycle.
859
+ * Used by ImportValidator to report cycle diagnostics (PRS-017 R3).
860
+ */
861
+ getCycleForDocument(uri: string): string[] | undefined {
862
+ return this.detectedCycles.get(uri);
863
+ }
864
+
865
+ // --- PRS-017 R1: Targeted ImportResolver cache invalidation ---
866
+
867
+ /**
868
+ * Invalidates the ImportResolver cache for the changed document and its dependents.
869
+ * This provides surgical cache invalidation instead of clearing the entire cache.
870
+ */
871
+ private invalidateImportResolverCache(changedUri: string): void {
872
+ if (!this.importResolver) return;
873
+
874
+ const affectedUris = [changedUri];
875
+ const dependents = this.importDependencies.get(changedUri);
876
+ if (dependents) {
877
+ affectedUris.push(...dependents);
878
+ }
879
+ this.importResolver.invalidateForDocuments(affectedUris);
880
+ }
499
881
  }