@polagram/core 0.0.2
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 +124 -0
- package/dist/index.d.ts +625 -0
- package/dist/polagram-core.js +3653 -0
- package/dist/polagram-core.umd.cjs +28 -0
- package/dist/src/api.d.ts +75 -0
- package/dist/src/api.js +160 -0
- package/dist/src/ast/ast.test.d.ts +1 -0
- package/dist/src/ast/ast.test.js +146 -0
- package/dist/src/ast/index.d.ts +119 -0
- package/dist/src/ast/index.js +2 -0
- package/dist/src/config/index.d.ts +1 -0
- package/dist/src/config/index.js +1 -0
- package/dist/src/config/schema.d.ts +182 -0
- package/dist/src/config/schema.js +78 -0
- package/dist/src/config/schema.test.d.ts +1 -0
- package/dist/src/config/schema.test.js +94 -0
- package/dist/src/generator/base/walker.d.ts +19 -0
- package/dist/src/generator/base/walker.js +56 -0
- package/dist/src/generator/base/walker.test.d.ts +1 -0
- package/dist/src/generator/base/walker.test.js +49 -0
- package/dist/src/generator/generators/mermaid.d.ts +24 -0
- package/dist/src/generator/generators/mermaid.js +140 -0
- package/dist/src/generator/generators/mermaid.test.d.ts +1 -0
- package/dist/src/generator/generators/mermaid.test.js +70 -0
- package/dist/src/generator/interface.d.ts +17 -0
- package/dist/src/generator/interface.js +1 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.js +17 -0
- package/dist/src/parser/base/lexer.d.ts +18 -0
- package/dist/src/parser/base/lexer.js +95 -0
- package/dist/src/parser/base/lexer.test.d.ts +1 -0
- package/dist/src/parser/base/lexer.test.js +53 -0
- package/dist/src/parser/base/parser.d.ts +14 -0
- package/dist/src/parser/base/parser.js +43 -0
- package/dist/src/parser/base/parser.test.d.ts +1 -0
- package/dist/src/parser/base/parser.test.js +90 -0
- package/dist/src/parser/index.d.ts +10 -0
- package/dist/src/parser/index.js +29 -0
- package/dist/src/parser/index.test.d.ts +1 -0
- package/dist/src/parser/index.test.js +23 -0
- package/dist/src/parser/interface.d.ts +8 -0
- package/dist/src/parser/interface.js +1 -0
- package/dist/src/parser/languages/mermaid/constants.d.ts +7 -0
- package/dist/src/parser/languages/mermaid/constants.js +20 -0
- package/dist/src/parser/languages/mermaid/index.d.ts +4 -0
- package/dist/src/parser/languages/mermaid/index.js +11 -0
- package/dist/src/parser/languages/mermaid/lexer.d.ts +14 -0
- package/dist/src/parser/languages/mermaid/lexer.js +152 -0
- package/dist/src/parser/languages/mermaid/lexer.test.d.ts +1 -0
- package/dist/src/parser/languages/mermaid/lexer.test.js +58 -0
- package/dist/src/parser/languages/mermaid/parser.d.ts +21 -0
- package/dist/src/parser/languages/mermaid/parser.js +340 -0
- package/dist/src/parser/languages/mermaid/parser.test.d.ts +1 -0
- package/dist/src/parser/languages/mermaid/parser.test.js +252 -0
- package/dist/src/parser/languages/mermaid/tokens.d.ts +9 -0
- package/dist/src/parser/languages/mermaid/tokens.js +1 -0
- package/dist/src/transformer/cleaners/prune-empty.d.ts +9 -0
- package/dist/src/transformer/cleaners/prune-empty.js +27 -0
- package/dist/src/transformer/cleaners/prune-empty.test.d.ts +1 -0
- package/dist/src/transformer/cleaners/prune-empty.test.js +69 -0
- package/dist/src/transformer/cleaners/prune-unused.d.ts +5 -0
- package/dist/src/transformer/cleaners/prune-unused.js +48 -0
- package/dist/src/transformer/cleaners/prune-unused.test.d.ts +1 -0
- package/dist/src/transformer/cleaners/prune-unused.test.js +71 -0
- package/dist/src/transformer/filters/focus.d.ts +13 -0
- package/dist/src/transformer/filters/focus.js +71 -0
- package/dist/src/transformer/filters/focus.test.d.ts +1 -0
- package/dist/src/transformer/filters/focus.test.js +50 -0
- package/dist/src/transformer/filters/remove.d.ts +12 -0
- package/dist/src/transformer/filters/remove.js +82 -0
- package/dist/src/transformer/filters/remove.test.d.ts +1 -0
- package/dist/src/transformer/filters/remove.test.js +38 -0
- package/dist/src/transformer/filters/resolve.d.ts +9 -0
- package/dist/src/transformer/filters/resolve.js +32 -0
- package/dist/src/transformer/filters/resolve.test.d.ts +1 -0
- package/dist/src/transformer/filters/resolve.test.js +48 -0
- package/dist/src/transformer/index.d.ts +10 -0
- package/dist/src/transformer/index.js +10 -0
- package/dist/src/transformer/lens.d.ts +12 -0
- package/dist/src/transformer/lens.js +58 -0
- package/dist/src/transformer/lens.test.d.ts +1 -0
- package/dist/src/transformer/lens.test.js +60 -0
- package/dist/src/transformer/orchestration/engine.d.ts +5 -0
- package/dist/src/transformer/orchestration/engine.js +24 -0
- package/dist/src/transformer/orchestration/engine.test.d.ts +1 -0
- package/dist/src/transformer/orchestration/engine.test.js +41 -0
- package/dist/src/transformer/orchestration/registry.d.ts +10 -0
- package/dist/src/transformer/orchestration/registry.js +27 -0
- package/dist/src/transformer/selector/matcher.d.ts +9 -0
- package/dist/src/transformer/selector/matcher.js +62 -0
- package/dist/src/transformer/selector/matcher.test.d.ts +1 -0
- package/dist/src/transformer/selector/matcher.test.js +53 -0
- package/dist/src/transformer/traverse/walker.d.ts +14 -0
- package/dist/src/transformer/traverse/walker.js +67 -0
- package/dist/src/transformer/traverse/walker.test.d.ts +1 -0
- package/dist/src/transformer/traverse/walker.test.js +48 -0
- package/dist/src/transformer/types.d.ts +47 -0
- package/dist/src/transformer/types.js +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Lexer } from './lexer';
|
|
3
|
+
import { Parser } from './parser';
|
|
4
|
+
describe('Mermaid Parser', () => {
|
|
5
|
+
const parse = (input) => {
|
|
6
|
+
const lexer = new Lexer(input);
|
|
7
|
+
const parser = new Parser(lexer);
|
|
8
|
+
return parser.parse();
|
|
9
|
+
};
|
|
10
|
+
describe('Basic Document Structure', () => {
|
|
11
|
+
it('should parse "sequenceDiagram" header', () => {
|
|
12
|
+
const input = `sequenceDiagram`;
|
|
13
|
+
const ast = parse(input);
|
|
14
|
+
expect(ast.kind).toBe('root');
|
|
15
|
+
expect(ast.meta.source).toBe('mermaid');
|
|
16
|
+
expect(ast.participants).toEqual([]);
|
|
17
|
+
expect(ast.events).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
it('should handle newlines around header', () => {
|
|
20
|
+
const input = `
|
|
21
|
+
sequenceDiagram
|
|
22
|
+
`;
|
|
23
|
+
const ast = parse(input);
|
|
24
|
+
expect(ast.meta.source).toBe('mermaid');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('Participant Definitions', () => {
|
|
28
|
+
it('should parse simple participant', () => {
|
|
29
|
+
const input = `
|
|
30
|
+
sequenceDiagram
|
|
31
|
+
participant Alice
|
|
32
|
+
participant Bob
|
|
33
|
+
`;
|
|
34
|
+
const ast = parse(input);
|
|
35
|
+
expect(ast.participants).toHaveLength(2);
|
|
36
|
+
expect(ast.participants[0]).toMatchObject({
|
|
37
|
+
id: 'Alice',
|
|
38
|
+
name: 'Alice',
|
|
39
|
+
type: 'participant'
|
|
40
|
+
});
|
|
41
|
+
expect(ast.participants[1]).toMatchObject({
|
|
42
|
+
id: 'Bob',
|
|
43
|
+
name: 'Bob',
|
|
44
|
+
type: 'participant'
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
it('should parse participant with multi-word alias', () => {
|
|
48
|
+
const input = `
|
|
49
|
+
sequenceDiagram
|
|
50
|
+
participant API as API Server
|
|
51
|
+
participant DB as Database System
|
|
52
|
+
API->>DB: Query
|
|
53
|
+
`;
|
|
54
|
+
const ast = parse(input);
|
|
55
|
+
const api = ast.participants.find(p => p.id === 'API');
|
|
56
|
+
expect(api).toBeDefined();
|
|
57
|
+
expect(api?.name).toBe('API Server');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('Message Definitions', () => {
|
|
61
|
+
it('should parse sync message and implicit participants', () => {
|
|
62
|
+
const input = `
|
|
63
|
+
sequenceDiagram
|
|
64
|
+
A->B: Hello
|
|
65
|
+
`;
|
|
66
|
+
const ast = parse(input);
|
|
67
|
+
expect(ast.participants).toHaveLength(2);
|
|
68
|
+
expect(ast.participants).toMatchObject([
|
|
69
|
+
{ id: 'A', name: 'A' },
|
|
70
|
+
{ id: 'B', name: 'B' }
|
|
71
|
+
]);
|
|
72
|
+
expect(ast.events).toHaveLength(1);
|
|
73
|
+
expect(ast.events[0]).toMatchObject({
|
|
74
|
+
kind: 'message',
|
|
75
|
+
from: 'A',
|
|
76
|
+
to: 'B',
|
|
77
|
+
text: 'Hello',
|
|
78
|
+
type: 'sync',
|
|
79
|
+
style: { line: 'solid', head: 'open' }
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
it('should parse various arrow types', () => {
|
|
83
|
+
const input = `
|
|
84
|
+
sequenceDiagram
|
|
85
|
+
A->>B: Arrow
|
|
86
|
+
A-->>B: Reply Arrow
|
|
87
|
+
A-->B: Dotted Open
|
|
88
|
+
A-xB: Cross
|
|
89
|
+
`;
|
|
90
|
+
const ast = parse(input);
|
|
91
|
+
expect(ast.events).toHaveLength(4);
|
|
92
|
+
const m1 = ast.events[0];
|
|
93
|
+
const m2 = ast.events[1];
|
|
94
|
+
const m3 = ast.events[2];
|
|
95
|
+
const m4 = ast.events[3];
|
|
96
|
+
// ->> : sync, solid, arrow
|
|
97
|
+
expect(m1).toMatchObject({
|
|
98
|
+
kind: 'message',
|
|
99
|
+
type: 'sync',
|
|
100
|
+
style: { line: 'solid', head: 'arrow' }
|
|
101
|
+
});
|
|
102
|
+
// -->> : reply, dotted, arrow
|
|
103
|
+
expect(m2).toMatchObject({
|
|
104
|
+
kind: 'message',
|
|
105
|
+
type: 'reply',
|
|
106
|
+
style: { line: 'dotted', head: 'arrow' }
|
|
107
|
+
});
|
|
108
|
+
// --> : reply, dotted, open
|
|
109
|
+
expect(m3).toMatchObject({
|
|
110
|
+
kind: 'message',
|
|
111
|
+
type: 'reply',
|
|
112
|
+
style: { line: 'dotted', head: 'open' }
|
|
113
|
+
});
|
|
114
|
+
// -x : destroy, solid, cross
|
|
115
|
+
expect(m4).toMatchObject({
|
|
116
|
+
kind: 'message',
|
|
117
|
+
type: 'destroy',
|
|
118
|
+
style: { line: 'solid', head: 'cross' }
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
it('should parse lifecycle activations in messages', () => {
|
|
122
|
+
const input = `
|
|
123
|
+
sequenceDiagram
|
|
124
|
+
A->>+B: Activate Target
|
|
125
|
+
B-->>-A: Deactivate Source
|
|
126
|
+
`;
|
|
127
|
+
const ast = parse(input);
|
|
128
|
+
const m1 = ast.events[0];
|
|
129
|
+
const m2 = ast.events[1];
|
|
130
|
+
if (m1.kind !== 'message' || m2.kind !== 'message') {
|
|
131
|
+
throw new Error('Expected message events');
|
|
132
|
+
}
|
|
133
|
+
expect(m1.lifecycle).toMatchObject({ activateTarget: true, deactivateSource: false });
|
|
134
|
+
expect(m2.lifecycle).toMatchObject({ activateTarget: false, deactivateSource: true });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
describe('Lifecycle Definitions', () => {
|
|
138
|
+
it('should parse standalone activate/deactivate', () => {
|
|
139
|
+
const input = `
|
|
140
|
+
sequenceDiagram
|
|
141
|
+
activate A
|
|
142
|
+
deactivate A
|
|
143
|
+
`;
|
|
144
|
+
const ast = parse(input);
|
|
145
|
+
expect(ast.events).toHaveLength(2);
|
|
146
|
+
expect(ast.events[0]).toMatchObject({ kind: 'activation', participantId: 'A', action: 'activate' });
|
|
147
|
+
expect(ast.events[1]).toMatchObject({ kind: 'activation', participantId: 'A', action: 'deactivate' });
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
describe('Note Definitions', () => {
|
|
151
|
+
it('should parse note positions', () => {
|
|
152
|
+
const input = `
|
|
153
|
+
sequenceDiagram
|
|
154
|
+
note left of A: Note Left
|
|
155
|
+
note right of A: Note Right
|
|
156
|
+
note over A: Note Over
|
|
157
|
+
`;
|
|
158
|
+
const ast = parse(input);
|
|
159
|
+
expect(ast.events).toHaveLength(3);
|
|
160
|
+
expect(ast.events[0]).toMatchObject({ kind: 'note', position: 'left', participantIds: ['A'], text: 'Note Left' });
|
|
161
|
+
expect(ast.events[1]).toMatchObject({ kind: 'note', position: 'right', participantIds: ['A'], text: 'Note Right' });
|
|
162
|
+
expect(ast.events[2]).toMatchObject({ kind: 'note', position: 'over', participantIds: ['A'], text: 'Note Over' });
|
|
163
|
+
});
|
|
164
|
+
it('should parse note over multiple participants', () => {
|
|
165
|
+
const input = `sequenceDiagram\nnote over A,B: Shared Note`;
|
|
166
|
+
const ast = parse(input);
|
|
167
|
+
expect(ast.events[0]).toMatchObject({ kind: 'note', position: 'over', participantIds: ['A', 'B'], text: 'Shared Note' });
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe('Fragment Definitions', () => {
|
|
171
|
+
it('should parse loop fragment', () => {
|
|
172
|
+
const input = `
|
|
173
|
+
sequenceDiagram
|
|
174
|
+
loop Every minute
|
|
175
|
+
A->B: Check
|
|
176
|
+
end
|
|
177
|
+
`;
|
|
178
|
+
const ast = parse(input);
|
|
179
|
+
expect(ast.events).toHaveLength(1);
|
|
180
|
+
const fragment = ast.events[0];
|
|
181
|
+
expect(fragment.kind).toBe('fragment');
|
|
182
|
+
if (fragment.kind !== 'fragment')
|
|
183
|
+
return; // type guard
|
|
184
|
+
expect(fragment.operator).toBe('loop');
|
|
185
|
+
expect(fragment.branches).toHaveLength(1);
|
|
186
|
+
expect(fragment.branches[0].condition).toBe('Every minute');
|
|
187
|
+
expect(fragment.branches[0].events).toHaveLength(1);
|
|
188
|
+
expect(fragment.branches[0].events[0].kind).toBe('message');
|
|
189
|
+
});
|
|
190
|
+
it('should parse alt/else fragment', () => {
|
|
191
|
+
const input = `
|
|
192
|
+
sequenceDiagram
|
|
193
|
+
alt Success
|
|
194
|
+
A->B: OK
|
|
195
|
+
else Failure
|
|
196
|
+
A->B: Error
|
|
197
|
+
end
|
|
198
|
+
`;
|
|
199
|
+
const ast = parse(input);
|
|
200
|
+
const fragment = ast.events[0];
|
|
201
|
+
expect(fragment.kind).toBe('fragment');
|
|
202
|
+
expect(fragment.operator).toBe('alt');
|
|
203
|
+
expect(fragment.branches).toHaveLength(2);
|
|
204
|
+
expect(fragment.branches[0].condition).toBe('Success');
|
|
205
|
+
expect(fragment.branches[0].events).toHaveLength(1);
|
|
206
|
+
expect(fragment.branches[1].condition).toBe('Failure');
|
|
207
|
+
expect(fragment.branches[1].events).toHaveLength(1);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
describe('Box/Group Definitions', () => {
|
|
211
|
+
it('should parse box definition with color and name', () => {
|
|
212
|
+
const code = `
|
|
213
|
+
sequenceDiagram
|
|
214
|
+
box "Frontend" #eef
|
|
215
|
+
participant A
|
|
216
|
+
participant B
|
|
217
|
+
end
|
|
218
|
+
box Backend
|
|
219
|
+
participant C
|
|
220
|
+
end
|
|
221
|
+
A->>C: Request
|
|
222
|
+
C-->>A: Response
|
|
223
|
+
`;
|
|
224
|
+
const ast = parse(code);
|
|
225
|
+
// Verify participants
|
|
226
|
+
expect(ast.participants).toHaveLength(3);
|
|
227
|
+
expect(ast.participants.map(p => p.id)).toEqual(['A', 'B', 'C']);
|
|
228
|
+
// Verify groups
|
|
229
|
+
expect(ast.groups).toHaveLength(2);
|
|
230
|
+
const group1 = ast.groups[0];
|
|
231
|
+
expect(group1.name).toContain('Frontend');
|
|
232
|
+
expect(group1.participantIds).toEqual(['A', 'B']);
|
|
233
|
+
const group2 = ast.groups[1];
|
|
234
|
+
expect(group2.name).toBe('Backend');
|
|
235
|
+
expect(group2.participantIds).toEqual(['C']);
|
|
236
|
+
});
|
|
237
|
+
it('should handle messages inside box block', () => {
|
|
238
|
+
const code = `
|
|
239
|
+
sequenceDiagram
|
|
240
|
+
participant U
|
|
241
|
+
box App
|
|
242
|
+
participant A
|
|
243
|
+
U->>A: inside box
|
|
244
|
+
end
|
|
245
|
+
`;
|
|
246
|
+
const ast = parse(code);
|
|
247
|
+
expect(ast.events).toHaveLength(1);
|
|
248
|
+
expect(ast.events[0].kind).toBe('message');
|
|
249
|
+
expect(ast.groups[0].participantIds).toEqual(['A']);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type TokenType = 'SEQUENCE_DIAGRAM' | 'NEWLINE' | 'EOF' | 'PARTICIPANT' | 'COLON' | 'IDENTIFIER' | 'STRING' | 'ARROW' | 'LOOP' | 'ALT' | 'OPT' | 'END' | 'ELSE' | 'UNKNOWN' | 'AS' | 'ACTOR' | 'TITLE' | 'NOTE' | 'LEFT' | 'RIGHT' | 'OVER' | 'OF' | 'ACTIVATE' | 'DEACTIVATE' | 'PLUS' | 'MINUS' | 'COMMA' | 'BOX';
|
|
2
|
+
export interface Token {
|
|
3
|
+
type: TokenType;
|
|
4
|
+
literal: string;
|
|
5
|
+
line: number;
|
|
6
|
+
column: number;
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { EventNode, FragmentNode } from '../../ast';
|
|
2
|
+
import { Walker } from '../traverse/walker';
|
|
3
|
+
/**
|
|
4
|
+
* Cleaner that removes empty fragments and branches from the AST.
|
|
5
|
+
* This runs after filters to ensure the AST structure remains valid/clean.
|
|
6
|
+
*/
|
|
7
|
+
export declare class StructureCleaner extends Walker {
|
|
8
|
+
protected visitFragment(node: FragmentNode): EventNode[];
|
|
9
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Walker } from '../traverse/walker';
|
|
2
|
+
/**
|
|
3
|
+
* Cleaner that removes empty fragments and branches from the AST.
|
|
4
|
+
* This runs after filters to ensure the AST structure remains valid/clean.
|
|
5
|
+
*/
|
|
6
|
+
export class StructureCleaner extends Walker {
|
|
7
|
+
visitFragment(node) {
|
|
8
|
+
// 1. Let super map the branches (recurse)
|
|
9
|
+
const result = super.visitFragment(node);
|
|
10
|
+
// Should contain exactly one node (the fragment with mapped branches)
|
|
11
|
+
if (result.length === 0)
|
|
12
|
+
return [];
|
|
13
|
+
const fragment = result[0];
|
|
14
|
+
// 2. Filter out empty branches
|
|
15
|
+
// A branch is "empty" if it has 0 events.
|
|
16
|
+
const validBranches = fragment.branches.filter(b => b.events.length > 0);
|
|
17
|
+
// 3. Evaluation
|
|
18
|
+
if (validBranches.length === 0) {
|
|
19
|
+
return []; // Remove fragment entirely
|
|
20
|
+
}
|
|
21
|
+
// Return updated fragment
|
|
22
|
+
return [{
|
|
23
|
+
...fragment,
|
|
24
|
+
branches: validBranches
|
|
25
|
+
}];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { StructureCleaner } from './prune-empty';
|
|
3
|
+
describe('StructureCleaner', () => {
|
|
4
|
+
const createAst = (events) => ({
|
|
5
|
+
kind: 'root',
|
|
6
|
+
meta: { version: '1', source: 'unknown' },
|
|
7
|
+
participants: [],
|
|
8
|
+
groups: [],
|
|
9
|
+
events
|
|
10
|
+
});
|
|
11
|
+
const msg = {
|
|
12
|
+
kind: 'message',
|
|
13
|
+
id: 'm1',
|
|
14
|
+
text: 'M',
|
|
15
|
+
from: 'A',
|
|
16
|
+
to: 'B',
|
|
17
|
+
type: 'sync',
|
|
18
|
+
style: { line: 'solid', head: 'arrow' }
|
|
19
|
+
};
|
|
20
|
+
it('removes fragment if it has no branches', () => {
|
|
21
|
+
const fragment = {
|
|
22
|
+
kind: 'fragment', id: 'f1', operator: 'alt',
|
|
23
|
+
branches: [] // Empty
|
|
24
|
+
};
|
|
25
|
+
const root = createAst([fragment, msg]);
|
|
26
|
+
const result = new StructureCleaner().transform(root);
|
|
27
|
+
expect(result.events).toHaveLength(1);
|
|
28
|
+
expect(result.events[0].id).toBe('m1');
|
|
29
|
+
});
|
|
30
|
+
it('removes branch if it has no events', () => {
|
|
31
|
+
const fragment = {
|
|
32
|
+
kind: 'fragment', id: 'f1', operator: 'alt',
|
|
33
|
+
branches: [
|
|
34
|
+
{ id: 'b1', condition: 'C1', events: [] }, // Empty
|
|
35
|
+
{ id: 'b2', condition: 'C2', events: [msg] } // Should keep
|
|
36
|
+
]
|
|
37
|
+
};
|
|
38
|
+
const root = createAst([fragment]);
|
|
39
|
+
const result = new StructureCleaner().transform(root);
|
|
40
|
+
const resFrag = result.events[0];
|
|
41
|
+
expect(resFrag.branches).toHaveLength(1);
|
|
42
|
+
expect(resFrag.branches[0].id).toBe('b2');
|
|
43
|
+
});
|
|
44
|
+
it('removes fragment if all branches are empty', () => {
|
|
45
|
+
const fragment = {
|
|
46
|
+
kind: 'fragment', id: 'f1', operator: 'alt',
|
|
47
|
+
branches: [
|
|
48
|
+
{ id: 'b1', condition: 'C1', events: [] }
|
|
49
|
+
]
|
|
50
|
+
};
|
|
51
|
+
const root = createAst([fragment]);
|
|
52
|
+
const result = new StructureCleaner().transform(root);
|
|
53
|
+
expect(result.events).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
it('recursively cleans nested fragments', () => {
|
|
56
|
+
const innerFrag = {
|
|
57
|
+
kind: 'fragment', id: 'inner', operator: 'loop',
|
|
58
|
+
branches: [{ id: 'ib', condition: 'loop', events: [] }]
|
|
59
|
+
};
|
|
60
|
+
const outerFrag = {
|
|
61
|
+
kind: 'fragment', id: 'outer', operator: 'opt',
|
|
62
|
+
branches: [{ id: 'ob', condition: 'opt', events: [innerFrag] }]
|
|
63
|
+
};
|
|
64
|
+
const root = createAst([outerFrag, msg]);
|
|
65
|
+
const result = new StructureCleaner().transform(root);
|
|
66
|
+
expect(result.events).toHaveLength(1);
|
|
67
|
+
expect(result.events[0].id).toBe('m1');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export class UnusedCleaner {
|
|
2
|
+
transform(root) {
|
|
3
|
+
const usedIds = this.collectUsedParticipants(root.events);
|
|
4
|
+
// Filter participants
|
|
5
|
+
const activeParticipants = root.participants.filter(p => usedIds.has(p.id));
|
|
6
|
+
// Filter groups (remove if empty, clean up member lists)
|
|
7
|
+
const activeGroups = root.groups.map(g => ({
|
|
8
|
+
...g,
|
|
9
|
+
participantIds: g.participantIds.filter(pid => usedIds.has(pid))
|
|
10
|
+
})).filter(g => g.participantIds.length > 0);
|
|
11
|
+
return {
|
|
12
|
+
...root,
|
|
13
|
+
participants: activeParticipants,
|
|
14
|
+
groups: activeGroups
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
collectUsedParticipants(events) {
|
|
18
|
+
const used = new Set();
|
|
19
|
+
function scan(nodes) {
|
|
20
|
+
for (const node of nodes) {
|
|
21
|
+
switch (node.kind) {
|
|
22
|
+
case 'message':
|
|
23
|
+
if (node.from)
|
|
24
|
+
used.add(node.from);
|
|
25
|
+
if (node.to)
|
|
26
|
+
used.add(node.to);
|
|
27
|
+
break;
|
|
28
|
+
case 'fragment':
|
|
29
|
+
for (const branch of node.branches) {
|
|
30
|
+
scan(branch.events);
|
|
31
|
+
}
|
|
32
|
+
break;
|
|
33
|
+
case 'activation':
|
|
34
|
+
used.add(node.participantId);
|
|
35
|
+
break;
|
|
36
|
+
case 'note':
|
|
37
|
+
node.participantIds.forEach(id => used.add(id));
|
|
38
|
+
break;
|
|
39
|
+
case 'ref':
|
|
40
|
+
node.participantIds.forEach(id => used.add(id));
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
scan(events);
|
|
46
|
+
return used;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { UnusedCleaner } from './prune-unused';
|
|
3
|
+
describe('UnusedCleaner', () => {
|
|
4
|
+
const createAst = (participants, events) => ({
|
|
5
|
+
kind: 'root',
|
|
6
|
+
meta: { version: '1', source: 'unknown' },
|
|
7
|
+
participants,
|
|
8
|
+
groups: [],
|
|
9
|
+
events
|
|
10
|
+
});
|
|
11
|
+
it('removes participants that are not referenced in messages', () => {
|
|
12
|
+
const p1 = { id: 'A', name: 'Alice', type: 'participant' };
|
|
13
|
+
const p2 = { id: 'B', name: 'Bob', type: 'participant' };
|
|
14
|
+
const p3 = { id: 'C', name: 'Charlie', type: 'participant' }; // Unused
|
|
15
|
+
const msg = {
|
|
16
|
+
kind: 'message', id: 'm1', text: 'Hi', from: 'A', to: 'B',
|
|
17
|
+
type: 'sync', style: { line: 'solid', head: 'arrow' }
|
|
18
|
+
};
|
|
19
|
+
const root = createAst([p1, p2, p3], [msg]);
|
|
20
|
+
const result = new UnusedCleaner().transform(root);
|
|
21
|
+
expect(result.participants).toHaveLength(2);
|
|
22
|
+
expect(result.participants.map(p => p.id)).toEqual(['A', 'B']);
|
|
23
|
+
});
|
|
24
|
+
it('keeps participants referenced in activations', () => {
|
|
25
|
+
const p1 = { id: 'A', name: 'Alice', type: 'participant' };
|
|
26
|
+
const act = { kind: 'activation', participantId: 'A', action: 'activate' };
|
|
27
|
+
const root = createAst([p1], [act]);
|
|
28
|
+
const result = new UnusedCleaner().transform(root);
|
|
29
|
+
expect(result.participants).toHaveLength(1);
|
|
30
|
+
});
|
|
31
|
+
it('keeps participants referenced in notes', () => {
|
|
32
|
+
const p1 = { id: 'A', name: 'Alice', type: 'participant' };
|
|
33
|
+
const note = { kind: 'note', id: 'n1', text: 'Note', position: 'over', participantIds: ['A'] };
|
|
34
|
+
const root = createAst([p1], [note]);
|
|
35
|
+
const result = new UnusedCleaner().transform(root);
|
|
36
|
+
expect(result.participants).toHaveLength(1);
|
|
37
|
+
});
|
|
38
|
+
it('cleans up groups removing unused members', () => {
|
|
39
|
+
const p1 = { id: 'A', name: 'Alice', type: 'participant' };
|
|
40
|
+
const p2 = { id: 'B', name: 'Bob', type: 'participant' }; // Unused
|
|
41
|
+
const group = { kind: 'group', id: 'g1', name: 'G', participantIds: ['A', 'B'] };
|
|
42
|
+
const msg = {
|
|
43
|
+
kind: 'message', id: 'm1', text: 'Hi', from: 'A', to: 'A',
|
|
44
|
+
type: 'sync', style: { line: 'solid', head: 'arrow' }
|
|
45
|
+
};
|
|
46
|
+
const root = {
|
|
47
|
+
kind: 'root',
|
|
48
|
+
meta: { version: '1', source: 'unknown' },
|
|
49
|
+
participants: [p1, p2],
|
|
50
|
+
groups: [group],
|
|
51
|
+
events: [msg]
|
|
52
|
+
};
|
|
53
|
+
const result = new UnusedCleaner().transform(root);
|
|
54
|
+
expect(result.participants.map(p => p.id)).toEqual(['A']);
|
|
55
|
+
expect(result.groups).toHaveLength(1);
|
|
56
|
+
expect(result.groups[0].participantIds).toEqual(['A']);
|
|
57
|
+
});
|
|
58
|
+
it('removes group entirely if becomes empty', () => {
|
|
59
|
+
const p2 = { id: 'B', name: 'Bob', type: 'participant' }; // Unused
|
|
60
|
+
const group = { kind: 'group', id: 'g1', name: 'G', participantIds: ['B'] };
|
|
61
|
+
const root = {
|
|
62
|
+
kind: 'root',
|
|
63
|
+
meta: { version: '1', source: 'unknown' },
|
|
64
|
+
participants: [p2],
|
|
65
|
+
groups: [group],
|
|
66
|
+
events: []
|
|
67
|
+
};
|
|
68
|
+
const result = new UnusedCleaner().transform(root);
|
|
69
|
+
expect(result.groups).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { EventNode, PolagramRoot } from '../../ast';
|
|
2
|
+
import { Walker } from '../traverse/walker';
|
|
3
|
+
import { FocusLayer } from '../types';
|
|
4
|
+
export declare class FocusFilter extends Walker {
|
|
5
|
+
private layer;
|
|
6
|
+
private matcher;
|
|
7
|
+
private targetParticipantIds;
|
|
8
|
+
constructor(layer: FocusLayer);
|
|
9
|
+
transform(root: PolagramRoot): PolagramRoot;
|
|
10
|
+
private resolveTargetParticipants;
|
|
11
|
+
protected visitEvent(node: EventNode): EventNode[];
|
|
12
|
+
private isRelatedToParticipant;
|
|
13
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Matcher } from '../selector/matcher';
|
|
2
|
+
import { Walker } from '../traverse/walker';
|
|
3
|
+
export class FocusFilter extends Walker {
|
|
4
|
+
constructor(layer) {
|
|
5
|
+
super();
|
|
6
|
+
Object.defineProperty(this, "layer", {
|
|
7
|
+
enumerable: true,
|
|
8
|
+
configurable: true,
|
|
9
|
+
writable: true,
|
|
10
|
+
value: layer
|
|
11
|
+
});
|
|
12
|
+
Object.defineProperty(this, "matcher", {
|
|
13
|
+
enumerable: true,
|
|
14
|
+
configurable: true,
|
|
15
|
+
writable: true,
|
|
16
|
+
value: new Matcher()
|
|
17
|
+
});
|
|
18
|
+
Object.defineProperty(this, "targetParticipantIds", {
|
|
19
|
+
enumerable: true,
|
|
20
|
+
configurable: true,
|
|
21
|
+
writable: true,
|
|
22
|
+
value: new Set()
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
transform(root) {
|
|
26
|
+
this.resolveTargetParticipants(root);
|
|
27
|
+
return super.transform(root);
|
|
28
|
+
}
|
|
29
|
+
resolveTargetParticipants(root) {
|
|
30
|
+
this.targetParticipantIds.clear();
|
|
31
|
+
const selector = this.layer.selector;
|
|
32
|
+
root.participants.forEach(p => {
|
|
33
|
+
if (this.matcher.matchParticipant(p, selector)) {
|
|
34
|
+
this.targetParticipantIds.add(p.id);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
visitEvent(node) {
|
|
39
|
+
// Only apply filtering if we have targets.
|
|
40
|
+
// If targets is empty, strictly speaking we should hide everything?
|
|
41
|
+
// Yes, "Focus on X" means "Show ONLY X". If X is not found, show nothing.
|
|
42
|
+
if (node.kind === 'message') {
|
|
43
|
+
const msg = node;
|
|
44
|
+
if (!this.isRelatedToParticipant(msg)) {
|
|
45
|
+
return []; // Drop irrelevant message
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (node.kind === 'note') {
|
|
49
|
+
const note = node;
|
|
50
|
+
const isRelated = note.participantIds.some(pid => this.targetParticipantIds.has(pid));
|
|
51
|
+
if (!isRelated)
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
if (node.kind === 'activation') {
|
|
55
|
+
const activation = node;
|
|
56
|
+
if (!this.targetParticipantIds.has(activation.participantId)) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return super.visitEvent(node);
|
|
61
|
+
}
|
|
62
|
+
// Helper to check if message involves the participant from selector
|
|
63
|
+
isRelatedToParticipant(msg) {
|
|
64
|
+
// Check if from or to matches any targeted participant ID
|
|
65
|
+
if (msg.from && this.targetParticipantIds.has(msg.from))
|
|
66
|
+
return true;
|
|
67
|
+
if (msg.to && this.targetParticipantIds.has(msg.to))
|
|
68
|
+
return true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { FocusFilter } from './focus';
|
|
3
|
+
describe('FocusFilter', () => {
|
|
4
|
+
const createAst = (events) => ({
|
|
5
|
+
kind: 'root',
|
|
6
|
+
meta: { version: '1', source: 'unknown' },
|
|
7
|
+
participants: [
|
|
8
|
+
{ id: 'A', name: 'A', type: 'participant' },
|
|
9
|
+
{ id: 'B', name: 'B', type: 'participant' },
|
|
10
|
+
{ id: 'C', name: 'C', type: 'participant' },
|
|
11
|
+
{ id: 'D', name: 'D', type: 'participant' }
|
|
12
|
+
],
|
|
13
|
+
groups: [],
|
|
14
|
+
events
|
|
15
|
+
});
|
|
16
|
+
const msgAB = { kind: 'message', id: 'm1', text: 'A->B', from: 'A', to: 'B', type: 'sync', style: { line: 'solid', head: 'arrow' } };
|
|
17
|
+
const msgBC = { kind: 'message', id: 'm2', text: 'B->C', from: 'B', to: 'C', type: 'sync', style: { line: 'solid', head: 'arrow' } };
|
|
18
|
+
const msgCD = { kind: 'message', id: 'm3', text: 'C->D', from: 'C', to: 'D', type: 'sync', style: { line: 'solid', head: 'arrow' } };
|
|
19
|
+
it('removes messages not related to focused participant', () => {
|
|
20
|
+
const root = createAst([msgAB, msgBC, msgCD]);
|
|
21
|
+
const layer = {
|
|
22
|
+
action: 'focus',
|
|
23
|
+
selector: { kind: 'participant', name: 'A' }
|
|
24
|
+
};
|
|
25
|
+
// Expect: msgAB (involves A), others removed
|
|
26
|
+
const result = new FocusFilter(layer).transform(root);
|
|
27
|
+
expect(result.events).toHaveLength(1);
|
|
28
|
+
expect(result.events[0].id).toBe('m1');
|
|
29
|
+
});
|
|
30
|
+
it('removes messages even inside fragments, but keeps structure', () => {
|
|
31
|
+
const fragment = {
|
|
32
|
+
kind: 'fragment', id: 'f1', operator: 'alt',
|
|
33
|
+
branches: [
|
|
34
|
+
{ id: 'b1', condition: 'related', events: [msgAB] },
|
|
35
|
+
{ id: 'b2', condition: 'irrelevant', events: [msgCD] }
|
|
36
|
+
]
|
|
37
|
+
};
|
|
38
|
+
const root = createAst([fragment]);
|
|
39
|
+
const layer = {
|
|
40
|
+
action: 'focus',
|
|
41
|
+
selector: { kind: 'participant', name: 'A' }
|
|
42
|
+
};
|
|
43
|
+
const result = new FocusFilter(layer).transform(root);
|
|
44
|
+
const resFrag = result.events[0];
|
|
45
|
+
// b1 should keep event
|
|
46
|
+
expect(resFrag.branches[0].events).toHaveLength(1);
|
|
47
|
+
// b2 should have event removed, BUT branch itself remains (empty)
|
|
48
|
+
expect(resFrag.branches[1].events).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { EventNode, PolagramRoot } from '../../ast';
|
|
2
|
+
import { Walker } from '../traverse/walker';
|
|
3
|
+
import { RemoveLayer } from '../types';
|
|
4
|
+
export declare class RemoveFilter extends Walker {
|
|
5
|
+
private layer;
|
|
6
|
+
private matcher;
|
|
7
|
+
private removedParticipantIds;
|
|
8
|
+
constructor(layer: RemoveLayer);
|
|
9
|
+
transform(root: PolagramRoot): PolagramRoot;
|
|
10
|
+
protected visitEvent(node: EventNode): EventNode[];
|
|
11
|
+
private isRelatedToRemovedParticipant;
|
|
12
|
+
}
|