@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.
- package/README.md +1 -1
- package/dist/cctext/language-api.js.map +1 -1
- package/dist/cctext/language.d.ts +7 -4
- package/dist/cctext/language.js.map +1 -1
- package/dist/{diagnostics-DYMvTSev.js → diagnostics-BSryP66e.js} +131 -40
- package/dist/diagnostics-BSryP66e.js.map +1 -0
- package/dist/{diagnostics-Clo3mkba.d.ts → diagnostics-Dtzgj5Q9.d.ts} +14 -5
- package/dist/graph/graph.d.ts +1 -1
- package/dist/graph/graph.js +88 -1
- package/dist/graph/graph.js.map +1 -1
- package/dist/graph/index.d.ts +1 -1
- package/dist/{graph-DR8rL2o3.d.ts → graph-DPOvglEm.d.ts} +10 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/library/diagnostics.d.ts +2 -2
- package/dist/library/diagnostics.js +2 -2
- package/dist/library/index.d.ts +2 -2
- package/dist/library/index.js +2 -2
- package/dist/library/oss-api.d.ts +1 -1
- package/dist/library/oss.d.ts +1 -1
- package/dist/library/rsengine.d.ts +1 -1
- package/dist/library/rsengine.js +1 -1
- package/dist/library/rsform-api.d.ts +13 -2
- package/dist/library/rsform-api.js +2 -2
- package/dist/library/rsform.d.ts +1 -1
- package/dist/library/rsmodel-api.d.ts +1 -1
- package/dist/library/rsmodel-api.js +1 -1
- package/dist/library/rsmodel.d.ts +1 -1
- package/dist/library/structure-planner.d.ts +1 -1
- package/dist/library/structure-planner.js +1 -1
- package/package.json +14 -13
- package/src/cctext/language-api.test.ts +5 -0
- package/src/cctext/language-api.ts +6 -6
- package/src/cctext/language.ts +3 -4
- package/src/graph/graph.test.ts +22 -0
- package/src/graph/graph.ts +105 -1
- package/src/index.ts +2 -0
- package/src/library/diagnostics.test.ts +115 -0
- package/src/library/diagnostics.ts +75 -40
- package/src/library/index.ts +2 -0
- package/src/library/rsform-api.test.ts +99 -3
- package/src/library/rsform-api.ts +147 -6
- package/dist/diagnostics-DYMvTSev.js.map +0 -1
package/src/graph/graph.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
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
|
|
151
|
+
const groups = new Map<string, Constituenta[]>();
|
|
109
152
|
for (const cst of items) {
|
|
110
|
-
const key = cst
|
|
111
|
-
if (key
|
|
153
|
+
const key = groupKey(cst);
|
|
154
|
+
if (!key) {
|
|
112
155
|
continue;
|
|
113
156
|
}
|
|
114
|
-
const group =
|
|
157
|
+
const group = groups.get(key) ?? [];
|
|
115
158
|
group.push(cst);
|
|
116
|
-
|
|
159
|
+
groups.set(key, group);
|
|
117
160
|
}
|
|
118
161
|
|
|
119
162
|
const result = new Map<number, CstDiagnostic>();
|
|
120
|
-
for (const group of
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 {
|
package/src/library/index.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
import {
|
|
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
|
+
});
|