@orcalang/orca-lang 0.1.18 → 0.1.21
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/compiler/dt-compiler.d.ts +26 -0
- package/dist/compiler/dt-compiler.d.ts.map +1 -0
- package/dist/compiler/dt-compiler.js +387 -0
- package/dist/compiler/dt-compiler.js.map +1 -0
- package/dist/health-check.d.ts +3 -0
- package/dist/health-check.d.ts.map +1 -0
- package/dist/health-check.js +235 -0
- package/dist/health-check.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/parser/ast-to-markdown.d.ts.map +1 -1
- package/dist/parser/ast-to-markdown.js +3 -1
- package/dist/parser/ast-to-markdown.js.map +1 -1
- package/dist/parser/ast.d.ts +3 -0
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/dt-ast.d.ts +43 -0
- package/dist/parser/dt-ast.d.ts.map +1 -0
- package/dist/parser/dt-ast.js +3 -0
- package/dist/parser/dt-ast.js.map +1 -0
- package/dist/parser/dt-parser.d.ts +40 -0
- package/dist/parser/dt-parser.d.ts.map +1 -0
- package/dist/parser/dt-parser.js +240 -0
- package/dist/parser/dt-parser.js.map +1 -0
- package/dist/parser/markdown-parser.d.ts.map +1 -1
- package/dist/parser/markdown-parser.js +43 -8
- package/dist/parser/markdown-parser.js.map +1 -1
- package/dist/skills.d.ts +50 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +508 -21
- package/dist/skills.js.map +1 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +49 -0
- package/dist/tools.js.map +1 -1
- package/dist/verifier/dt-verifier.d.ts +32 -0
- package/dist/verifier/dt-verifier.d.ts.map +1 -0
- package/dist/verifier/dt-verifier.js +830 -0
- package/dist/verifier/dt-verifier.js.map +1 -0
- package/dist/verifier/properties.d.ts +4 -0
- package/dist/verifier/properties.d.ts.map +1 -1
- package/dist/verifier/properties.js +56 -20
- package/dist/verifier/properties.js.map +1 -1
- package/dist/verifier/structural.d.ts.map +1 -1
- package/dist/verifier/structural.js +6 -1
- package/dist/verifier/structural.js.map +1 -1
- package/dist/verifier/types.d.ts +4 -0
- package/dist/verifier/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/compiler/dt-compiler.ts +454 -0
- package/src/health-check.ts +273 -0
- package/src/index.ts +5 -1
- package/src/parser/ast-to-markdown.ts +2 -1
- package/src/parser/ast.ts +4 -0
- package/src/parser/dt-ast.ts +40 -0
- package/src/parser/dt-parser.ts +289 -0
- package/src/parser/markdown-parser.ts +43 -8
- package/src/skills.ts +591 -22
- package/src/tools.ts +53 -0
- package/src/verifier/dt-verifier.ts +928 -0
- package/src/verifier/properties.ts +78 -23
- package/src/verifier/structural.ts +5 -1
- package/src/verifier/types.ts +4 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Health Check Runner - Dogfooding Orca to check itself
|
|
3
|
+
// Uses sequential execution pattern modeled after Orca state machine semantics
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { join, dirname } from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const REPO_ROOT = join(__dirname, '../../..');
|
|
11
|
+
|
|
12
|
+
interface StepResult {
|
|
13
|
+
name: string;
|
|
14
|
+
status: 'pending' | 'success' | 'failed';
|
|
15
|
+
output: string;
|
|
16
|
+
duration: number;
|
|
17
|
+
passed?: number;
|
|
18
|
+
skipped?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface HealthReport {
|
|
22
|
+
steps: StepResult[];
|
|
23
|
+
startTime: number;
|
|
24
|
+
endTime: number;
|
|
25
|
+
totalPassed: number;
|
|
26
|
+
totalFailed: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function runCommand(cmd: string, cwd: string, timeout = 120000): { status: number; stdout: string; stderr: string } {
|
|
30
|
+
try {
|
|
31
|
+
const stdout = execSync(cmd, { cwd, encoding: 'utf-8', timeout, maxBuffer: 10 * 1024 * 1024 });
|
|
32
|
+
return { status: 0, stdout: stdout as string, stderr: '' };
|
|
33
|
+
} catch (e: any) {
|
|
34
|
+
return {
|
|
35
|
+
status: e.status || 1,
|
|
36
|
+
stdout: e.stdout?.toString() || '',
|
|
37
|
+
stderr: e.stderr?.toString() || e.message || ''
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function runHealthCheck(): Promise<HealthReport> {
|
|
43
|
+
const report: HealthReport = {
|
|
44
|
+
steps: [],
|
|
45
|
+
startTime: Date.now(),
|
|
46
|
+
endTime: 0,
|
|
47
|
+
totalPassed: 0,
|
|
48
|
+
totalFailed: 0
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ── Step 1: Build ──────────────────────────────────────────────
|
|
52
|
+
{
|
|
53
|
+
const step: StepResult = { name: 'build', status: 'pending', output: '', duration: 0 };
|
|
54
|
+
const start = Date.now();
|
|
55
|
+
console.log('━━━ Building TypeScript packages ━━━');
|
|
56
|
+
const result = runCommand('pnpm run build', REPO_ROOT);
|
|
57
|
+
step.duration = Date.now() - start;
|
|
58
|
+
step.status = result.status === 0 ? 'success' : 'failed';
|
|
59
|
+
step.output = result.status === 0 ? 'Build succeeded' : result.stderr || result.stdout;
|
|
60
|
+
report.steps.push(step);
|
|
61
|
+
console.log(` ${step.status === 'success' ? '✓' : '✗'} Build ${step.status} (${step.duration}ms)\n`);
|
|
62
|
+
if (step.status === 'failed') {
|
|
63
|
+
report.endTime = Date.now();
|
|
64
|
+
return report;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Step 2: orca-lang tests ─────────────────────────────────────
|
|
69
|
+
{
|
|
70
|
+
const step: StepResult = { name: 'test:lang', status: 'pending', output: '', duration: 0 };
|
|
71
|
+
const start = Date.now();
|
|
72
|
+
console.log('━━━ Running orca-lang tests ━━━');
|
|
73
|
+
const result = runCommand('cd packages/orca-lang && pnpm test 2>&1', REPO_ROOT);
|
|
74
|
+
step.duration = Date.now() - start;
|
|
75
|
+
const match = result.stdout.match(/(\d+) passed.*?(\d+) skipped/);
|
|
76
|
+
step.passed = match ? parseInt(match[1]) : 0;
|
|
77
|
+
step.skipped = match ? parseInt(match[2]) : 0;
|
|
78
|
+
step.status = result.status === 0 && step.passed >= 180 ? 'success' : 'failed';
|
|
79
|
+
step.output = `${step.passed} passed, ${step.skipped} skipped`;
|
|
80
|
+
report.steps.push(step);
|
|
81
|
+
console.log(` ${step.status === 'success' ? '✓' : '✗'} ${step.passed} tests passed, ${step.skipped} skipped (${step.duration}ms)\n`);
|
|
82
|
+
if (step.status === 'failed') {
|
|
83
|
+
report.endTime = Date.now();
|
|
84
|
+
return report;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Step 3: demo-ts smoke test ─────────────────────────────────
|
|
89
|
+
{
|
|
90
|
+
const step: StepResult = { name: 'demo-ts:test', status: 'pending', output: '', duration: 0 };
|
|
91
|
+
const start = Date.now();
|
|
92
|
+
console.log('━━━ Running demo-ts smoke test ━━━');
|
|
93
|
+
const result = runCommand('pnpm --filter orca-demo-ts run test 2>&1', REPO_ROOT);
|
|
94
|
+
step.duration = Date.now() - start;
|
|
95
|
+
step.status = result.status === 0 ? 'success' : 'failed';
|
|
96
|
+
step.output = result.status === 0 ? 'Tests passed' : result.stdout;
|
|
97
|
+
report.steps.push(step);
|
|
98
|
+
console.log(` ${step.status === 'success' ? '✓' : '✗'} demo-ts tests ${step.status} (${step.duration}ms)\n`);
|
|
99
|
+
if (step.status === 'failed') {
|
|
100
|
+
report.endTime = Date.now();
|
|
101
|
+
return report;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Step 4: demo-ts ticket demo ─────────────────────────────────
|
|
106
|
+
{
|
|
107
|
+
const step: StepResult = { name: 'demo-ts:ticket', status: 'pending', output: '', duration: 0 };
|
|
108
|
+
const start = Date.now();
|
|
109
|
+
console.log('━━━ Running demo-ts ticket demo ━━━');
|
|
110
|
+
const result = runCommand('pnpm -w run run:demo-ts:ticket 2>&1', REPO_ROOT);
|
|
111
|
+
step.duration = Date.now() - start;
|
|
112
|
+
step.status = result.status === 0 ? 'success' : 'failed';
|
|
113
|
+
step.output = result.status === 0 ? 'Demo passed' : result.stdout;
|
|
114
|
+
report.steps.push(step);
|
|
115
|
+
console.log(` ${step.status === 'success' ? '✓' : '✗'} Support Ticket demo ${step.status} (${step.duration}ms)\n`);
|
|
116
|
+
if (step.status === 'failed') {
|
|
117
|
+
report.endTime = Date.now();
|
|
118
|
+
return report;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Step 5: demo-python ─────────────────────────────────────────
|
|
123
|
+
{
|
|
124
|
+
const step: StepResult = { name: 'demo-python', status: 'pending', output: '', duration: 0 };
|
|
125
|
+
const start = Date.now();
|
|
126
|
+
console.log('━━━ Running demo-python ━━━');
|
|
127
|
+
const result = runCommand('.venv/bin/python packages/demo-python/demo.py 2>&1', REPO_ROOT);
|
|
128
|
+
step.duration = Date.now() - start;
|
|
129
|
+
step.status = result.status === 0 ? 'success' : 'failed';
|
|
130
|
+
step.output = result.status === 0 ? 'Demo passed' : result.stdout;
|
|
131
|
+
report.steps.push(step);
|
|
132
|
+
console.log(` ${step.status === 'success' ? '✓' : '✗'} demo-python ${step.status} (${step.duration}ms)\n`);
|
|
133
|
+
if (step.status === 'failed') {
|
|
134
|
+
report.endTime = Date.now();
|
|
135
|
+
return report;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Step 6: demo-go ─────────────────────────────────────────────
|
|
140
|
+
{
|
|
141
|
+
const step: StepResult = { name: 'demo-go (trip)', status: 'pending', output: '', duration: 0 };
|
|
142
|
+
const start = Date.now();
|
|
143
|
+
console.log('━━━ Running demo-go (trip) ━━━');
|
|
144
|
+
const result = runCommand('pnpm run test:demo-go 2>&1', REPO_ROOT);
|
|
145
|
+
step.duration = Date.now() - start;
|
|
146
|
+
step.status = result.status === 0 ? 'success' : 'failed';
|
|
147
|
+
step.output = result.status === 0 ? 'Demo passed' : result.stdout;
|
|
148
|
+
report.steps.push(step);
|
|
149
|
+
console.log(` ${step.status === 'success' ? '✓' : '✗'} demo-go (trip) ${step.status} (${step.duration}ms)\n`);
|
|
150
|
+
if (step.status === 'failed') {
|
|
151
|
+
report.endTime = Date.now();
|
|
152
|
+
return report;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Step 6b: demo-go:loan ──────────────────────────────────────
|
|
157
|
+
{
|
|
158
|
+
const step: StepResult = { name: 'demo-go:loan', status: 'pending', output: '', duration: 0 };
|
|
159
|
+
const start = Date.now();
|
|
160
|
+
|
|
161
|
+
// Build the loan binary first (not pre-built like 'trip')
|
|
162
|
+
console.log('━━━ Building demo-go:loan binary ━━━');
|
|
163
|
+
const buildResult = runCommand('cd packages/demo-go && go build -o loan ./cmd/loan 2>&1', REPO_ROOT);
|
|
164
|
+
if (buildResult.status !== 0) {
|
|
165
|
+
step.duration = Date.now() - start;
|
|
166
|
+
step.status = 'failed';
|
|
167
|
+
step.output = buildResult.stderr || buildResult.stdout;
|
|
168
|
+
report.steps.push(step);
|
|
169
|
+
console.log(` ✗ Build failed (${step.duration}ms): ${step.output}\n`);
|
|
170
|
+
report.endTime = Date.now();
|
|
171
|
+
return report;
|
|
172
|
+
}
|
|
173
|
+
console.log(` ✓ Build succeeded (${Date.now() - start}ms)\n`);
|
|
174
|
+
|
|
175
|
+
console.log('━━━ Running demo-go:loan (loan application) ━━━');
|
|
176
|
+
const runStart = Date.now();
|
|
177
|
+
const result = runCommand('cd packages/demo-go && ./loan 2>&1', REPO_ROOT);
|
|
178
|
+
step.duration = Date.now() - runStart;
|
|
179
|
+
step.status = result.status === 0 ? 'success' : 'failed';
|
|
180
|
+
step.output = result.status === 0 ? 'Demo passed' : result.stdout;
|
|
181
|
+
report.steps.push(step);
|
|
182
|
+
console.log(` ${step.status === 'success' ? '✓' : '✗'} demo-go:loan (loan) ${step.status} (${step.duration}ms)\n`);
|
|
183
|
+
if (step.status === 'failed') {
|
|
184
|
+
report.endTime = Date.now();
|
|
185
|
+
return report;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Step 8: demo-nanolab tests ─────────────────────────────────
|
|
190
|
+
{
|
|
191
|
+
const step: StepResult = { name: 'demo-nanolab:test', status: 'pending', output: '', duration: 0 };
|
|
192
|
+
const start = Date.now();
|
|
193
|
+
console.log('━━━ Running demo-nanolab tests ━━━');
|
|
194
|
+
const result = runCommand('pnpm run test:demo-nanolab 2>&1', REPO_ROOT);
|
|
195
|
+
step.duration = Date.now() - start;
|
|
196
|
+
step.status = result.status === 0 ? 'success' : 'failed';
|
|
197
|
+
step.output = result.status === 0 ? 'Tests passed' : result.stdout;
|
|
198
|
+
report.steps.push(step);
|
|
199
|
+
console.log(` ${step.status === 'success' ? '✓' : '✗'} demo-nanolab tests ${step.status} (${step.duration}ms)\n`);
|
|
200
|
+
if (step.status === 'failed') {
|
|
201
|
+
report.endTime = Date.now();
|
|
202
|
+
return report;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Step 8: Example verification ────────────────────────────────
|
|
207
|
+
{
|
|
208
|
+
const step: StepResult = { name: 'examples:verify', status: 'pending', output: '', duration: 0 };
|
|
209
|
+
const start = Date.now();
|
|
210
|
+
console.log('━━━ Verifying example files ━━━');
|
|
211
|
+
const result = runCommand('cd packages/orca-lang && pnpm exec tsx src/index.ts verify examples/simple-toggle.orca.md examples/payment-processor.orca.md 2>&1', REPO_ROOT);
|
|
212
|
+
step.duration = Date.now() - start;
|
|
213
|
+
step.status = result.status === 0 ? 'success' : 'failed';
|
|
214
|
+
step.output = result.status === 0 ? 'Examples verified' : result.stdout;
|
|
215
|
+
report.steps.push(step);
|
|
216
|
+
console.log(` ${step.status === 'success' ? '✓' : '✗'} Examples verified ${step.status} (${step.duration}ms)\n`);
|
|
217
|
+
if (step.status === 'failed') {
|
|
218
|
+
report.endTime = Date.now();
|
|
219
|
+
return report;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
report.endTime = Date.now();
|
|
224
|
+
return report;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function printReport(report: HealthReport) {
|
|
228
|
+
console.log('╔════════════════════════════════════════════════════════════╗');
|
|
229
|
+
console.log('║ HEALTH CHECK REPORT ║');
|
|
230
|
+
console.log('╚════════════════════════════════════════════════════════════╝\n');
|
|
231
|
+
|
|
232
|
+
for (const step of report.steps) {
|
|
233
|
+
const icon = step.status === 'success' ? '✓' : step.status === 'failed' ? '✗' : '○';
|
|
234
|
+
const label = step.status === 'success' ? 'PASS' : step.status === 'failed' ? 'FAIL' : 'SKIP';
|
|
235
|
+
console.log(` ${icon} ${step.name.padEnd(20)} ${label.padEnd(6)} ${step.duration}ms${step.passed !== undefined ? ` (${step.passed} tests)` : ''}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const totalDuration = report.endTime - report.startTime;
|
|
239
|
+
report.totalPassed = report.steps.filter(s => s.status === 'success').length;
|
|
240
|
+
report.totalFailed = report.steps.filter(s => s.status === 'failed').length;
|
|
241
|
+
|
|
242
|
+
console.log(`\n Total time: ${totalDuration}ms`);
|
|
243
|
+
console.log(` Passed: ${report.totalPassed}/${report.totalPassed + report.totalFailed}\n`);
|
|
244
|
+
|
|
245
|
+
if (report.totalFailed === 0) {
|
|
246
|
+
console.log(' ✓ ALL CHECKS PASSED - Project is healthy\n');
|
|
247
|
+
} else {
|
|
248
|
+
console.log(` ✗ ${report.totalFailed} CHECK(S) FAILED\n`);
|
|
249
|
+
// Print failed step outputs
|
|
250
|
+
for (const step of report.steps.filter(s => s.status === 'failed')) {
|
|
251
|
+
console.log(` ── ${step.name} output ──`);
|
|
252
|
+
console.log(` ${step.output.split('\n').slice(0, 10).join('\n ')}`);
|
|
253
|
+
if (step.output.split('\n').length > 10) console.log(' ... (truncated)');
|
|
254
|
+
console.log();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function main() {
|
|
260
|
+
console.log('╔════════════════════════════════════════════════════════════╗');
|
|
261
|
+
console.log('║ ORCA HEALTH CHECK - SELF-HOSTED RUNNER ║');
|
|
262
|
+
console.log('╚════════════════════════════════════════════════════════════╝\n');
|
|
263
|
+
|
|
264
|
+
const report = await runHealthCheck();
|
|
265
|
+
printReport(report);
|
|
266
|
+
|
|
267
|
+
process.exit(report.totalFailed > 0 ? 1 : 0);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
main().catch(err => {
|
|
271
|
+
console.error('Health check failed with error:', err);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { checkStructural, analyzeFile } from './verifier/structural.js';
|
|
|
22
22
|
import { checkCompleteness } from './verifier/completeness.js';
|
|
23
23
|
import { checkDeterminism } from './verifier/determinism.js';
|
|
24
24
|
import { checkProperties } from './verifier/properties.js';
|
|
25
|
+
import { computeAlignedDTOutputDomain } from './verifier/dt-verifier.js';
|
|
25
26
|
import { compileToXState, compileToXStateMachine } from './compiler/xstate.js';
|
|
26
27
|
import { compileToMermaid } from './compiler/mermaid.js';
|
|
27
28
|
import { verifySkill, compileSkill, generateActionsSkill, refineSkill, generateOrcaSkill, generateOrcaMultiSkill, parseSkill, type SkillInput } from './skills.js';
|
|
@@ -232,7 +233,10 @@ async function verify(input: SkillInput, json: boolean = false): Promise<void> {
|
|
|
232
233
|
const structural = checkStructural(machine);
|
|
233
234
|
const completeness = checkCompleteness(machine);
|
|
234
235
|
const determinism = checkDeterminism(machine);
|
|
235
|
-
const
|
|
236
|
+
const dtOutputDomain = file.decisionTables.length > 0
|
|
237
|
+
? computeAlignedDTOutputDomain({ machines: [machine], decisionTables: file.decisionTables })
|
|
238
|
+
: undefined;
|
|
239
|
+
const properties = checkProperties(machine, { dtOutputDomain });
|
|
236
240
|
|
|
237
241
|
const allErrors = [
|
|
238
242
|
...structural.errors,
|
|
@@ -101,7 +101,8 @@ function emitStates(states: StateDef[], level: number): string[] {
|
|
|
101
101
|
if (state.onEntry) lines.push(`- on_entry: ${state.onEntry}`);
|
|
102
102
|
if (state.onExit) lines.push(`- on_exit: ${state.onExit}`);
|
|
103
103
|
if (state.timeout) lines.push(`- timeout: ${state.timeout.duration} -> ${state.timeout.target}`);
|
|
104
|
-
if (state.
|
|
104
|
+
if (state.ignoredAll) lines.push(`- ignore: *`);
|
|
105
|
+
else if (state.ignoredEvents?.length) lines.push(`- ignore: ${state.ignoredEvents.join(', ')}`);
|
|
105
106
|
if (state.onDone) lines.push(`- on_done: -> ${state.onDone}`);
|
|
106
107
|
|
|
107
108
|
if (state.parallel) {
|
package/src/parser/ast.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Orca AST Type Definitions
|
|
2
2
|
|
|
3
|
+
import { DecisionTableDef } from './dt-ast.js';
|
|
4
|
+
|
|
3
5
|
export interface Position {
|
|
4
6
|
line: number;
|
|
5
7
|
column: number;
|
|
@@ -118,6 +120,7 @@ export interface StateDef {
|
|
|
118
120
|
parent?: string; // Parent state name for hierarchical states
|
|
119
121
|
transitions?: Transition[];
|
|
120
122
|
ignoredEvents?: string[];
|
|
123
|
+
ignoredAll?: boolean; // true when "- ignore: *" — all unhandled events discarded
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
export interface Transition {
|
|
@@ -228,6 +231,7 @@ export interface MachineDef {
|
|
|
228
231
|
// Multi-machine file (for machine invocation)
|
|
229
232
|
export interface OrcaFile {
|
|
230
233
|
machines: MachineDef[];
|
|
234
|
+
decisionTables: DecisionTableDef[]; // NEW
|
|
231
235
|
}
|
|
232
236
|
|
|
233
237
|
export interface ParseResult {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Decision Table AST Types
|
|
2
|
+
|
|
3
|
+
export type ConditionType = 'bool' | 'enum' | 'int_range' | 'string';
|
|
4
|
+
|
|
5
|
+
export interface ConditionDef {
|
|
6
|
+
name: string;
|
|
7
|
+
type: ConditionType;
|
|
8
|
+
values: string[]; // enum values, or ['true','false'] for bool
|
|
9
|
+
range?: { min: number; max: number }; // for int_range
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ActionType = 'bool' | 'enum' | 'string';
|
|
13
|
+
|
|
14
|
+
export interface ActionOutputDef {
|
|
15
|
+
name: string;
|
|
16
|
+
type: ActionType;
|
|
17
|
+
description?: string;
|
|
18
|
+
values?: string[]; // valid values for enum type
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CellValue =
|
|
22
|
+
| { kind: 'any' } // "-" wildcard
|
|
23
|
+
| { kind: 'exact'; value: string } // exact match
|
|
24
|
+
| { kind: 'negated'; value: string } // "!value"
|
|
25
|
+
| { kind: 'set'; values: string[] }; // "a,b" (match any in set)
|
|
26
|
+
|
|
27
|
+
export interface Rule {
|
|
28
|
+
number?: number; // optional rule # from the # column
|
|
29
|
+
conditions: Map<string, CellValue>; // condition name → cell value
|
|
30
|
+
actions: Map<string, string>; // action name → output value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DecisionTableDef {
|
|
34
|
+
name: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
conditions: ConditionDef[];
|
|
37
|
+
actions: ActionOutputDef[];
|
|
38
|
+
rules: Rule[];
|
|
39
|
+
policy: 'first-match' | 'all-match'; // default: first-match
|
|
40
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// Decision Table Parser
|
|
2
|
+
// Parses ## conditions, ## actions, ## rules sections from markdown elements
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ConditionDef, ConditionType, ActionOutputDef, ActionType,
|
|
6
|
+
CellValue, Rule, DecisionTableDef,
|
|
7
|
+
} from './dt-ast.js';
|
|
8
|
+
|
|
9
|
+
interface MdHeading { kind: 'heading'; level: number; text: string; line: number }
|
|
10
|
+
interface MdTable { kind: 'table'; headers: string[]; rows: string[][]; line: number }
|
|
11
|
+
interface MdBulletList { kind: 'bullets'; items: string[]; line: number }
|
|
12
|
+
interface MdBlockquote { kind: 'blockquote'; text: string; line: number }
|
|
13
|
+
interface MdParagraph { kind: 'paragraph'; text: string; line: number }
|
|
14
|
+
interface MdSeparator { kind: 'separator'; line: number }
|
|
15
|
+
|
|
16
|
+
type MdElement = MdHeading | MdTable | MdBulletList | MdBlockquote | MdParagraph | MdSeparator;
|
|
17
|
+
|
|
18
|
+
function findColumnIndex(headers: string[], name: string): number {
|
|
19
|
+
return headers.findIndex(h => h.toLowerCase() === name.toLowerCase());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// --- Cell Value Parsing ---
|
|
23
|
+
|
|
24
|
+
function parseCellValue(text: string | undefined): CellValue {
|
|
25
|
+
if (!text || text.trim() === '' || text.trim() === '-') {
|
|
26
|
+
return { kind: 'any' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const trimmed = text.trim();
|
|
30
|
+
|
|
31
|
+
// Negated: !value (bare '!' with no value falls through to exact match)
|
|
32
|
+
if (trimmed.startsWith('!')) {
|
|
33
|
+
const negatedValue = trimmed.slice(1);
|
|
34
|
+
if (negatedValue) {
|
|
35
|
+
return { kind: 'negated', value: negatedValue };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Set: a,b,c
|
|
40
|
+
if (trimmed.includes(',')) {
|
|
41
|
+
return { kind: 'set', values: trimmed.split(',').map(v => v.trim()).filter(Boolean) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Exact match
|
|
45
|
+
return { kind: 'exact', value: trimmed };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Section Parsers ---
|
|
49
|
+
|
|
50
|
+
function parseConditionsTable(table: MdTable): ConditionDef[] {
|
|
51
|
+
const nameIdx = findColumnIndex(table.headers, 'name');
|
|
52
|
+
const typeIdx = findColumnIndex(table.headers, 'type');
|
|
53
|
+
const valuesIdx = findColumnIndex(table.headers, 'values');
|
|
54
|
+
|
|
55
|
+
return table.rows.map(row => {
|
|
56
|
+
const name = (nameIdx >= 0 ? row[nameIdx] : '') || '';
|
|
57
|
+
const typeStr = (typeIdx >= 0 ? row[typeIdx] : '') || 'string';
|
|
58
|
+
const valuesStr = (valuesIdx >= 0 ? row[valuesIdx] : '') || '';
|
|
59
|
+
|
|
60
|
+
const type: ConditionType = typeStr.trim() as ConditionType;
|
|
61
|
+
|
|
62
|
+
// Bool conditions auto-populate values ['true', 'false'] when Values column is empty
|
|
63
|
+
let values: string[];
|
|
64
|
+
let range: { min: number; max: number } | undefined;
|
|
65
|
+
|
|
66
|
+
if (type === 'bool') {
|
|
67
|
+
values = valuesStr.trim() ? valuesStr.split(',').map(v => v.trim()) : ['true', 'false'];
|
|
68
|
+
} else if (type === 'int_range') {
|
|
69
|
+
// Parse min..max format
|
|
70
|
+
const rangeMatch = valuesStr.match(/(\d+)\s*\.\.\s*(\d+)/);
|
|
71
|
+
if (rangeMatch) {
|
|
72
|
+
range = { min: parseInt(rangeMatch[1], 10), max: parseInt(rangeMatch[2], 10) };
|
|
73
|
+
values = [];
|
|
74
|
+
} else {
|
|
75
|
+
values = [];
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
// enum or string - comma-separated values
|
|
79
|
+
values = valuesStr ? valuesStr.split(',').map(v => v.trim()).filter(Boolean) : [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const condition: ConditionDef = { name: name.trim(), type, values };
|
|
83
|
+
if (range) condition.range = range;
|
|
84
|
+
|
|
85
|
+
return condition;
|
|
86
|
+
}).filter(c => c.name !== '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseActionsTable(table: MdTable): ActionOutputDef[] {
|
|
90
|
+
const nameIdx = findColumnIndex(table.headers, 'name');
|
|
91
|
+
const typeIdx = findColumnIndex(table.headers, 'type');
|
|
92
|
+
const descIdx = findColumnIndex(table.headers, 'description');
|
|
93
|
+
const valuesIdx = findColumnIndex(table.headers, 'values');
|
|
94
|
+
|
|
95
|
+
return table.rows.map(row => {
|
|
96
|
+
const name = (nameIdx >= 0 ? row[nameIdx] : '') || '';
|
|
97
|
+
const typeStr = (typeIdx >= 0 ? row[typeIdx] : '') || 'string';
|
|
98
|
+
const desc = descIdx >= 0 ? (row[descIdx] || '').trim() : '';
|
|
99
|
+
const valuesStr = valuesIdx >= 0 ? (row[valuesIdx] || '').trim() : '';
|
|
100
|
+
|
|
101
|
+
const type: ActionType = typeStr.trim() as ActionType;
|
|
102
|
+
const action: ActionOutputDef = {
|
|
103
|
+
name: name.trim(),
|
|
104
|
+
type,
|
|
105
|
+
description: desc || undefined,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (valuesStr && type === 'enum') {
|
|
109
|
+
action.values = valuesStr.split(',').map(v => v.trim()).filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return action;
|
|
113
|
+
}).filter(a => a.name !== '');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseRulesTable(
|
|
117
|
+
table: MdTable,
|
|
118
|
+
conditionNames: Set<string>,
|
|
119
|
+
actionNames: Set<string>
|
|
120
|
+
): { rules: Rule[]; warnings: string[] } {
|
|
121
|
+
const warnings: string[] = [];
|
|
122
|
+
const rules: Rule[] = [];
|
|
123
|
+
|
|
124
|
+
// Determine column types from headers
|
|
125
|
+
const columnTypes: Array<{ name: string; type: 'condition' | 'action' | 'skip' }> = [];
|
|
126
|
+
|
|
127
|
+
for (const header of table.headers) {
|
|
128
|
+
const trimmed = header.trim();
|
|
129
|
+
const lower = trimmed.toLowerCase();
|
|
130
|
+
|
|
131
|
+
if (lower === '#') {
|
|
132
|
+
columnTypes.push({ name: '#', type: 'skip' });
|
|
133
|
+
} else if (trimmed.startsWith('→ ') || trimmed.startsWith('-> ')) {
|
|
134
|
+
// Action column - strip prefix
|
|
135
|
+
const actionName = trimmed.replace(/^→\s*/, '').replace(/^->\s*/, '');
|
|
136
|
+
columnTypes.push({ name: actionName, type: 'action' });
|
|
137
|
+
|
|
138
|
+
if (!actionNames.has(actionName)) {
|
|
139
|
+
warnings.push(`Unknown action column: "${actionName}" (not declared in ## actions)`);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// Condition column
|
|
143
|
+
columnTypes.push({ name: trimmed, type: 'condition' });
|
|
144
|
+
|
|
145
|
+
if (!conditionNames.has(trimmed)) {
|
|
146
|
+
warnings.push(`Unknown condition column: "${trimmed}" (not declared in ## conditions)`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Parse each row
|
|
152
|
+
for (let rowIdx = 0; rowIdx < table.rows.length; rowIdx++) {
|
|
153
|
+
const row = table.rows[rowIdx];
|
|
154
|
+
const rule: Rule = {
|
|
155
|
+
conditions: new Map(),
|
|
156
|
+
actions: new Map(),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
for (let colIdx = 0; colIdx < columnTypes.length && colIdx < row.length; colIdx++) {
|
|
160
|
+
const col = columnTypes[colIdx];
|
|
161
|
+
const cell = row[colIdx];
|
|
162
|
+
|
|
163
|
+
if (col.type === 'skip') {
|
|
164
|
+
// Rule numbering column - parse as optional number
|
|
165
|
+
const num = parseInt(cell?.trim() || '', 10);
|
|
166
|
+
if (!isNaN(num)) {
|
|
167
|
+
rule.number = num;
|
|
168
|
+
}
|
|
169
|
+
} else if (col.type === 'condition') {
|
|
170
|
+
const cellValue = parseCellValue(cell);
|
|
171
|
+
rule.conditions.set(col.name, cellValue);
|
|
172
|
+
} else if (col.type === 'action') {
|
|
173
|
+
const value = cell?.trim() || '';
|
|
174
|
+
if (value) {
|
|
175
|
+
rule.actions.set(col.name, value);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
rules.push(rule);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { rules, warnings };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- Main Decision Table Parser ---
|
|
187
|
+
|
|
188
|
+
export function parseDecisionTable(elements: MdElement[]): { decisionTable: DecisionTableDef; warnings: string[] } {
|
|
189
|
+
let tableName = '';
|
|
190
|
+
let description: string | undefined;
|
|
191
|
+
let conditions: ConditionDef[] = [];
|
|
192
|
+
let actions: ActionOutputDef[] = [];
|
|
193
|
+
let rules: Rule[] = [];
|
|
194
|
+
let policy: 'first-match' | 'all-match' = 'first-match';
|
|
195
|
+
const warnings: string[] = [];
|
|
196
|
+
|
|
197
|
+
// Track current section
|
|
198
|
+
let currentSection: 'conditions' | 'actions' | 'rules' | null = null;
|
|
199
|
+
let currentTable: MdTable | null = null;
|
|
200
|
+
|
|
201
|
+
// Collect description from paragraphs before first ## heading
|
|
202
|
+
const descriptionParts: string[] = [];
|
|
203
|
+
|
|
204
|
+
for (let i = 0; i < elements.length; i++) {
|
|
205
|
+
const el = elements[i];
|
|
206
|
+
|
|
207
|
+
if (el.kind === 'heading') {
|
|
208
|
+
if (el.level === 1 && el.text.startsWith('decision_table ')) {
|
|
209
|
+
tableName = el.text.slice(15).trim();
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (el.level === 2) {
|
|
214
|
+
const sectionName = el.text.toLowerCase();
|
|
215
|
+
|
|
216
|
+
// Before processing first section, capture accumulated description
|
|
217
|
+
if (currentSection === null && descriptionParts.length > 0) {
|
|
218
|
+
description = descriptionParts.join(' ').trim();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (sectionName === 'conditions') {
|
|
222
|
+
currentSection = 'conditions';
|
|
223
|
+
} else if (sectionName === 'actions') {
|
|
224
|
+
currentSection = 'actions';
|
|
225
|
+
} else if (sectionName === 'rules') {
|
|
226
|
+
currentSection = 'rules';
|
|
227
|
+
} else if (sectionName === 'metadata') {
|
|
228
|
+
currentSection = null; // metadata doesn't have a table
|
|
229
|
+
} else {
|
|
230
|
+
currentSection = null;
|
|
231
|
+
}
|
|
232
|
+
currentTable = null;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Reset section on unknown headings
|
|
237
|
+
currentSection = null;
|
|
238
|
+
currentTable = null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Accumulate description from paragraphs and blockquotes before first section
|
|
242
|
+
if (currentSection === null) {
|
|
243
|
+
if (el.kind === 'paragraph') {
|
|
244
|
+
descriptionParts.push(el.text);
|
|
245
|
+
} else if (el.kind === 'blockquote') {
|
|
246
|
+
descriptionParts.push(el.text);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Capture tables for current section
|
|
251
|
+
if (el.kind === 'table' && currentSection !== null) {
|
|
252
|
+
if (currentSection === 'conditions') {
|
|
253
|
+
conditions = parseConditionsTable(el);
|
|
254
|
+
} else if (currentSection === 'actions') {
|
|
255
|
+
actions = parseActionsTable(el);
|
|
256
|
+
} else if (currentSection === 'rules') {
|
|
257
|
+
const result = parseRulesTable(el, new Set(conditions.map(c => c.name)), new Set(actions.map(a => a.name)));
|
|
258
|
+
rules = result.rules;
|
|
259
|
+
warnings.push(...result.warnings);
|
|
260
|
+
}
|
|
261
|
+
currentTable = el;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const decisionTable: DecisionTableDef = {
|
|
266
|
+
name: tableName,
|
|
267
|
+
description,
|
|
268
|
+
conditions,
|
|
269
|
+
actions,
|
|
270
|
+
rules,
|
|
271
|
+
policy,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return { decisionTable, warnings };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Parse a chunk of markdown elements that represents a single decision table
|
|
278
|
+
// Returns null if the chunk doesn't start with # decision_table
|
|
279
|
+
export function parseDecisionTableChunk(chunk: MdElement[]): DecisionTableDef | null {
|
|
280
|
+
if (chunk.length === 0) return null;
|
|
281
|
+
|
|
282
|
+
const firstHeading = chunk.find(el => el.kind === 'heading' && el.level === 1) as MdHeading | undefined;
|
|
283
|
+
if (!firstHeading || !firstHeading.text.startsWith('decision_table ')) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const { decisionTable } = parseDecisionTable(chunk);
|
|
288
|
+
return decisionTable;
|
|
289
|
+
}
|