@rsconcept/domain 2.0.2 → 2.1.1

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 (59) hide show
  1. package/README.md +1 -1
  2. package/dist/{analyzer-iqAXyROE.d.ts → analyzer-fNhkwO-6.d.ts} +2 -2
  3. package/dist/{ast-annotations-D6KZDu12.d.ts → ast-annotations-CiIx8qBM.d.ts} +2 -2
  4. package/dist/{calculator-k8BGIKmj.d.ts → calculator-DE89LtIM.d.ts} +2 -2
  5. package/dist/cctext/language-api.js.map +1 -1
  6. package/dist/cctext/language.d.ts +7 -4
  7. package/dist/cctext/language.js.map +1 -1
  8. package/dist/{diagnostics-D7SiHAwW.js → diagnostics-CY9sKggB.js} +152 -12
  9. package/dist/diagnostics-CY9sKggB.js.map +1 -0
  10. package/dist/{diagnostics-Clo3mkba.d.ts → diagnostics-EaIt3zIw.d.ts} +28 -6
  11. package/dist/{error-CzIbnP-N.d.ts → error-B0EkIx4K.d.ts} +2 -1
  12. package/dist/graph/graph.d.ts +1 -1
  13. package/dist/graph/graph.js +93 -1
  14. package/dist/graph/graph.js.map +1 -1
  15. package/dist/graph/index.d.ts +1 -1
  16. package/dist/{graph-DR8rL2o3.d.ts → graph-CL73WdW1.d.ts} +15 -2
  17. package/dist/index.d.ts +7 -7
  18. package/dist/index.js +2 -2
  19. package/dist/library/diagnostics.d.ts +2 -2
  20. package/dist/library/diagnostics.js +2 -2
  21. package/dist/library/index.d.ts +2 -2
  22. package/dist/library/index.js +2 -2
  23. package/dist/library/oss-api.d.ts +1 -1
  24. package/dist/library/oss.d.ts +1 -1
  25. package/dist/library/rsengine.d.ts +2 -2
  26. package/dist/library/rsengine.js +1 -1
  27. package/dist/library/rsform-api.d.ts +14 -4
  28. package/dist/library/rsform-api.js +2 -2
  29. package/dist/library/rsform.d.ts +1 -1
  30. package/dist/library/rsmodel-api.d.ts +1 -1
  31. package/dist/library/rsmodel-api.js +1 -1
  32. package/dist/library/rsmodel.d.ts +1 -1
  33. package/dist/library/structure-planner.d.ts +1 -1
  34. package/dist/library/structure-planner.js +1 -1
  35. package/dist/rslang/ast-annotations.d.ts +1 -1
  36. package/dist/rslang/error.d.ts +1 -1
  37. package/dist/rslang/error.js +2 -1
  38. package/dist/rslang/error.js.map +1 -1
  39. package/dist/rslang/eval/calculator.d.ts +1 -1
  40. package/dist/rslang/eval/evaluator.d.ts +1 -1
  41. package/dist/rslang/index.d.ts +4 -4
  42. package/dist/rslang/parser/syntax-errors/index.d.ts +1 -1
  43. package/dist/rslang/semantic/analyzer.d.ts +1 -1
  44. package/dist/rslang/semantic/type-auditor.d.ts +1 -1
  45. package/dist/rslang/semantic/value-auditor.d.ts +1 -1
  46. package/package.json +14 -13
  47. package/src/cctext/language-api.test.ts +5 -0
  48. package/src/cctext/language-api.ts +6 -6
  49. package/src/cctext/language.ts +3 -4
  50. package/src/graph/graph.test.ts +22 -0
  51. package/src/graph/graph.ts +115 -1
  52. package/src/index.ts +2 -0
  53. package/src/library/diagnostics.test.ts +34 -0
  54. package/src/library/diagnostics.ts +81 -2
  55. package/src/library/index.ts +2 -0
  56. package/src/library/rsform-api.test.ts +99 -3
  57. package/src/library/rsform-api.ts +155 -7
  58. package/src/rslang/error.ts +4 -1
  59. package/dist/diagnostics-D7SiHAwW.js.map +0 -1
@@ -1,13 +1,13 @@
1
1
  import { A as makeTypePath, b as Typification, d as EmptySetT, f as ExpressionType, g as TypeClass, m as LogicT, t as AnyTypificationT, v as TypeID, y as TypePath } from "../typification-9lNu9R4k.js";
2
- import { i as RSErrorDescription, r as RSErrorCode } from "../error-CzIbnP-N.js";
3
- import { i as readTypeAnnotation, r as readErrorAnnotation } from "../ast-annotations-D6KZDu12.js";
2
+ import { i as RSErrorDescription, r as RSErrorCode } from "../error-B0EkIx4K.js";
3
+ import { i as readTypeAnnotation, r as readErrorAnnotation } from "../ast-annotations-CiIx8qBM.js";
4
4
  import { d as ValuePath, l as Value, p as makeValuePath } from "../value-BduA_ZGp.js";
5
- import { n as CalculatorResult, r as RSCalculator, t as CalculatorEvaluateOptions } from "../calculator-k8BGIKmj.js";
5
+ import { n as CalculatorResult, r as RSCalculator, t as CalculatorEvaluateOptions } from "../calculator-DE89LtIM.js";
6
6
  import { d as printValue } from "../value-api-CXLl_zg0.js";
7
7
  import { t as parser } from "../parser-Bwd8LxJ1.js";
8
8
  import { t as TokenID } from "../token-DeXAmzwr.js";
9
9
  import { t as ValueClass } from "../value-class-CNI-lqXJ.js";
10
- import { a as RSLangAnalyzer, n as AnalysisFast, r as AnalysisFull, t as AnalysisBase } from "../analyzer-iqAXyROE.js";
10
+ import { a as RSLangAnalyzer, n as AnalysisFast, r as AnalysisFull, t as AnalysisBase } from "../analyzer-fNhkwO-6.js";
11
11
  import { n as ArgumentsType } from "../arguments-extractor-ngatANBI.js";
12
12
  import { n as applyAsciiTypeSubstitutions, r as parseTypeText } from "../typification-parser-Dytsv9CI.js";
13
13
  import { n as TypificationNodeData, t as TypificationGraph } from "../typification-graph-BY_oGT82.js";
@@ -1,6 +1,6 @@
1
1
  import { t as AstNode } from "../../../ast-C8sIpKdL.js";
2
2
  import { g as TypeClass } from "../../../typification-9lNu9R4k.js";
3
- import { t as ErrorReporter } from "../../../error-CzIbnP-N.js";
3
+ import { t as ErrorReporter } from "../../../error-B0EkIx4K.js";
4
4
 
5
5
  //#region src/rslang/parser/syntax-errors/index.d.ts
6
6
  /** When set, refines incomplete formal headers (`[α∈…]`, quantifiers). */
@@ -1,2 +1,2 @@
1
- import { a as RSLangAnalyzer, i as AnalysisOptions, n as AnalysisFast, r as AnalysisFull, t as AnalysisBase } from "../../analyzer-iqAXyROE.js";
1
+ import { a as RSLangAnalyzer, i as AnalysisOptions, n as AnalysisFast, r as AnalysisFull, t as AnalysisBase } from "../../analyzer-fNhkwO-6.js";
2
2
  export { AnalysisBase, AnalysisFast, AnalysisFull, AnalysisOptions, RSLangAnalyzer };
@@ -1,6 +1,6 @@
1
1
  import { t as AstNode } from "../../ast-C8sIpKdL.js";
2
2
  import { _ as TypeContext, f as ExpressionType } from "../../typification-9lNu9R4k.js";
3
- import { t as ErrorReporter } from "../../error-CzIbnP-N.js";
3
+ import { t as ErrorReporter } from "../../error-B0EkIx4K.js";
4
4
 
5
5
  //#region src/rslang/semantic/type-auditor.d.ts
6
6
  /** Type auditor for AST type checking. */
@@ -1,5 +1,5 @@
1
1
  import { t as AstNode } from "../../ast-C8sIpKdL.js";
2
- import { t as ErrorReporter } from "../../error-CzIbnP-N.js";
2
+ import { t as ErrorReporter } from "../../error-B0EkIx4K.js";
3
3
  import { n as ValueClassContext, t as ValueClass } from "../../value-class-CNI-lqXJ.js";
4
4
 
5
5
  //#region src/rslang/semantic/value-auditor.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rsconcept/domain",
3
- "version": "2.0.2",
3
+ "version": "2.1.1",
4
4
  "description": "Shared TypeScript domain for RS language, RSForm, RSModel, RSEngine, OSS, and concept-text helpers.",
5
5
  "license": "MIT",
6
6
  "author": "IRBorisov",
@@ -154,6 +154,18 @@
154
154
  "README.md",
155
155
  "LICENSE"
156
156
  ],
157
+ "scripts": {
158
+ "generate": "lezer-generator src/rslang/parser/rslang.grammar -o src/rslang/parser/parser.ts",
159
+ "build": "tsdown",
160
+ "dev": "tsdown --watch",
161
+ "typecheck": "tsc -p tsconfig.json --noEmit",
162
+ "test": "vitest run",
163
+ "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
164
+ "lintFix": "eslint . --report-unused-disable-directives --max-warnings 0 --fix",
165
+ "format": "prettier --write \"src/**/*.ts\" \"*.{ts,js}\"",
166
+ "format:check": "prettier --check \"src/**/*.ts\" \"*.{ts,js}\"",
167
+ "prepublishOnly": "pnpm run build"
168
+ },
157
169
  "dependencies": {
158
170
  "@lezer/common": "^1.5.2",
159
171
  "@lezer/lr": "^1.4.10"
@@ -174,16 +186,5 @@
174
186
  },
175
187
  "publishConfig": {
176
188
  "access": "public"
177
- },
178
- "scripts": {
179
- "generate": "lezer-generator src/rslang/parser/rslang.grammar -o src/rslang/parser/parser.ts",
180
- "build": "tsdown",
181
- "dev": "tsdown --watch",
182
- "typecheck": "tsc -p tsconfig.json --noEmit",
183
- "test": "vitest run",
184
- "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
185
- "lintFix": "eslint . --report-unused-disable-directives --max-warnings 0 --fix",
186
- "format": "prettier --write \"src/**/*.ts\" \"*.{ts,js}\"",
187
- "format:check": "prettier --check \"src/**/*.ts\" \"*.{ts,js}\""
188
189
  }
189
- }
190
+ }
@@ -112,6 +112,11 @@ describe('Testing reference parsing', () => {
112
112
  expect(parseReference('@{0|derived term}')).toBeNull();
113
113
  expect(parseReference('@{-0|derived term}')).toBeNull();
114
114
  expect(parseReference('@{1|}')).toBeNull();
115
+ // Cyrillic А (U+0410) is not a syntactic offset; treat as entity alias
116
+ expect(parseReference('@{А55|sing,datv}')).toStrictEqual({
117
+ type: 'entity',
118
+ data: { entity: 'А55', tags: ['sing', 'datv'] }
119
+ });
115
120
  });
116
121
  });
117
122
 
@@ -81,7 +81,7 @@ export function parseEntityReference(text: string): EntityReference {
81
81
  if (ref?.type !== ReferenceType.ENTITY) {
82
82
  throw new Error(`Invalid entity reference: ${text}`);
83
83
  }
84
- return ref.data as EntityReference;
84
+ return ref.data;
85
85
  }
86
86
 
87
87
  /**
@@ -94,7 +94,7 @@ export function parseSyntacticReference(text: string): SyntacticReference {
94
94
  if (ref?.type !== ReferenceType.SYNTACTIC) {
95
95
  throw new Error(`Invalid syntactic reference: ${text}`);
96
96
  }
97
- return ref.data as SyntacticReference;
97
+ return ref.data;
98
98
  }
99
99
 
100
100
  /** Extracts a validated reference from string representation. */
@@ -172,12 +172,12 @@ export function resolveTextReferences(text: string, context: TermContext): strin
172
172
 
173
173
  for (const ref of references) {
174
174
  if (ref.ref.type === ReferenceType.ENTITY) {
175
- ref.resolved = resolveEntity(ref.ref.data as EntityReference, context);
175
+ ref.resolved = resolveEntity(ref.ref.data, context);
176
176
  }
177
177
  }
178
178
  references.forEach((ref, index) => {
179
179
  if (ref.ref.type === ReferenceType.SYNTACTIC) {
180
- ref.resolved = resolveSyntactic(ref.ref.data as SyntacticReference, index, references);
180
+ ref.resolved = resolveSyntactic(ref.ref.data, index, references);
181
181
  }
182
182
  });
183
183
 
@@ -196,11 +196,11 @@ export function resolveTextReferences(text: string, context: TermContext): strin
196
196
  export function referenceToString(ref: IReference): string {
197
197
  switch (ref.type) {
198
198
  case ReferenceType.ENTITY: {
199
- const entity = ref.data as EntityReference;
199
+ const entity = ref.data;
200
200
  return `@{${entity.entity}|${entity.tags.join(',')}}`;
201
201
  }
202
202
  case ReferenceType.SYNTACTIC: {
203
- const syntactic = ref.data as SyntacticReference;
203
+ const syntactic = ref.data;
204
204
  return `@{${syntactic.offset}|${syntactic.nominal}}`;
205
205
  }
206
206
  }
@@ -74,7 +74,6 @@ export interface SyntacticReference {
74
74
  }
75
75
 
76
76
  /** Represents abstract reference data. */
77
- export interface IReference {
78
- type: ReferenceType;
79
- data: EntityReference | SyntacticReference;
80
- }
77
+ export type IReference =
78
+ | { type: typeof ReferenceType.ENTITY; data: EntityReference }
79
+ | { type: typeof ReferenceType.SYNTACTIC; data: SyntacticReference };
@@ -374,6 +374,28 @@ describe('Testing Graph queries', () => {
374
374
  expect(graph.findCycle()).toStrictEqual(null);
375
375
  });
376
376
 
377
+ it('findCycles returns one closed path per cyclic SCC', () => {
378
+ const graph = new Graph([[1, 2], [2, 1], [3, 4], [4, 3], [5]]);
379
+ const cycles = graph.findCycles();
380
+ expect(cycles).toHaveLength(2);
381
+ expect(cycles.some(cycle => cycle[0] === cycle[cycle.length - 1] && cycle.includes(1) && cycle.includes(2))).toBe(
382
+ true
383
+ );
384
+ expect(cycles.some(cycle => cycle[0] === cycle[cycle.length - 1] && cycle.includes(3) && cycle.includes(4))).toBe(
385
+ true
386
+ );
387
+ });
388
+
389
+ it('findCycles reports self-loop', () => {
390
+ const graph = new Graph([[1, 1], [2]]);
391
+ expect(graph.findCycles()).toStrictEqual([[1, 1]]);
392
+ });
393
+
394
+ it('findCycles returns empty for acyclic graph', () => {
395
+ const graph = new Graph([[1, 2], [2, 3], [3]]);
396
+ expect(graph.findCycles()).toStrictEqual([]);
397
+ });
398
+
377
399
  it('sortStable should stably reorder node ids according to topological order', () => {
378
400
  const graph = new Graph([
379
401
  [1, 2],
@@ -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,118 @@ 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
+ /**
473
+ * Tarjan strongly connected components (iterative).
474
+ *
475
+ * @returns Node-id arrays; multi-node components are cyclic SCCs, single-node
476
+ * components are acyclic unless the node has a self-loop.
477
+ */
478
+ stronglyConnectedComponents(): NodeID[][] {
479
+ let index = 0;
480
+ const indices = new Map<NodeID, number>();
481
+ const lowlink = new Map<NodeID, number>();
482
+ const onStack = new Set<NodeID>();
483
+ const stack: NodeID[] = [];
484
+ const components: NodeID[][] = [];
485
+
486
+ /** Tarjan stack frame for iterative {@link Graph.stronglyConnectedComponents}. */
487
+ interface Frame {
488
+ nodeId: NodeID;
489
+ edgeIndex: number;
490
+ entered: boolean;
491
+ }
492
+ for (const startId of this.nodes.keys()) {
493
+ if (indices.has(startId)) {
494
+ continue;
495
+ }
496
+ const callStack: Frame[] = [{ nodeId: startId, edgeIndex: 0, entered: false }];
497
+ while (callStack.length > 0) {
498
+ const frame = callStack[callStack.length - 1];
499
+ const node = this.nodes.get(frame.nodeId);
500
+ if (!node) {
501
+ callStack.pop();
502
+ continue;
503
+ }
504
+
505
+ if (!frame.entered) {
506
+ frame.entered = true;
507
+ indices.set(frame.nodeId, index);
508
+ lowlink.set(frame.nodeId, index);
509
+ index += 1;
510
+ stack.push(frame.nodeId);
511
+ onStack.add(frame.nodeId);
512
+ }
513
+
514
+ if (frame.edgeIndex < node.outputs.length) {
515
+ const child = node.outputs[frame.edgeIndex];
516
+ frame.edgeIndex += 1;
517
+ if (!indices.has(child)) {
518
+ callStack.push({ nodeId: child, edgeIndex: 0, entered: false });
519
+ } else if (onStack.has(child)) {
520
+ lowlink.set(frame.nodeId, Math.min(lowlink.get(frame.nodeId)!, indices.get(child)!));
521
+ }
522
+ continue;
523
+ }
524
+
525
+ callStack.pop();
526
+ if (callStack.length > 0) {
527
+ const parentId = callStack[callStack.length - 1].nodeId;
528
+ lowlink.set(parentId, Math.min(lowlink.get(parentId)!, lowlink.get(frame.nodeId)!));
529
+ }
530
+
531
+ if (lowlink.get(frame.nodeId) === indices.get(frame.nodeId)) {
532
+ const component: NodeID[] = [];
533
+ while (stack.length > 0) {
534
+ const id = stack.pop()!;
535
+ onStack.delete(id);
536
+ component.push(id);
537
+ if (id === frame.nodeId) {
538
+ break;
539
+ }
540
+ }
541
+ components.push(component);
542
+ }
543
+ }
544
+ }
545
+ return components;
546
+ }
433
547
  }
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
@@ -1,8 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
+ import { Graph } from '../graph';
4
+
3
5
  import {
4
6
  assignSchemaDiagnostics,
5
7
  DiagnosticKind,
8
+ formatDependencyCycle,
6
9
  hasCstDiagnostic,
7
10
  modelStatusCstDiagnostic,
8
11
  RSDiagnosticCode
@@ -82,4 +85,35 @@ describe('assignSchemaDiagnostics', () => {
82
85
  'D2'
83
86
  );
84
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
+
116
+ it('uses the same text regardless of cycle rotation', () => {
117
+ expect(formatDependencyCycle(['P1', 'F3', 'P1'])).toBe('F3 → P1 → F3');
118
+ });
85
119
  });
@@ -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,76 @@ export function assignSchemaDiagnostics(
72
81
  }
73
82
  }
74
83
 
84
+ /**
85
+ * Format a closed dependency cycle as `A → B → A`.
86
+ *
87
+ * Rotates the path to start at the lexicographically smallest alias so the same
88
+ * cycle always renders identically regardless of graph traversal order.
89
+ *
90
+ * @param aliases Closed or open alias path from {@link Graph.findCycles}.
91
+ * @returns Human-readable cycle text joined with ` → `.
92
+ */
93
+ export function formatDependencyCycle(aliases: readonly string[]): string {
94
+ if (aliases.length < 2) {
95
+ return aliases.join(' → ');
96
+ }
97
+
98
+ const closed = aliases[0] === aliases[aliases.length - 1];
99
+ const open = closed ? aliases.slice(0, -1) : [...aliases];
100
+ if (open.length === 0) {
101
+ return aliases.join(' → ');
102
+ }
103
+
104
+ let bestStart = 0;
105
+ for (let index = 1; index < open.length; index++) {
106
+ const alias = open[index];
107
+ const bestAlias = open[bestStart];
108
+ if (alias !== undefined && bestAlias !== undefined && alias < bestAlias) {
109
+ bestStart = index;
110
+ }
111
+ }
112
+ const rotated = [...open.slice(bestStart), ...open.slice(0, bestStart)];
113
+ if (closed) {
114
+ const first = rotated[0];
115
+ if (first !== undefined) {
116
+ rotated.push(first);
117
+ }
118
+ }
119
+ return rotated.join(' → ');
120
+ }
121
+
122
+ /**
123
+ * Map each node id that participates in a dependency cycle to a schema diagnostic.
124
+ *
125
+ * `params[0]` is the closed cycle path (`F3 → P1 → F3`).
126
+ *
127
+ * @param graph Constituent dependency graph (provider → dependent edges).
128
+ * @param resolveAlias Maps node id to display alias for cycle formatting.
129
+ * @returns One {@link CstDiagnostic} per node on each cyclic SCC.
130
+ */
131
+ export function detectDependencyCycleDiagnostics(
132
+ graph: Graph,
133
+ resolveAlias: (id: number) => string
134
+ ): Map<number, CstDiagnostic> {
135
+ const result = new Map<number, CstDiagnostic>();
136
+ for (const cycle of graph.findCycles()) {
137
+ if (cycle.length < 2) {
138
+ continue;
139
+ }
140
+ const aliases = cycle.map(id => resolveAlias(id));
141
+ const cycleText = formatDependencyCycle(aliases);
142
+ const memberIds = new Set(cycle);
143
+ for (const id of memberIds) {
144
+ result.set(id, {
145
+ kind: DiagnosticKind.SCHEMA,
146
+ code: RSDiagnosticCode.schemaDependencyCycle,
147
+ params: [cycleText]
148
+ });
149
+ }
150
+ }
151
+ return result;
152
+ }
153
+
75
154
  /** Map a model evaluation status to a compact diagnostic, or `null` when not an issue. */
76
155
  export function modelStatusCstDiagnostic(status: EvalStatus, cstType: CstType): CstDiagnostic | null {
77
156
  const code = modelStatusToCode(status, cstType);
@@ -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
+ });