@rsconcept/domain 2.0.1 → 2.1.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 (43) hide show
  1. package/README.md +1 -1
  2. package/dist/cctext/language-api.js.map +1 -1
  3. package/dist/cctext/language.d.ts +7 -4
  4. package/dist/cctext/language.js.map +1 -1
  5. package/dist/{diagnostics-DYMvTSev.js → diagnostics-BSryP66e.js} +131 -40
  6. package/dist/diagnostics-BSryP66e.js.map +1 -0
  7. package/dist/{diagnostics-Clo3mkba.d.ts → diagnostics-Dtzgj5Q9.d.ts} +14 -5
  8. package/dist/graph/graph.d.ts +1 -1
  9. package/dist/graph/graph.js +88 -1
  10. package/dist/graph/graph.js.map +1 -1
  11. package/dist/graph/index.d.ts +1 -1
  12. package/dist/{graph-DR8rL2o3.d.ts → graph-DPOvglEm.d.ts} +10 -2
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.js +2 -2
  15. package/dist/library/diagnostics.d.ts +2 -2
  16. package/dist/library/diagnostics.js +2 -2
  17. package/dist/library/index.d.ts +2 -2
  18. package/dist/library/index.js +2 -2
  19. package/dist/library/oss-api.d.ts +1 -1
  20. package/dist/library/oss.d.ts +1 -1
  21. package/dist/library/rsengine.d.ts +1 -1
  22. package/dist/library/rsengine.js +1 -1
  23. package/dist/library/rsform-api.d.ts +13 -2
  24. package/dist/library/rsform-api.js +2 -2
  25. package/dist/library/rsform.d.ts +1 -1
  26. package/dist/library/rsmodel-api.d.ts +1 -1
  27. package/dist/library/rsmodel-api.js +1 -1
  28. package/dist/library/rsmodel.d.ts +1 -1
  29. package/dist/library/structure-planner.d.ts +1 -1
  30. package/dist/library/structure-planner.js +1 -1
  31. package/package.json +14 -13
  32. package/src/cctext/language-api.test.ts +5 -0
  33. package/src/cctext/language-api.ts +6 -6
  34. package/src/cctext/language.ts +3 -4
  35. package/src/graph/graph.test.ts +22 -0
  36. package/src/graph/graph.ts +105 -1
  37. package/src/index.ts +2 -0
  38. package/src/library/diagnostics.test.ts +115 -0
  39. package/src/library/diagnostics.ts +75 -40
  40. package/src/library/index.ts +2 -0
  41. package/src/library/rsform-api.test.ts +99 -3
  42. package/src/library/rsform-api.ts +147 -6
  43. package/dist/diagnostics-DYMvTSev.js.map +0 -1
@@ -379,7 +379,7 @@ export class Graph<NodeID = number> {
379
379
  * Finds a cycle in the graph.
380
380
  *
381
381
  * @returns {NodeID[] | null} The cycle if found, otherwise `null`.
382
- * Uses non-recursive DFS.
382
+ * Uses non-recursive DFS. The returned path is closed (first id equals last).
383
383
  */
384
384
  findCycle(): NodeID[] | null {
385
385
  const visited = new Set<NodeID>();
@@ -430,4 +430,108 @@ export class Graph<NodeID = number> {
430
430
  }
431
431
  return null;
432
432
  }
433
+
434
+ /**
435
+ * Finds one closed cycle path per cyclic strongly connected component.
436
+ *
437
+ * @returns Closed paths (first id equals last). Empty when the graph is acyclic.
438
+ */
439
+ findCycles(): NodeID[][] {
440
+ const components = this.stronglyConnectedComponents();
441
+ const result: NodeID[][] = [];
442
+ for (const component of components) {
443
+ if (component.length === 1) {
444
+ const id = component[0];
445
+ if (this.hasEdge(id, id)) {
446
+ result.push([id, id]);
447
+ }
448
+ continue;
449
+ }
450
+ const subgraph = new Graph<NodeID>();
451
+ const memberIds = new Set(component);
452
+ for (const id of component) {
453
+ subgraph.addNode(id);
454
+ const node = this.nodes.get(id);
455
+ if (!node) {
456
+ continue;
457
+ }
458
+ for (const child of node.outputs) {
459
+ if (memberIds.has(child)) {
460
+ subgraph.addEdge(id, child);
461
+ }
462
+ }
463
+ }
464
+ const cycle = subgraph.findCycle();
465
+ if (cycle) {
466
+ result.push(cycle);
467
+ }
468
+ }
469
+ return result;
470
+ }
471
+
472
+ /** Tarjan strongly connected components (non-recursive). */
473
+ stronglyConnectedComponents(): NodeID[][] {
474
+ let index = 0;
475
+ const indices = new Map<NodeID, number>();
476
+ const lowlink = new Map<NodeID, number>();
477
+ const onStack = new Set<NodeID>();
478
+ const stack: NodeID[] = [];
479
+ const components: NodeID[][] = [];
480
+
481
+ type Frame = { nodeId: NodeID; edgeIndex: number; entered: boolean };
482
+ for (const startId of this.nodes.keys()) {
483
+ if (indices.has(startId)) {
484
+ continue;
485
+ }
486
+ const callStack: Frame[] = [{ nodeId: startId, edgeIndex: 0, entered: false }];
487
+ while (callStack.length > 0) {
488
+ const frame = callStack[callStack.length - 1];
489
+ const node = this.nodes.get(frame.nodeId);
490
+ if (!node) {
491
+ callStack.pop();
492
+ continue;
493
+ }
494
+
495
+ if (!frame.entered) {
496
+ frame.entered = true;
497
+ indices.set(frame.nodeId, index);
498
+ lowlink.set(frame.nodeId, index);
499
+ index += 1;
500
+ stack.push(frame.nodeId);
501
+ onStack.add(frame.nodeId);
502
+ }
503
+
504
+ if (frame.edgeIndex < node.outputs.length) {
505
+ const child = node.outputs[frame.edgeIndex];
506
+ frame.edgeIndex += 1;
507
+ if (!indices.has(child)) {
508
+ callStack.push({ nodeId: child, edgeIndex: 0, entered: false });
509
+ } else if (onStack.has(child)) {
510
+ lowlink.set(frame.nodeId, Math.min(lowlink.get(frame.nodeId)!, indices.get(child)!));
511
+ }
512
+ continue;
513
+ }
514
+
515
+ callStack.pop();
516
+ if (callStack.length > 0) {
517
+ const parentId = callStack[callStack.length - 1].nodeId;
518
+ lowlink.set(parentId, Math.min(lowlink.get(parentId)!, lowlink.get(frame.nodeId)!));
519
+ }
520
+
521
+ if (lowlink.get(frame.nodeId) === indices.get(frame.nodeId)) {
522
+ const component: NodeID[] = [];
523
+ while (stack.length > 0) {
524
+ const id = stack.pop()!;
525
+ onStack.delete(id);
526
+ component.push(id);
527
+ if (id === frame.nodeId) {
528
+ break;
529
+ }
530
+ }
531
+ components.push(component);
532
+ }
533
+ }
534
+ }
535
+ return components;
536
+ }
433
537
  }
package/src/index.ts CHANGED
@@ -11,7 +11,9 @@ export { Graph } from './graph/graph';
11
11
  export {
12
12
  assignSchemaDiagnostics,
13
13
  type CstDiagnostic,
14
+ detectDependencyCycleDiagnostics,
14
15
  DiagnosticKind,
16
+ formatDependencyCycle,
15
17
  hasCstDiagnostic,
16
18
  modelStatusCstDiagnostic,
17
19
  RSDiagnosticCode
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { Graph } from '../graph';
4
+
5
+ import {
6
+ assignSchemaDiagnostics,
7
+ DiagnosticKind,
8
+ formatDependencyCycle,
9
+ hasCstDiagnostic,
10
+ modelStatusCstDiagnostic,
11
+ RSDiagnosticCode
12
+ } from './diagnostics';
13
+ import { type Constituenta, CstType } from './rsform';
14
+ import { EvalStatus } from './rsmodel';
15
+
16
+ function stubCst(id: number, alias: string, overrides: Partial<Constituenta> = {}): Constituenta {
17
+ return {
18
+ id,
19
+ alias,
20
+ term_resolved: '',
21
+ definition_formal: '',
22
+ convention: '',
23
+ cst_type: CstType.BASE,
24
+ is_type_mismatch: false,
25
+ typification_manual: '',
26
+ analysis: { success: true, type: null },
27
+ diagnostics: [],
28
+ ...overrides
29
+ } as Constituenta;
30
+ }
31
+
32
+ describe('hasCstDiagnostic', () => {
33
+ it('returns true when constituent has the requested code', () => {
34
+ const cst = stubCst(1, 'X1', {
35
+ diagnostics: [{ kind: DiagnosticKind.SCHEMA, code: RSDiagnosticCode.schemaHomonym, params: ['X2'] }]
36
+ });
37
+ expect(hasCstDiagnostic(cst, RSDiagnosticCode.schemaHomonym)).toBe(true);
38
+ expect(hasCstDiagnostic(cst, RSDiagnosticCode.schemaFormalDuplicate)).toBe(false);
39
+ });
40
+
41
+ it('returns false when diagnostics are absent', () => {
42
+ expect(hasCstDiagnostic(stubCst(1, 'X1'), RSDiagnosticCode.schemaHomonym)).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe('modelStatusCstDiagnostic', () => {
47
+ it('maps model evaluation statuses to compact diagnostics', () => {
48
+ expect(modelStatusCstDiagnostic(EvalStatus.EMPTY, CstType.BASE)).toEqual({
49
+ kind: DiagnosticKind.MODEL,
50
+ code: RSDiagnosticCode.modelEmpty,
51
+ params: [String(EvalStatus.EMPTY)]
52
+ });
53
+ expect(modelStatusCstDiagnostic(EvalStatus.AXIOM_FALSE, CstType.AXIOM)).toEqual({
54
+ kind: DiagnosticKind.MODEL,
55
+ code: RSDiagnosticCode.modelAxiomFalse,
56
+ params: [String(EvalStatus.AXIOM_FALSE)]
57
+ });
58
+ expect(modelStatusCstDiagnostic(EvalStatus.HAS_DATA, CstType.BASE)).toBeNull();
59
+ expect(modelStatusCstDiagnostic(EvalStatus.EMPTY, CstType.TERM)).toBeNull();
60
+ });
61
+ });
62
+
63
+ describe('assignSchemaDiagnostics', () => {
64
+ it('detects homonyms and formal duplicates', () => {
65
+ const items = [
66
+ stubCst(1, 'X1', { term_resolved: 'person' }),
67
+ stubCst(2, 'X2', { term_resolved: 'Person' }),
68
+ stubCst(3, 'D1', { cst_type: CstType.TERM, definition_formal: 'Pr1(X1)' }),
69
+ stubCst(4, 'D2', { cst_type: CstType.TERM, definition_formal: 'Pr1(X1)' })
70
+ ];
71
+ const resolveAlias = (id: number) => items.find(item => item.id === id)!.alias;
72
+ const normalizedDefinitions = new Map<number, string>([
73
+ [3, 'pr1(x1)'],
74
+ [4, 'pr1(x1)']
75
+ ]);
76
+
77
+ assignSchemaDiagnostics(items, resolveAlias, normalizedDefinitions);
78
+
79
+ expect(hasCstDiagnostic(items[0], RSDiagnosticCode.schemaHomonym)).toBe(true);
80
+ expect(items[0].diagnostics.find(item => item.code === RSDiagnosticCode.schemaHomonym)?.params?.[0]).toBe('X2');
81
+ expect(hasCstDiagnostic(items[1], RSDiagnosticCode.schemaHomonym)).toBe(true);
82
+ expect(items[1].diagnostics.find(item => item.code === RSDiagnosticCode.schemaHomonym)?.params?.[0]).toBe('X1');
83
+ expect(hasCstDiagnostic(items[2], RSDiagnosticCode.schemaFormalDuplicate)).toBe(true);
84
+ expect(items[2].diagnostics.find(item => item.code === RSDiagnosticCode.schemaFormalDuplicate)?.params?.[0]).toBe(
85
+ 'D2'
86
+ );
87
+ });
88
+
89
+ it('detects dependency cycles when graph is provided', () => {
90
+ const items = [
91
+ stubCst(1, 'F3', { cst_type: CstType.TERM, definition_formal: 'P1' }),
92
+ stubCst(2, 'P1', { cst_type: CstType.AXIOM, definition_formal: 'F3' }),
93
+ stubCst(3, 'X1', { convention: 'base', term_resolved: 'base' })
94
+ ];
95
+ const graph = new Graph([[1, 2], [2, 1], [3]]);
96
+ const resolveAlias = (id: number) => items.find(item => item.id === id)!.alias;
97
+
98
+ assignSchemaDiagnostics(items, resolveAlias, new Map(), graph);
99
+
100
+ expect(hasCstDiagnostic(items[0], RSDiagnosticCode.schemaDependencyCycle)).toBe(true);
101
+ expect(hasCstDiagnostic(items[1], RSDiagnosticCode.schemaDependencyCycle)).toBe(true);
102
+ expect(hasCstDiagnostic(items[2], RSDiagnosticCode.schemaDependencyCycle)).toBe(false);
103
+ const cycle = items[0].diagnostics.find(item => item.code === RSDiagnosticCode.schemaDependencyCycle)?.params?.[0];
104
+ expect(cycle).toBeDefined();
105
+ expect(cycle!.includes('F3')).toBe(true);
106
+ expect(cycle!.includes('P1')).toBe(true);
107
+ expect(cycle!.startsWith(cycle!.split(' → ').at(-1)!)).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe('formatDependencyCycle', () => {
112
+ it('joins aliases with arrows', () => {
113
+ expect(formatDependencyCycle(['F3', 'P1', 'F3'])).toBe('F3 → P1 → F3');
114
+ });
115
+ });
@@ -2,6 +2,7 @@
2
2
  * Module: Compact diagnostics for constituents (schema and model issue codes).
3
3
  */
4
4
 
5
+ import { type Graph } from '../graph';
5
6
  import { labelType } from '../rslang/labels';
6
7
 
7
8
  import { type Constituenta, type CstType } from './rsform';
@@ -23,6 +24,7 @@ export const RSDiagnosticCode = {
23
24
  schemaMissingConvention: 0x8903,
24
25
  schemaMissingTerm: 0x8904,
25
26
  schemaTypeMismatch: 0x8905,
27
+ schemaDependencyCycle: 0x8906,
26
28
 
27
29
  modelEmpty: 0x8910,
28
30
  modelAxiomFalse: 0x8911,
@@ -45,20 +47,27 @@ export function hasCstDiagnostic(cst: Constituenta, code: RSDiagnosticCode): boo
45
47
  }
46
48
 
47
49
  /**
48
- * Populate `diagnostics` on each constituent (homonyms, duplicates, metadata, type mismatch).
50
+ * Populate `diagnostics` on each constituent (homonyms, duplicates, metadata, type mismatch, cycles).
49
51
  *
50
52
  * Requires normalized formal definitions for duplicate detection (same as Portal loader).
53
+ * When `graph` is provided, dependency cycles are reported as {@link RSDiagnosticCode.schemaDependencyCycle}.
51
54
  */
52
55
  export function assignSchemaDiagnostics(
53
56
  items: Constituenta[],
54
57
  resolveAlias: (id: number) => string,
55
- normalizedDefinitions: ReadonlyMap<number, string>
58
+ normalizedDefinitions: ReadonlyMap<number, string>,
59
+ graph?: Graph
56
60
  ): void {
57
61
  const homonymById = detectHomonymDiagnostics(items, resolveAlias);
58
62
  const duplicateById = detectFormalDuplicateDiagnostics(items, resolveAlias, normalizedDefinitions);
63
+ const cycleById = graph ? detectDependencyCycleDiagnostics(graph, resolveAlias) : new Map<number, CstDiagnostic>();
59
64
 
60
65
  for (const cst of items) {
61
66
  const diagnostics: CstDiagnostic[] = [];
67
+ const cycle = cycleById.get(cst.id);
68
+ if (cycle) {
69
+ diagnostics.push(cycle);
70
+ }
62
71
  const homonym = homonymById.get(cst.id);
63
72
  if (homonym) {
64
73
  diagnostics.push(homonym);
@@ -72,6 +81,38 @@ export function assignSchemaDiagnostics(
72
81
  }
73
82
  }
74
83
 
84
+ /** Format a closed cycle of aliases as `A → B → A`. */
85
+ export function formatDependencyCycle(aliases: readonly string[]): string {
86
+ return aliases.join(' → ');
87
+ }
88
+
89
+ /**
90
+ * Map each node id that participates in a dependency cycle to a schema diagnostic.
91
+ * `params[0]` is the closed cycle path (`F3 → P1 → F3`).
92
+ */
93
+ export function detectDependencyCycleDiagnostics(
94
+ graph: Graph,
95
+ resolveAlias: (id: number) => string
96
+ ): Map<number, CstDiagnostic> {
97
+ const result = new Map<number, CstDiagnostic>();
98
+ for (const cycle of graph.findCycles()) {
99
+ if (cycle.length < 2) {
100
+ continue;
101
+ }
102
+ const aliases = cycle.map(id => resolveAlias(id));
103
+ const cycleText = formatDependencyCycle(aliases);
104
+ const memberIds = new Set(cycle);
105
+ for (const id of memberIds) {
106
+ result.set(id, {
107
+ kind: DiagnosticKind.SCHEMA,
108
+ code: RSDiagnosticCode.schemaDependencyCycle,
109
+ params: [cycleText]
110
+ });
111
+ }
112
+ }
113
+ return result;
114
+ }
115
+
75
116
  /** Map a model evaluation status to a compact diagnostic, or `null` when not an issue. */
76
117
  export function modelStatusCstDiagnostic(status: EvalStatus, cstType: CstType): CstDiagnostic | null {
77
118
  const code = modelStatusToCode(status, cstType);
@@ -101,23 +142,25 @@ function collectLocalSchemaDiagnostics(cst: Constituenta): CstDiagnostic[] {
101
142
  return result;
102
143
  }
103
144
 
104
- function detectHomonymDiagnostics(
145
+ function detectGroupedAliasDiagnostics(
105
146
  items: Constituenta[],
106
- resolveAlias: (id: number) => string
147
+ resolveAlias: (id: number) => string,
148
+ groupKey: (cst: Constituenta) => string | undefined,
149
+ code: RSDiagnosticCode
107
150
  ): Map<number, CstDiagnostic> {
108
- const byTerm = new Map<string, Constituenta[]>();
151
+ const groups = new Map<string, Constituenta[]>();
109
152
  for (const cst of items) {
110
- const key = cst.term_resolved.trim().toLocaleLowerCase();
111
- if (key === '') {
153
+ const key = groupKey(cst);
154
+ if (!key) {
112
155
  continue;
113
156
  }
114
- const group = byTerm.get(key) ?? [];
157
+ const group = groups.get(key) ?? [];
115
158
  group.push(cst);
116
- byTerm.set(key, group);
159
+ groups.set(key, group);
117
160
  }
118
161
 
119
162
  const result = new Map<number, CstDiagnostic>();
120
- for (const group of byTerm.values()) {
163
+ for (const group of groups.values()) {
121
164
  if (group.length <= 1) {
122
165
  continue;
123
166
  }
@@ -128,7 +171,7 @@ function detectHomonymDiagnostics(
128
171
  .join(', ');
129
172
  result.set(cst.id, {
130
173
  kind: DiagnosticKind.SCHEMA,
131
- code: RSDiagnosticCode.schemaHomonym,
174
+ code,
132
175
  params: [aliases]
133
176
  });
134
177
  }
@@ -136,40 +179,32 @@ function detectHomonymDiagnostics(
136
179
  return result;
137
180
  }
138
181
 
182
+ function detectHomonymDiagnostics(
183
+ items: Constituenta[],
184
+ resolveAlias: (id: number) => string
185
+ ): Map<number, CstDiagnostic> {
186
+ return detectGroupedAliasDiagnostics(
187
+ items,
188
+ resolveAlias,
189
+ cst => {
190
+ const key = cst.term_resolved.trim().toLocaleLowerCase();
191
+ return key === '' ? undefined : key;
192
+ },
193
+ RSDiagnosticCode.schemaHomonym
194
+ );
195
+ }
196
+
139
197
  function detectFormalDuplicateDiagnostics(
140
198
  items: Constituenta[],
141
199
  resolveAlias: (id: number) => string,
142
200
  normalizedDefinitions: ReadonlyMap<number, string>
143
201
  ): Map<number, CstDiagnostic> {
144
- const byDefinition = new Map<string, Constituenta[]>();
145
- for (const cst of items) {
146
- const key = normalizedDefinitions.get(cst.id);
147
- if (!key) {
148
- continue;
149
- }
150
- const group = byDefinition.get(key) ?? [];
151
- group.push(cst);
152
- byDefinition.set(key, group);
153
- }
154
-
155
- const result = new Map<number, CstDiagnostic>();
156
- for (const group of byDefinition.values()) {
157
- if (group.length <= 1) {
158
- continue;
159
- }
160
- for (const cst of group) {
161
- const aliases = group
162
- .filter(other => other.id !== cst.id)
163
- .map(other => resolveAlias(other.id))
164
- .join(', ');
165
- result.set(cst.id, {
166
- kind: DiagnosticKind.SCHEMA,
167
- code: RSDiagnosticCode.schemaFormalDuplicate,
168
- params: [aliases]
169
- });
170
- }
171
- }
172
- return result;
202
+ return detectGroupedAliasDiagnostics(
203
+ items,
204
+ resolveAlias,
205
+ cst => normalizedDefinitions.get(cst.id),
206
+ RSDiagnosticCode.schemaFormalDuplicate
207
+ );
173
208
  }
174
209
 
175
210
  function modelStatusToCode(status: EvalStatus, cstType: CstType): RSDiagnosticCode | null {
@@ -1,7 +1,9 @@
1
1
  export {
2
2
  assignSchemaDiagnostics,
3
3
  type CstDiagnostic,
4
+ detectDependencyCycleDiagnostics,
4
5
  DiagnosticKind,
6
+ formatDependencyCycle,
5
7
  hasCstDiagnostic,
6
8
  modelStatusCstDiagnostic,
7
9
  RSDiagnosticCode
@@ -1,9 +1,18 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
+ import { Graph } from '../graph';
3
4
  import { RSErrorCode } from '../rslang/error';
4
-
5
- import { CstType, type RSForm } from './rsform';
6
- import { applyMappingToConstituents, getAnalysisFor, normalizeExpression, validateAliasFormat } from './rsform-api';
5
+ import { RSLangAnalyzer } from '../rslang/semantic/analyzer';
6
+
7
+ import { RSDiagnosticCode } from './diagnostics';
8
+ import { type Constituenta, CstType, type RSForm } from './rsform';
9
+ import {
10
+ applyMappingToConstituents,
11
+ findCstByStructure,
12
+ getAnalysisFor,
13
+ normalizeExpression,
14
+ validateAliasFormat
15
+ } from './rsform-api';
7
16
 
8
17
  const emptySchema = {} as RSForm;
9
18
 
@@ -49,6 +58,56 @@ describe('getAnalysisFor', () => {
49
58
  params: [CstType.CONSTANT, 'C1']
50
59
  });
51
60
  });
61
+
62
+ it('upgrades to dependency cycle for unsaved draft that closes a cycle', () => {
63
+ // Saved graph: F3 → P1 only (P1 does not yet reference F3).
64
+ // Draft for P1 introduces F3, closing F3 ↔ P1 before Save.
65
+ const analyzer = new RSLangAnalyzer();
66
+ analyzer.addBase('X1');
67
+ // F3 is untyped in the analyzer (as after a failed cycle parse), so P1's call fails.
68
+ const items = [
69
+ {
70
+ id: 1,
71
+ alias: 'X1',
72
+ cst_type: CstType.BASE,
73
+ definition_formal: '',
74
+ diagnostics: []
75
+ },
76
+ {
77
+ id: 2,
78
+ alias: 'F3',
79
+ cst_type: CstType.FUNCTION,
80
+ definition_formal: '[σ∈X1] P1[σ]',
81
+ diagnostics: []
82
+ },
83
+ {
84
+ id: 3,
85
+ alias: 'P1',
86
+ cst_type: CstType.PREDICATE,
87
+ definition_formal: '[σ∈X1] σ=σ',
88
+ diagnostics: []
89
+ }
90
+ ] as unknown as Constituenta[];
91
+ const graph = new Graph([
92
+ [1, 2],
93
+ [1, 3],
94
+ [3, 2] // P1 → F3 from saved F3 definition
95
+ ]);
96
+ const schema = {
97
+ items,
98
+ graph,
99
+ analyzer,
100
+ cstByAlias: new Map(items.map(item => [item.alias, item])),
101
+ cstByID: new Map(items.map(item => [item.id, item]))
102
+ } as unknown as RSForm;
103
+
104
+ const draft = '[σ∈X1] F3[σ]=F3[σ]';
105
+ const result = getAnalysisFor(draft, CstType.PREDICATE, schema, 'P1');
106
+ const cycleError = result.errors.find(error => error.code === RSDiagnosticCode.schemaDependencyCycle);
107
+ expect(cycleError).toBeDefined();
108
+ expect(cycleError!.params?.[0]).toContain('F3');
109
+ expect(cycleError!.params?.[0]).toContain('P1');
110
+ });
52
111
  });
53
112
 
54
113
  describe('applyMappingToConstituents', () => {
@@ -98,3 +157,40 @@ describe('normalizeExpression', () => {
98
157
  expect(normalizeExpression('ℬ(X1)', CstType.STRUCTURED)).toBeNull();
99
158
  });
100
159
  });
160
+
161
+ describe('findCstByStructure', () => {
162
+ function makeProjectionChainSchema() {
163
+ const s1 = { id: 1, alias: 'S1' };
164
+ const d1 = { id: 2, alias: 'D1', spawner: 1, spawner_path: [0, 1] };
165
+ const d2 = { id: 3, alias: 'D2', spawner: 2, spawner_path: [2] };
166
+ const d3 = { id: 4, alias: 'D3', spawner: 3, spawner_path: [1] };
167
+ const d4 = { id: 5, alias: 'D4', spawner: 4, spawner_path: [2] };
168
+ const schema = {
169
+ items: [s1, d1, d2, d3, d4],
170
+ cstByID: new Map([
171
+ [1, s1],
172
+ [2, d1],
173
+ [3, d2],
174
+ [4, d3],
175
+ [5, d4]
176
+ ])
177
+ } as unknown as RSForm;
178
+ return { schema, s1, d1, d2, d3, d4 };
179
+ }
180
+
181
+ it('resolves chain of relative projection paths to root target', () => {
182
+ const { schema, s1, d2 } = makeProjectionChainSchema();
183
+
184
+ expect(findCstByStructure(schema, s1 as never, [0, 1, 2] as never)).toBe(d2);
185
+ });
186
+
187
+ it('resolves a four-step projection chain from the root structure', () => {
188
+ const { schema, s1, d1, d2, d3, d4 } = makeProjectionChainSchema();
189
+
190
+ expect(findCstByStructure(schema, s1 as never, [0, 1] as never)).toBe(d1);
191
+ expect(findCstByStructure(schema, s1 as never, [0, 1, 2] as never)).toBe(d2);
192
+ expect(findCstByStructure(schema, s1 as never, [0, 1, 2, 1] as never)).toBe(d3);
193
+ expect(findCstByStructure(schema, s1 as never, [0, 1, 2, 1, 2] as never)).toBe(d4);
194
+ expect(findCstByStructure(schema, s1 as never, [0, 1, 2, 2] as never)).toBeNull();
195
+ });
196
+ });