@lokascript/framework 2.0.0 → 2.2.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/dist/aot/index.d.ts +1 -0
- package/dist/aot/index.d.ts.map +1 -1
- package/dist/aot/registry-bridge.d.ts +27 -0
- package/dist/aot/registry-bridge.d.ts.map +1 -0
- package/dist/api/create-dsl.d.ts.map +1 -1
- package/dist/api/dispatcher.d.ts +22 -0
- package/dist/api/dispatcher.d.ts.map +1 -1
- package/dist/api/domain-registry.d.ts +41 -0
- package/dist/api/domain-registry.d.ts.map +1 -1
- package/dist/api/index.js +1438 -21
- package/dist/api/index.js.map +1 -1
- package/dist/core/index.js +247 -10
- package/dist/core/index.js.map +1 -1
- package/dist/core/pattern-matching/index.js +74 -10
- package/dist/core/pattern-matching/index.js.map +1 -1
- package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
- package/dist/core/tokenization/index.js +133 -0
- package/dist/core/tokenization/index.js.map +1 -1
- package/dist/core/tokenization/morphology/base-normalizer.d.ts +94 -0
- package/dist/core/tokenization/morphology/base-normalizer.d.ts.map +1 -0
- package/dist/core/tokenization/morphology/index.d.ts +3 -1
- package/dist/core/tokenization/morphology/index.d.ts.map +1 -1
- package/dist/core/types.d.ts +81 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +40 -0
- package/dist/core/types.js.map +1 -1
- package/dist/feedback/confidence-gate.d.ts +28 -0
- package/dist/feedback/confidence-gate.d.ts.map +1 -0
- package/dist/feedback/feedback-formatter.d.ts +21 -0
- package/dist/feedback/feedback-formatter.d.ts.map +1 -0
- package/dist/feedback/index.d.ts +11 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/pattern-tracker.d.ts +47 -0
- package/dist/feedback/pattern-tracker.d.ts.map +1 -0
- package/dist/feedback/types.d.ts +113 -0
- package/dist/feedback/types.d.ts.map +1 -0
- package/dist/generation/index.js +2 -1
- package/dist/generation/index.js.map +1 -1
- package/dist/index.cjs +3264 -132
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3223 -132
- package/dist/index.js.map +1 -1
- package/dist/ir/explicit-parser.d.ts +86 -0
- package/dist/ir/explicit-parser.d.ts.map +1 -0
- package/dist/ir/explicit-renderer.d.ts +34 -0
- package/dist/ir/explicit-renderer.d.ts.map +1 -0
- package/dist/ir/from-interchange.d.ts +51 -0
- package/dist/ir/from-interchange.d.ts.map +1 -0
- package/dist/ir/index.d.ts +31 -0
- package/dist/ir/index.d.ts.map +1 -0
- package/dist/ir/index.js +1934 -0
- package/dist/ir/index.js.map +1 -0
- package/dist/ir/json-schema.d.ts +40 -0
- package/dist/ir/json-schema.d.ts.map +1 -0
- package/dist/ir/protocol-json.d.ts +73 -0
- package/dist/ir/protocol-json.d.ts.map +1 -0
- package/dist/ir/references.d.ts +19 -0
- package/dist/ir/references.d.ts.map +1 -0
- package/dist/ir/to-runtime-ast.d.ts +53 -0
- package/dist/ir/to-runtime-ast.d.ts.map +1 -0
- package/dist/ir/types.d.ts +151 -0
- package/dist/ir/types.d.ts.map +1 -0
- package/dist/parsing/index.js +74 -10
- package/dist/parsing/index.js.map +1 -1
- package/dist/prompts/index.d.ts +9 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/prompt-generator.d.ts +49 -0
- package/dist/prompts/prompt-generator.d.ts.map +1 -0
- package/dist/prompts/types.d.ts +64 -0
- package/dist/prompts/types.d.ts.map +1 -0
- package/dist/schema/command-schema.d.ts +17 -0
- package/dist/schema/command-schema.d.ts.map +1 -1
- package/dist/schema/index.js.map +1 -1
- package/dist/testing/index.js +8673 -8260
- package/dist/testing/index.js.map +1 -1
- package/dist/training/index.d.ts +11 -0
- package/dist/training/index.d.ts.map +1 -0
- package/dist/training/jsonl-writer.d.ts +42 -0
- package/dist/training/jsonl-writer.d.ts.map +1 -0
- package/dist/training/schema-synthesizer.d.ts +20 -0
- package/dist/training/schema-synthesizer.d.ts.map +1 -0
- package/dist/training/types.d.ts +73 -0
- package/dist/training/types.d.ts.map +1 -0
- package/package.json +5 -1
- package/src/aot/index.ts +1 -0
- package/src/aot/registry-bridge.test.ts +104 -0
- package/src/aot/registry-bridge.ts +66 -0
- package/src/api/create-dsl.test.ts +306 -0
- package/src/api/create-dsl.ts +21 -0
- package/src/api/dispatcher.test.ts +66 -0
- package/src/api/dispatcher.ts +77 -9
- package/src/api/domain-registry.ts +120 -4
- package/src/core/pattern-matching/pattern-matcher.test.ts +131 -0
- package/src/core/pattern-matching/pattern-matcher.ts +102 -19
- package/src/core/tokenization/morphology/base-normalizer.ts +249 -0
- package/src/core/tokenization/morphology/index.ts +3 -1
- package/src/core/types.ts +147 -2
- package/src/feedback/confidence-gate.test.ts +111 -0
- package/src/feedback/confidence-gate.ts +70 -0
- package/src/feedback/feedback-formatter.test.ts +198 -0
- package/src/feedback/feedback-formatter.ts +298 -0
- package/src/feedback/index.ts +24 -0
- package/src/feedback/pattern-tracker.test.ts +198 -0
- package/src/feedback/pattern-tracker.ts +140 -0
- package/src/feedback/types.ts +149 -0
- package/src/generation/pattern-generator.ts +2 -2
- package/src/index.ts +29 -0
- package/src/integration/llm-lse-integration.test.ts +457 -0
- package/src/ir/conformance.test.ts +120 -0
- package/src/ir/explicit-parser.test.ts +612 -0
- package/src/ir/explicit-parser.ts +793 -0
- package/src/ir/explicit-renderer.test.ts +229 -0
- package/src/ir/explicit-renderer.ts +198 -0
- package/src/ir/from-interchange.test.ts +853 -0
- package/src/ir/from-interchange.ts +594 -0
- package/src/ir/index.ts +77 -0
- package/src/ir/json-schema.test.ts +281 -0
- package/src/ir/json-schema.ts +299 -0
- package/src/ir/protocol-json.test.ts +1187 -0
- package/src/ir/protocol-json.ts +751 -0
- package/src/ir/references.test.ts +38 -0
- package/src/ir/references.ts +33 -0
- package/src/ir/round-trip.test.ts +557 -0
- package/src/ir/to-runtime-ast.ts +203 -0
- package/src/ir/types.ts +191 -0
- package/src/prompts/index.ts +15 -0
- package/src/prompts/prompt-generator.test.ts +386 -0
- package/src/prompts/prompt-generator.ts +531 -0
- package/src/prompts/types.ts +89 -0
- package/src/schema/command-schema.ts +19 -0
- package/src/training/index.ts +13 -0
- package/src/training/jsonl-writer.test.ts +138 -0
- package/src/training/jsonl-writer.ts +67 -0
- package/src/training/schema-synthesizer.test.ts +250 -0
- package/src/training/schema-synthesizer.ts +306 -0
- package/src/training/types.ts +103 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
parseExplicit,
|
|
4
|
+
parseCompound,
|
|
5
|
+
parseDocument,
|
|
6
|
+
isExplicitSyntax,
|
|
7
|
+
isCompoundSyntax,
|
|
8
|
+
isDocumentSyntax,
|
|
9
|
+
} from './explicit-parser';
|
|
10
|
+
import type { ParseExplicitOptions, SchemaLookup } from './types';
|
|
11
|
+
import { defineCommand, defineRole } from '../schema/command-schema';
|
|
12
|
+
import type { CompoundSemanticNode } from '../core/types';
|
|
13
|
+
|
|
14
|
+
describe('isExplicitSyntax', () => {
|
|
15
|
+
it('detects bracket syntax', () => {
|
|
16
|
+
expect(isExplicitSyntax('[toggle patient:.active]')).toBe(true);
|
|
17
|
+
expect(isExplicitSyntax(' [toggle patient:.active] ')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('rejects non-bracket syntax', () => {
|
|
21
|
+
expect(isExplicitSyntax('toggle .active')).toBe(false);
|
|
22
|
+
expect(isExplicitSyntax('[incomplete')).toBe(false);
|
|
23
|
+
expect(isExplicitSyntax('incomplete]')).toBe(false);
|
|
24
|
+
expect(isExplicitSyntax('')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('parseExplicit — without schema', () => {
|
|
29
|
+
it('parses basic command with roles', () => {
|
|
30
|
+
const node = parseExplicit('[toggle patient:.active destination:#button]');
|
|
31
|
+
expect(node.kind).toBe('command');
|
|
32
|
+
expect(node.action).toBe('toggle');
|
|
33
|
+
expect(node.roles.get('patient')).toEqual({
|
|
34
|
+
type: 'selector',
|
|
35
|
+
value: '.active',
|
|
36
|
+
selectorKind: 'class',
|
|
37
|
+
});
|
|
38
|
+
expect(node.roles.get('destination')).toEqual({
|
|
39
|
+
type: 'selector',
|
|
40
|
+
value: '#button',
|
|
41
|
+
selectorKind: 'id',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('parses selector values', () => {
|
|
46
|
+
const node = parseExplicit('[add patient:.highlight destination:#output]');
|
|
47
|
+
const patient = node.roles.get('patient');
|
|
48
|
+
expect(patient?.type).toBe('selector');
|
|
49
|
+
expect(patient && 'value' in patient && patient.value).toBe('.highlight');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('parses string literal values', () => {
|
|
53
|
+
const node = parseExplicit('[put patient:"hello world" destination:#output]');
|
|
54
|
+
const patient = node.roles.get('patient');
|
|
55
|
+
expect(patient).toEqual({ type: 'literal', value: 'hello world', dataType: 'string' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('parses boolean values', () => {
|
|
59
|
+
const node = parseExplicit('[set destination:myVar goal:true]');
|
|
60
|
+
expect(node.roles.get('goal')).toEqual({ type: 'literal', value: true, dataType: 'boolean' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('parses numeric values', () => {
|
|
64
|
+
const node = parseExplicit('[increment destination:count quantity:5]');
|
|
65
|
+
expect(node.roles.get('quantity')).toEqual({ type: 'literal', value: 5, dataType: 'number' });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('parses duration values', () => {
|
|
69
|
+
const node = parseExplicit('[wait duration:500ms]');
|
|
70
|
+
expect(node.roles.get('duration')).toEqual({
|
|
71
|
+
type: 'literal',
|
|
72
|
+
value: '500ms',
|
|
73
|
+
dataType: 'duration',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const node2 = parseExplicit('[wait duration:2s]');
|
|
77
|
+
expect(node2.roles.get('duration')).toEqual({
|
|
78
|
+
type: 'literal',
|
|
79
|
+
value: '2s',
|
|
80
|
+
dataType: 'duration',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('parses reference values', () => {
|
|
85
|
+
const node = parseExplicit('[add patient:.active destination:me]');
|
|
86
|
+
expect(node.roles.get('destination')).toEqual({ type: 'reference', value: 'me' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('parses plain string values (unquoted, non-special)', () => {
|
|
90
|
+
const node = parseExplicit('[fetch source:/api/users responseType:json]');
|
|
91
|
+
// Unquoted values get no dataType — only explicitly quoted values get dataType: 'string'
|
|
92
|
+
expect(node.roles.get('source')).toEqual({
|
|
93
|
+
type: 'literal',
|
|
94
|
+
value: '/api/users',
|
|
95
|
+
});
|
|
96
|
+
expect(node.roles.get('responseType')).toEqual({
|
|
97
|
+
type: 'literal',
|
|
98
|
+
value: 'json',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('parses event handlers with body', () => {
|
|
103
|
+
const node = parseExplicit('[on event:click body:[toggle patient:.active]]');
|
|
104
|
+
expect(node.kind).toBe('event-handler');
|
|
105
|
+
expect(node.action).toBe('on');
|
|
106
|
+
const eventNode = node as { body: unknown[] };
|
|
107
|
+
expect(eventNode.body).toHaveLength(1);
|
|
108
|
+
expect(eventNode.body[0]).toMatchObject({ kind: 'command', action: 'toggle' });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('accepts any role names without schema', () => {
|
|
112
|
+
// Custom domain roles should parse fine
|
|
113
|
+
const node = parseExplicit('[deploy destination:production source:main manner:rolling]');
|
|
114
|
+
expect(node.action).toBe('deploy');
|
|
115
|
+
expect(node.roles.get('destination')).toEqual({
|
|
116
|
+
type: 'literal',
|
|
117
|
+
value: 'production',
|
|
118
|
+
});
|
|
119
|
+
expect(node.roles.get('source')).toEqual({
|
|
120
|
+
type: 'literal',
|
|
121
|
+
value: 'main',
|
|
122
|
+
});
|
|
123
|
+
expect(node.roles.get('manner')).toEqual({
|
|
124
|
+
type: 'literal',
|
|
125
|
+
value: 'rolling',
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('sets sourceLanguage to explicit', () => {
|
|
130
|
+
const node = parseExplicit('[toggle patient:.active]');
|
|
131
|
+
expect(node.metadata?.sourceLanguage).toBe('explicit');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('parseExplicit — error handling', () => {
|
|
136
|
+
it('throws on missing brackets', () => {
|
|
137
|
+
expect(() => parseExplicit('toggle patient:.active')).toThrow('must be wrapped in brackets');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('throws on empty brackets', () => {
|
|
141
|
+
expect(() => parseExplicit('[]')).toThrow('Empty explicit statement');
|
|
142
|
+
expect(() => parseExplicit('[ ]')).toThrow('Empty explicit statement');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('throws on missing colon in role', () => {
|
|
146
|
+
expect(() => parseExplicit('[toggle active]')).toThrow('Expected role:value');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('throws on missing event role for event handler', () => {
|
|
150
|
+
expect(() => parseExplicit('[on body:[toggle patient:.active]]')).toThrow(
|
|
151
|
+
'requires event role'
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('parseExplicit — with SchemaLookup', () => {
|
|
157
|
+
const toggleSchema = defineCommand({
|
|
158
|
+
action: 'toggle',
|
|
159
|
+
roles: [
|
|
160
|
+
defineRole({ role: 'patient', required: true, expectedTypes: ['selector'] }),
|
|
161
|
+
defineRole({ role: 'destination', required: false, expectedTypes: ['selector'] }),
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const schemaLookup: SchemaLookup = {
|
|
166
|
+
getSchema(action: string) {
|
|
167
|
+
if (action === 'toggle') return toggleSchema;
|
|
168
|
+
return undefined;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const options: ParseExplicitOptions = { schemaLookup };
|
|
173
|
+
|
|
174
|
+
it('validates roles against schema', () => {
|
|
175
|
+
// Valid roles pass
|
|
176
|
+
const node = parseExplicit('[toggle patient:.active]', options);
|
|
177
|
+
expect(node.action).toBe('toggle');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('rejects unknown roles for known commands', () => {
|
|
181
|
+
expect(() => parseExplicit('[toggle patient:.active bogus:value]', options)).toThrow(
|
|
182
|
+
'Unknown role "bogus"'
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('enforces required roles', () => {
|
|
187
|
+
expect(() => parseExplicit('[toggle destination:#btn]', options)).toThrow(
|
|
188
|
+
'Missing required role "patient"'
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('allows any roles for unknown commands', () => {
|
|
193
|
+
// 'deploy' has no schema registered
|
|
194
|
+
const node = parseExplicit('[deploy target:prod]', options);
|
|
195
|
+
expect(node.action).toBe('deploy');
|
|
196
|
+
expect(node.roles.get('target')).toBeDefined();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('parseExplicit — boolean flags', () => {
|
|
201
|
+
it('parses +flag as enabled FlagValue', () => {
|
|
202
|
+
const node = parseExplicit('[column name:id +primary-key]');
|
|
203
|
+
expect(node.roles.get('primary-key')).toEqual({
|
|
204
|
+
type: 'flag',
|
|
205
|
+
name: 'primary-key',
|
|
206
|
+
enabled: true,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('parses ~flag as disabled FlagValue', () => {
|
|
211
|
+
const node = parseExplicit('[field name:email ~nullable]');
|
|
212
|
+
expect(node.roles.get('nullable')).toEqual({
|
|
213
|
+
type: 'flag',
|
|
214
|
+
name: 'nullable',
|
|
215
|
+
enabled: false,
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('parses flags alongside role:value pairs', () => {
|
|
220
|
+
const node = parseExplicit('[column name:id type:uuid +primary-key +not-null]');
|
|
221
|
+
expect(node.action).toBe('column');
|
|
222
|
+
expect(node.roles.get('name')).toEqual({ type: 'literal', value: 'id' });
|
|
223
|
+
expect(node.roles.get('type')).toEqual({ type: 'literal', value: 'uuid' });
|
|
224
|
+
expect(node.roles.get('primary-key')).toEqual({
|
|
225
|
+
type: 'flag',
|
|
226
|
+
name: 'primary-key',
|
|
227
|
+
enabled: true,
|
|
228
|
+
});
|
|
229
|
+
expect(node.roles.get('not-null')).toEqual({
|
|
230
|
+
type: 'flag',
|
|
231
|
+
name: 'not-null',
|
|
232
|
+
enabled: true,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('parses mixed + and ~ flags', () => {
|
|
237
|
+
const node = parseExplicit('[field name:email +required ~nullable]');
|
|
238
|
+
expect(node.roles.get('required')).toEqual({
|
|
239
|
+
type: 'flag',
|
|
240
|
+
name: 'required',
|
|
241
|
+
enabled: true,
|
|
242
|
+
});
|
|
243
|
+
expect(node.roles.get('nullable')).toEqual({
|
|
244
|
+
type: 'flag',
|
|
245
|
+
name: 'nullable',
|
|
246
|
+
enabled: false,
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('throws on empty flag name', () => {
|
|
251
|
+
expect(() => parseExplicit('[column name:id +]')).toThrow('Empty flag name');
|
|
252
|
+
expect(() => parseExplicit('[column name:id ~]')).toThrow('Empty flag name');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('validates flag names against schema', () => {
|
|
256
|
+
const schema = defineCommand({
|
|
257
|
+
action: 'column',
|
|
258
|
+
roles: [
|
|
259
|
+
defineRole({ role: 'name', required: true, expectedTypes: ['literal'] }),
|
|
260
|
+
defineRole({ role: 'primary-key', required: false, expectedTypes: ['flag'] }),
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
const schemaLookup: SchemaLookup = {
|
|
264
|
+
getSchema(action: string) {
|
|
265
|
+
if (action === 'column') return schema;
|
|
266
|
+
return undefined;
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
// Valid flag
|
|
270
|
+
const node = parseExplicit('[column name:id +primary-key]', { schemaLookup });
|
|
271
|
+
expect(node.roles.get('primary-key')).toBeDefined();
|
|
272
|
+
|
|
273
|
+
// Invalid flag
|
|
274
|
+
expect(() => parseExplicit('[column name:id +unknown-flag]', { schemaLookup })).toThrow(
|
|
275
|
+
'Unknown flag "unknown-flag"'
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('allows any flags for unknown commands (no schema)', () => {
|
|
280
|
+
const node = parseExplicit('[widget +draggable +resizable]');
|
|
281
|
+
expect(node.roles.get('draggable')).toEqual({
|
|
282
|
+
type: 'flag',
|
|
283
|
+
name: 'draggable',
|
|
284
|
+
enabled: true,
|
|
285
|
+
});
|
|
286
|
+
expect(node.roles.get('resizable')).toEqual({
|
|
287
|
+
type: 'flag',
|
|
288
|
+
name: 'resizable',
|
|
289
|
+
enabled: true,
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe('parseExplicit — custom reference set', () => {
|
|
295
|
+
it('uses custom references', () => {
|
|
296
|
+
const options: ParseExplicitOptions = {
|
|
297
|
+
referenceSet: new Set(['self', 'parent']),
|
|
298
|
+
};
|
|
299
|
+
const node = parseExplicit('[add patient:.active destination:self]', options);
|
|
300
|
+
expect(node.roles.get('destination')).toEqual({ type: 'reference', value: 'self' });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('does not recognize default references with custom set', () => {
|
|
304
|
+
const options: ParseExplicitOptions = {
|
|
305
|
+
referenceSet: new Set(['self']),
|
|
306
|
+
};
|
|
307
|
+
// 'me' is not in the custom set, so it becomes a literal (no dataType for unquoted values)
|
|
308
|
+
const node = parseExplicit('[add patient:.active destination:me]', options);
|
|
309
|
+
expect(node.roles.get('destination')).toEqual({
|
|
310
|
+
type: 'literal',
|
|
311
|
+
value: 'me',
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ===========================================================================
|
|
317
|
+
// collectDiagnostics mode (v1.2.1)
|
|
318
|
+
// ===========================================================================
|
|
319
|
+
|
|
320
|
+
describe('parseExplicit — collectDiagnostics', () => {
|
|
321
|
+
const toggleSchema = defineCommand({
|
|
322
|
+
action: 'toggle',
|
|
323
|
+
roles: [
|
|
324
|
+
defineRole({ role: 'patient', required: true, expectedTypes: ['selector'] }),
|
|
325
|
+
defineRole({ role: 'destination', required: false, expectedTypes: ['selector'] }),
|
|
326
|
+
],
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const schemaLookup: SchemaLookup = {
|
|
330
|
+
getSchema(action: string) {
|
|
331
|
+
if (action === 'toggle') return toggleSchema;
|
|
332
|
+
return undefined;
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const collectOpts: ParseExplicitOptions = { schemaLookup, collectDiagnostics: true };
|
|
337
|
+
|
|
338
|
+
it('returns node with diagnostics for unknown role', () => {
|
|
339
|
+
const node = parseExplicit('[toggle patient:.active badRole:foo]', collectOpts);
|
|
340
|
+
expect(node.action).toBe('toggle');
|
|
341
|
+
expect(node.diagnostics).toHaveLength(1);
|
|
342
|
+
expect(node.diagnostics![0].severity).toBe('error');
|
|
343
|
+
expect(node.diagnostics![0].code).toBe('UNKNOWN_ROLE');
|
|
344
|
+
expect(node.diagnostics![0].message).toContain('badRole');
|
|
345
|
+
expect(node.diagnostics![0].source).toBe('schema');
|
|
346
|
+
expect(node.diagnostics![0].suggestions).toContain('patient');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('returns node with diagnostics for missing required role', () => {
|
|
350
|
+
const node = parseExplicit('[toggle destination:.active]', collectOpts);
|
|
351
|
+
expect(node.action).toBe('toggle');
|
|
352
|
+
expect(node.diagnostics).toHaveLength(1);
|
|
353
|
+
expect(node.diagnostics![0].code).toBe('MISSING_REQUIRED_ROLE');
|
|
354
|
+
expect(node.diagnostics![0].message).toContain('patient');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('returns node with diagnostics for unknown flag', () => {
|
|
358
|
+
const node = parseExplicit('[toggle patient:.active +badFlag]', collectOpts);
|
|
359
|
+
expect(node.diagnostics).toHaveLength(1);
|
|
360
|
+
expect(node.diagnostics![0].code).toBe('UNKNOWN_FLAG');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('returns node with diagnostics for invalid role format', () => {
|
|
364
|
+
const node = parseExplicit('[toggle patient:.active noColonHere]', collectOpts);
|
|
365
|
+
expect(node.diagnostics).toHaveLength(1);
|
|
366
|
+
expect(node.diagnostics![0].code).toBe('INVALID_ROLE_FORMAT');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('collects multiple diagnostics', () => {
|
|
370
|
+
const node = parseExplicit('[toggle badRole:foo +badFlag]', collectOpts);
|
|
371
|
+
// unknown role + unknown flag + missing required role (patient)
|
|
372
|
+
expect(node.diagnostics!.length).toBeGreaterThanOrEqual(2);
|
|
373
|
+
const codes = node.diagnostics!.map(d => d.code);
|
|
374
|
+
expect(codes).toContain('UNKNOWN_ROLE');
|
|
375
|
+
expect(codes).toContain('UNKNOWN_FLAG');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('returns clean node when no errors (no diagnostics field)', () => {
|
|
379
|
+
const node = parseExplicit('[toggle patient:.active]', collectOpts);
|
|
380
|
+
expect(node.action).toBe('toggle');
|
|
381
|
+
expect(node.diagnostics).toBeUndefined();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('still throws on fatal errors (missing brackets)', () => {
|
|
385
|
+
expect(() => parseExplicit('toggle patient:.active', collectOpts)).toThrow(
|
|
386
|
+
'Explicit syntax must be wrapped in brackets'
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('still throws on empty input', () => {
|
|
391
|
+
expect(() => parseExplicit('[]', collectOpts)).toThrow('Empty explicit statement');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('returns diagnostics on event handler missing event role', () => {
|
|
395
|
+
const node = parseExplicit('[on body:[toggle patient:.active]]', collectOpts);
|
|
396
|
+
expect(node.diagnostics).toHaveLength(1);
|
|
397
|
+
expect(node.diagnostics![0].code).toBe('MISSING_EVENT_ROLE');
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// =============================================================================
|
|
402
|
+
// parseCompound
|
|
403
|
+
// =============================================================================
|
|
404
|
+
|
|
405
|
+
describe('parseCompound', () => {
|
|
406
|
+
it('passes through single bracket command to parseExplicit', () => {
|
|
407
|
+
const node = parseCompound('[toggle patient:.active]');
|
|
408
|
+
expect(node.kind).toBe('command');
|
|
409
|
+
expect(node.action).toBe('toggle');
|
|
410
|
+
expect(node.roles.get('patient')?.value).toBe('.active');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('parses two commands chained with then', () => {
|
|
414
|
+
const node = parseCompound('[add patient:.loading] then [fetch source:"/api/data"]');
|
|
415
|
+
expect(node.kind).toBe('compound');
|
|
416
|
+
const compound = node as CompoundSemanticNode;
|
|
417
|
+
expect(compound.chainType).toBe('then');
|
|
418
|
+
expect(compound.statements).toHaveLength(2);
|
|
419
|
+
expect(compound.statements[0].action).toBe('add');
|
|
420
|
+
expect(compound.statements[1].action).toBe('fetch');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('parses commands chained with and', () => {
|
|
424
|
+
const node = parseCompound('[add patient:.a] and [add patient:.b]');
|
|
425
|
+
expect(node.kind).toBe('compound');
|
|
426
|
+
expect((node as CompoundSemanticNode).chainType).toBe('and');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('parses commands chained with async', () => {
|
|
430
|
+
const node = parseCompound('[fetch source:/api/a] async [fetch source:/api/b]');
|
|
431
|
+
expect(node.kind).toBe('compound');
|
|
432
|
+
expect((node as CompoundSemanticNode).chainType).toBe('async');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('parses commands chained with sequential', () => {
|
|
436
|
+
const node = parseCompound('[add patient:.a] sequential [add patient:.b]');
|
|
437
|
+
expect(node.kind).toBe('compound');
|
|
438
|
+
expect((node as CompoundSemanticNode).chainType).toBe('sequential');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('parses commands chained with |> pipe', () => {
|
|
442
|
+
const node = parseCompound('[fetch source:/api/users] |> [put destination:#list]');
|
|
443
|
+
expect(node.kind).toBe('compound');
|
|
444
|
+
const compound = node as CompoundSemanticNode;
|
|
445
|
+
expect(compound.chainType).toBe('pipe');
|
|
446
|
+
expect(compound.statements).toHaveLength(2);
|
|
447
|
+
expect(compound.statements[0].action).toBe('fetch');
|
|
448
|
+
expect(compound.statements[1].action).toBe('put');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('handles three or more chained commands', () => {
|
|
452
|
+
const node = parseCompound(
|
|
453
|
+
'[add patient:.loading] then [fetch source:/api/data] then [remove patient:.loading]'
|
|
454
|
+
);
|
|
455
|
+
expect(node.kind).toBe('compound');
|
|
456
|
+
const compound = node as CompoundSemanticNode;
|
|
457
|
+
expect(compound.chainType).toBe('then');
|
|
458
|
+
expect(compound.statements).toHaveLength(3);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('uses first chain operator for mixed operators', () => {
|
|
462
|
+
const node = parseCompound('[add patient:.a] then [add patient:.b] and [add patient:.c]');
|
|
463
|
+
expect(node.kind).toBe('compound');
|
|
464
|
+
expect((node as CompoundSemanticNode).chainType).toBe('then');
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// =============================================================================
|
|
469
|
+
// Annotation parsing (via parseCompound)
|
|
470
|
+
// =============================================================================
|
|
471
|
+
|
|
472
|
+
describe('parseCompound — annotations', () => {
|
|
473
|
+
it('parses single annotation with value', () => {
|
|
474
|
+
const node = parseCompound('@timeout(5s) [fetch source:"/api/users"]');
|
|
475
|
+
expect(node.annotations).toHaveLength(1);
|
|
476
|
+
expect(node.annotations![0]).toEqual({ name: 'timeout', value: '5s' });
|
|
477
|
+
expect(node.action).toBe('fetch');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('parses annotation without value (flag-style)', () => {
|
|
481
|
+
const node = parseCompound('@deprecated [toggle patient:.active]');
|
|
482
|
+
expect(node.annotations).toHaveLength(1);
|
|
483
|
+
expect(node.annotations![0]).toEqual({ name: 'deprecated' });
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('parses multiple annotations preserving order', () => {
|
|
487
|
+
const node = parseCompound('@retry(3) @timeout(10s) @cache(60s) [fetch source:"/api/data"]');
|
|
488
|
+
expect(node.annotations).toHaveLength(3);
|
|
489
|
+
expect(node.annotations![0].name).toBe('retry');
|
|
490
|
+
expect(node.annotations![1].name).toBe('timeout');
|
|
491
|
+
expect(node.annotations![2].name).toBe('cache');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('handles annotation with path-like value', () => {
|
|
495
|
+
const node = parseCompound('@source(/api/v2/users) [fetch source:"/api"]');
|
|
496
|
+
expect(node.annotations![0]).toEqual({ name: 'source', value: '/api/v2/users' });
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('handles annotation with quoted string value', () => {
|
|
500
|
+
const node = parseCompound('@doc("Toggle active state on click") [toggle patient:.active]');
|
|
501
|
+
expect(node.annotations![0]).toEqual({
|
|
502
|
+
name: 'doc',
|
|
503
|
+
value: 'Toggle active state on click',
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('returns no annotations when none present', () => {
|
|
508
|
+
const node = parseCompound('[toggle patient:.active]');
|
|
509
|
+
expect(node.annotations).toBeUndefined();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('attaches annotations to compound nodes', () => {
|
|
513
|
+
const node = parseCompound(
|
|
514
|
+
'@permission(admin) [add patient:.loading] then [fetch source:"/api/users"]'
|
|
515
|
+
);
|
|
516
|
+
expect(node.kind).toBe('compound');
|
|
517
|
+
expect(node.annotations).toHaveLength(1);
|
|
518
|
+
expect(node.annotations![0]).toEqual({ name: 'permission', value: 'admin' });
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// =============================================================================
|
|
523
|
+
// parseDocument
|
|
524
|
+
// =============================================================================
|
|
525
|
+
|
|
526
|
+
describe('parseDocument', () => {
|
|
527
|
+
it('parses a simple document with version header', () => {
|
|
528
|
+
const envelope = parseDocument('#!lse 1.2\n[toggle patient:.active]\n[add patient:.highlight]');
|
|
529
|
+
expect(envelope.lseVersion).toBe('1.2');
|
|
530
|
+
expect(envelope.nodes).toHaveLength(2);
|
|
531
|
+
expect(envelope.nodes[0].action).toBe('toggle');
|
|
532
|
+
expect(envelope.nodes[1].action).toBe('add');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('defaults to version 1.0 when no header present', () => {
|
|
536
|
+
const envelope = parseDocument('[toggle patient:.active]');
|
|
537
|
+
expect(envelope.lseVersion).toBe('1.0');
|
|
538
|
+
expect(envelope.nodes).toHaveLength(1);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('skips // comments', () => {
|
|
542
|
+
const envelope = parseDocument(
|
|
543
|
+
'#!lse 1.2\n// This is a comment\n[toggle patient:.active]\n// Another comment'
|
|
544
|
+
);
|
|
545
|
+
expect(envelope.nodes).toHaveLength(1);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('skips # comments', () => {
|
|
549
|
+
const envelope = parseDocument('#!lse 1.2\n# This is a comment\n[toggle patient:.active]');
|
|
550
|
+
expect(envelope.nodes).toHaveLength(1);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('skips blank lines', () => {
|
|
554
|
+
const envelope = parseDocument(
|
|
555
|
+
'#!lse 1.2\n\n[toggle patient:.active]\n\n\n[add patient:.highlight]\n'
|
|
556
|
+
);
|
|
557
|
+
expect(envelope.nodes).toHaveLength(2);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('handles three-part version numbers', () => {
|
|
561
|
+
const envelope = parseDocument('#!lse 1.2.0\n[toggle patient:.active]');
|
|
562
|
+
expect(envelope.lseVersion).toBe('1.2.0');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('handles compound statements in document lines', () => {
|
|
566
|
+
const envelope = parseDocument(
|
|
567
|
+
'#!lse 1.2\n[add patient:.loading] then [fetch source:/api/data]'
|
|
568
|
+
);
|
|
569
|
+
expect(envelope.nodes).toHaveLength(1);
|
|
570
|
+
expect(envelope.nodes[0].kind).toBe('compound');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('handles annotated statements in document lines', () => {
|
|
574
|
+
const envelope = parseDocument('#!lse 1.2\n@timeout(5s) [fetch source:"/api/users"]');
|
|
575
|
+
expect(envelope.nodes).toHaveLength(1);
|
|
576
|
+
expect(envelope.nodes[0].annotations).toHaveLength(1);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// =============================================================================
|
|
581
|
+
// Detection helpers
|
|
582
|
+
// =============================================================================
|
|
583
|
+
|
|
584
|
+
describe('isCompoundSyntax', () => {
|
|
585
|
+
it('detects compound statements', () => {
|
|
586
|
+
expect(isCompoundSyntax('[a patient:x] then [b patient:y]')).toBe(true);
|
|
587
|
+
expect(isCompoundSyntax('[a patient:x] |> [b patient:y]')).toBe(true);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('rejects single commands', () => {
|
|
591
|
+
expect(isCompoundSyntax('[toggle patient:.active]')).toBe(false);
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
describe('isDocumentSyntax', () => {
|
|
596
|
+
it('detects multi-line input', () => {
|
|
597
|
+
expect(isDocumentSyntax('[a patient:x]\n[b patient:y]')).toBe(true);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('detects version header', () => {
|
|
601
|
+
expect(isDocumentSyntax('#!lse 1.2')).toBe(true);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('detects comment lines', () => {
|
|
605
|
+
expect(isDocumentSyntax('// a comment')).toBe(true);
|
|
606
|
+
expect(isDocumentSyntax('# a comment')).toBe(true);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('rejects single bracket commands', () => {
|
|
610
|
+
expect(isDocumentSyntax('[toggle patient:.active]')).toBe(false);
|
|
611
|
+
});
|
|
612
|
+
});
|