@skillstech/thunderlang 0.1.7 → 0.3.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.
@@ -0,0 +1,98 @@
1
+ // C# target adapter. Emits a self-contained Program.cs that defines each decision as a typed
2
+ // static method (using the same exprToCSharp translator the codegen uses), calls every test case
3
+ // with typed literal arguments, and prints the results as one JSON line. It runs through a
4
+ // throwaway `dotnet` console project (scaffold + `dotnet run`), so a live C# run grades real
5
+ // compiled + executed code. Returns null (skip cleanly) when no usable .NET SDK exists.
6
+ import { spawnSync } from 'node:child_process';
7
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { tmpdir } from 'node:os';
10
+ import { exprToCSharp } from './expr.mjs';
11
+ import { inputNames, inferInputTypes, buildCases, parseLastJsonObject, cachedSmoke, strLit, litFor } from './target-util.mjs';
12
+
13
+ const CS_TYPE = { number: 'double', bool: 'bool', string: 'string' };
14
+
15
+ // Scaffold a bare console project in `dir`, overwrite Program.cs with `source`, and run it.
16
+ // Returns stdout, or null on any failure. A bare console app has no external packages, so the
17
+ // implicit restore is offline.
18
+ function dotnetRun(dir, source) {
19
+ const proj = join(dir, 'app');
20
+ const env = { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1' };
21
+ const mk = spawnSync('dotnet', ['new', 'console', '-o', proj], { encoding: 'utf8', timeout: 120000, env });
22
+ if (mk.status !== 0) return null;
23
+ writeFileSync(join(proj, 'Program.cs'), source);
24
+ const run = spawnSync('dotnet', ['run', '--project', proj, '-c', 'Release'], { encoding: 'utf8', timeout: 240000, env });
25
+ if (run.status !== 0) return null;
26
+ return run.stdout;
27
+ }
28
+
29
+ // The emitted C# source (also usable for display / `gen`-parity).
30
+ export function emitCSharpModule(ast, cases = buildCases(ast)) {
31
+ const L = [
32
+ 'using System;',
33
+ 'using System.Collections.Generic;',
34
+ 'using System.Linq;',
35
+ 'using System.Text;',
36
+ 'class ThunderTarget {',
37
+ ];
38
+ const typesByDec = {};
39
+ for (const d of ast.decisions || []) {
40
+ const names = inputNames(d);
41
+ const types = inferInputTypes(ast, d);
42
+ typesByDec[d.name] = types;
43
+ const params = names.map((n) => `${CS_TYPE[types[n]] || 'string'} ${n}`).join(', ');
44
+ L.push(` static string ${d.name}(${params}) {`);
45
+ for (const r of d.rules || []) {
46
+ let cond; try { cond = exprToCSharp(r.when, { inputs: names }); } catch { cond = 'false'; }
47
+ L.push(` if (${cond}) return ${strLit(r.result)};`);
48
+ }
49
+ L.push(` return ${d.default == null ? 'null' : strLit(d.default)};`, ' }');
50
+ }
51
+ L.push(' static string J(string s) {');
52
+ L.push(' if (s == null) return "null";');
53
+ L.push(' var b = new StringBuilder("\\"");');
54
+ L.push(' foreach (char c in s) {');
55
+ L.push(' if (c == \'\\\\\' || c == \'"\') b.Append(\'\\\\\').Append(c);');
56
+ L.push(' else if (c == \'\\n\') b.Append("\\\\n"); else b.Append(c); }');
57
+ L.push(' return b.Append(\'"\').ToString(); }');
58
+ L.push(' static void Main() {');
59
+ L.push(' var outp = new List<KeyValuePair<string,string>>();');
60
+ for (const c of cases) {
61
+ const dec = (ast.decisions || []).find((d) => d.name === c.fn);
62
+ if (!dec) continue;
63
+ const types = typesByDec[c.fn];
64
+ const argList = inputNames(dec).map((n) => litFor(types[n], c.given[n])).join(', ');
65
+ L.push(` outp.Add(new KeyValuePair<string,string>(${strLit(c.key)}, ${c.fn}(${argList})));`);
66
+ }
67
+ L.push(' var sb = new StringBuilder("{"); bool first = true;');
68
+ L.push(' foreach (var e in outp) {');
69
+ L.push(' if (!first) sb.Append(","); first = false;');
70
+ L.push(' sb.Append(J(e.Key)).Append(":").Append(J(e.Value)); }');
71
+ L.push(' Console.WriteLine(sb.Append("}").ToString());');
72
+ L.push(' }');
73
+ L.push('}');
74
+ return L.join('\n');
75
+ }
76
+
77
+ export function csharpAvailable() {
78
+ return cachedSmoke('csharp', () => {
79
+ const dir = mkdtempSync(join(tmpdir(), 'tl-cs-smoke-'));
80
+ try {
81
+ const src = 'using System; class ThunderTarget { static void Main(){ Console.WriteLine("{\\"ok\\":\\"1\\"}"); } }';
82
+ const out = dotnetRun(dir, src);
83
+ return !!out && /"ok"/.test(out);
84
+ } finally { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } }
85
+ });
86
+ }
87
+
88
+ // Run every decision test case through generated, compiled C#. Returns { "Test / case": actual }
89
+ // or null when no usable .NET SDK is present.
90
+ export function runCSharpTarget(ast) {
91
+ if (!csharpAvailable()) return null;
92
+ const cases = buildCases(ast);
93
+ const dir = mkdtempSync(join(tmpdir(), 'tl-cs-'));
94
+ try {
95
+ const out = dotnetRun(dir, emitCSharpModule(ast, cases));
96
+ return out == null ? null : parseLastJsonObject(out);
97
+ } finally { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } }
98
+ }
@@ -0,0 +1,87 @@
1
+ // Java target adapter. Emits a self-contained ThunderTarget.java that defines each decision as a
2
+ // typed static method (using the same exprToJava translator the codegen uses), calls every test
3
+ // case with typed literal arguments, and prints the results as one JSON line. It runs through the
4
+ // JDK 11+ single-file source launcher (`java ThunderTarget.java`) , no separate javac step , so a
5
+ // live Java run grades real executed code. Returns null (skip cleanly) when no usable JDK exists.
6
+ import { spawnSync } from 'node:child_process';
7
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { tmpdir } from 'node:os';
10
+ import { exprToJava } from './expr.mjs';
11
+ import { inputNames, inferInputTypes, buildCases, parseLastJsonObject, cachedSmoke, strLit, litFor } from './target-util.mjs';
12
+
13
+ const JAVA_TYPE = { number: 'double', bool: 'boolean', string: 'String' };
14
+
15
+ // The emitted Java source (also usable for display / `gen`-parity).
16
+ export function emitJavaModule(ast, cases = buildCases(ast)) {
17
+ const L = [
18
+ 'import java.util.LinkedHashMap;',
19
+ 'import java.util.Map;',
20
+ '',
21
+ 'public class ThunderTarget {',
22
+ ];
23
+ const typesByDec = {};
24
+ for (const d of ast.decisions || []) {
25
+ const names = inputNames(d);
26
+ const types = inferInputTypes(ast, d);
27
+ typesByDec[d.name] = types;
28
+ const params = names.map((n) => `${JAVA_TYPE[types[n]] || 'String'} ${n}`).join(', ');
29
+ L.push(` static String ${d.name}(${params}) {`);
30
+ for (const r of d.rules || []) {
31
+ let cond; try { cond = exprToJava(r.when, { inputs: names }); } catch { cond = 'false'; }
32
+ L.push(` if (${cond}) return ${strLit(r.result)};`);
33
+ }
34
+ L.push(` return ${d.default == null ? 'null' : strLit(d.default)};`, ' }');
35
+ }
36
+ L.push(' static String j(String s) {');
37
+ L.push(' if (s == null) return "null";');
38
+ L.push(' StringBuilder b = new StringBuilder("\\"");');
39
+ L.push(' for (int i = 0; i < s.length(); i++) { char c = s.charAt(i);');
40
+ L.push(' if (c == \'\\\\\' || c == \'"\') b.append(\'\\\\\').append(c);');
41
+ L.push(' else if (c == \'\\n\') b.append("\\\\n"); else b.append(c); }');
42
+ L.push(' return b.append(\'"\').toString(); }');
43
+ L.push(' public static void main(String[] args) {');
44
+ L.push(' Map<String,String> out = new LinkedHashMap<>();');
45
+ for (const c of cases) {
46
+ const dec = (ast.decisions || []).find((d) => d.name === c.fn);
47
+ if (!dec) continue;
48
+ const types = typesByDec[c.fn];
49
+ const argList = inputNames(dec).map((n) => litFor(types[n], c.given[n])).join(', ');
50
+ L.push(` out.put(${strLit(c.key)}, ${c.fn}(${argList}));`);
51
+ }
52
+ L.push(' StringBuilder sb = new StringBuilder("{"); boolean first = true;');
53
+ L.push(' for (Map.Entry<String,String> e : out.entrySet()) {');
54
+ L.push(' if (!first) sb.append(","); first = false;');
55
+ L.push(' sb.append(j(e.getKey())).append(":").append(j(e.getValue())); }');
56
+ L.push(' System.out.println(sb.append("}").toString());');
57
+ L.push(' }');
58
+ L.push('}');
59
+ return L.join('\n');
60
+ }
61
+
62
+ export function javaAvailable() {
63
+ return cachedSmoke('java', () => {
64
+ const dir = mkdtempSync(join(tmpdir(), 'tl-java-smoke-'));
65
+ try {
66
+ const f = join(dir, 'Hi.java');
67
+ writeFileSync(f, 'public class Hi { public static void main(String[] a){ System.out.println("{\\"ok\\":\\"1\\"}"); } }');
68
+ const r = spawnSync('java', [f], { encoding: 'utf8', timeout: 60000 });
69
+ return r.status === 0 && /"ok"/.test(r.stdout || '');
70
+ } finally { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } }
71
+ });
72
+ }
73
+
74
+ // Run every decision test case through generated, compiled Java. Returns { "Test / case": actual }
75
+ // or null when no usable JDK is present.
76
+ export function runJavaTarget(ast) {
77
+ if (!javaAvailable()) return null;
78
+ const cases = buildCases(ast);
79
+ const dir = mkdtempSync(join(tmpdir(), 'tl-java-'));
80
+ try {
81
+ const f = join(dir, 'ThunderTarget.java');
82
+ writeFileSync(f, emitJavaModule(ast, cases));
83
+ const r = spawnSync('java', [f], { encoding: 'utf8', timeout: 120000 });
84
+ if (r.status !== 0) return null;
85
+ return parseLastJsonObject(r.stdout);
86
+ } finally { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } }
87
+ }
@@ -0,0 +1,90 @@
1
+ // Python target adapter. Compiles each decision into an executable Python function using the
2
+ // SAME expression translator the codegen uses (exprToPython), then runs the test cases through a
3
+ // real `python3` child process and returns actual outputs. Like the TypeScript adapter, this makes
4
+ // conformance "grade a live target" rather than "grade fed results" — the generated Python is
5
+ // executed for real. Returns null (not a partial result) when python3 is unavailable, so callers
6
+ // can report the target as skipped rather than failing.
7
+ import { spawnSync } from 'node:child_process';
8
+ import { exprToPython } from './expr.mjs';
9
+
10
+ const inputNames = (dec) => (dec.inputs || []).map((i) => String(i).split(':')[0].trim()).filter(Boolean);
11
+
12
+ function coerceVal(v) {
13
+ if (v == null || typeof v !== 'string') return v;
14
+ const s = v.trim();
15
+ if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
16
+ if (s === 'true') return true;
17
+ if (s === 'false') return false;
18
+ return v;
19
+ }
20
+
21
+ // True when a runnable python3 interpreter is present on this machine.
22
+ export function pythonAvailable() {
23
+ try {
24
+ const r = spawnSync('python3', ['--version'], { encoding: 'utf8', timeout: 10000 });
25
+ return r.status === 0;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ // The emitted Python source (for display / gen-parity).
32
+ export function emitPythonModule(ast) {
33
+ const L = ['# Executable Python target compiled from the decision(s). Deterministic, no AI.'];
34
+ for (const d of ast.decisions || []) {
35
+ L.push(`def ${d.name}(${inputNames(d).join(', ')}):`);
36
+ for (const r of d.rules || []) {
37
+ let cond; try { cond = exprToPython(r.when, { inputs: inputNames(d) }); } catch { cond = 'False'; }
38
+ L.push(` if ${cond}: return ${JSON.stringify(r.result)}`);
39
+ }
40
+ L.push(` return ${d.default == null ? 'None' : JSON.stringify(d.default)}`);
41
+ L.push('');
42
+ }
43
+ return L.join('\n');
44
+ }
45
+
46
+ // Run every decision test case through generated Python. Returns { "Test / case": actual },
47
+ // or null if python3 is unavailable or the interpreter failed to run the program.
48
+ export function runPythonTarget(ast) {
49
+ if (!pythonAvailable()) return null;
50
+
51
+ // Build the list of calls to make, one per test case, in a stable order.
52
+ const cases = [];
53
+ for (const t of ast.tests || []) {
54
+ const dec = (ast.decisions || []).find((d) => d.name === t.name);
55
+ if (!dec) continue;
56
+ const names = inputNames(dec);
57
+ for (const c of t.cases || []) {
58
+ cases.push({
59
+ key: `${t.name} / ${c.name || 'case'}`,
60
+ fn: t.name,
61
+ args: names.map((n) => coerceVal((c.given || {})[n])),
62
+ });
63
+ }
64
+ }
65
+
66
+ // Emit a self-contained program: decision defs + a driver that runs each case and prints JSON.
67
+ // Cases (with already-coerced arg types) are handed in as a JSON literal parsed by json.loads,
68
+ // so Python receives real ints/floats/bools/strings rather than stringified values.
69
+ const program = [
70
+ 'import json',
71
+ emitPythonModule(ast),
72
+ `CASES = json.loads(${JSON.stringify(JSON.stringify(cases))})`,
73
+ 'out = {}',
74
+ 'for c in CASES:',
75
+ ' fn = globals().get(c["fn"])',
76
+ ' try:',
77
+ ' out[c["key"]] = fn(*c["args"]) if callable(fn) else None',
78
+ ' except Exception:',
79
+ ' out[c["key"]] = None',
80
+ 'print(json.dumps(out))',
81
+ ].join('\n');
82
+
83
+ const res = spawnSync('python3', ['-'], { input: program, encoding: 'utf8', timeout: 30000 });
84
+ if (res.status !== 0 || !res.stdout) return null;
85
+ try {
86
+ return JSON.parse(res.stdout.trim().split('\n').pop());
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
@@ -0,0 +1,66 @@
1
+ // TypeScript/JS target adapter. Compiles each decision into an executable function using the
2
+ // SAME expression translator the TypeScript codegen uses (exprToJs), then runs the test cases
3
+ // through the generated code and returns actual outputs. This turns conformance from "grade fed
4
+ // results" into "grade a live target": the generated implementation is executed for real.
5
+ // Type annotations are erased at runtime, so running the type-stripped JS is faithful to the TS.
6
+ import { exprToJs } from './expr.mjs';
7
+
8
+ const inputNames = (dec) => (dec.inputs || []).map((i) => String(i).split(':')[0].trim()).filter(Boolean);
9
+
10
+ function coerceVal(v) {
11
+ if (v == null || typeof v !== 'string') return v;
12
+ const s = v.trim();
13
+ if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
14
+ if (s === 'true') return true;
15
+ if (s === 'false') return false;
16
+ return v;
17
+ }
18
+
19
+ // The emitted source (for display / `gen`-parity); the runtime uses new Function on the body.
20
+ export function emitDecisionModule(ast) {
21
+ const L = ['// Executable target compiled from the decision(s). Deterministic, no AI.'];
22
+ for (const d of ast.decisions || []) {
23
+ L.push(`export function ${d.name}(${inputNames(d).join(', ')}) {`);
24
+ for (const r of d.rules || []) {
25
+ let cond; try { cond = exprToJs(r.when, { inputs: inputNames(d) }); } catch { cond = 'false'; }
26
+ L.push(` if (${cond}) return ${JSON.stringify(r.result)};`);
27
+ }
28
+ L.push(` return ${JSON.stringify(d.default ?? null)};`, '}');
29
+ }
30
+ return L.join('\n');
31
+ }
32
+
33
+ // Compile each decision to a runnable function.
34
+ function compileDecisionFns(ast) {
35
+ const fns = {};
36
+ for (const d of ast.decisions || []) {
37
+ const names = inputNames(d);
38
+ const body = [];
39
+ for (const r of d.rules || []) {
40
+ let cond; try { cond = exprToJs(r.when, { inputs: names }); } catch { cond = 'false'; }
41
+ body.push(`if (${cond}) return ${JSON.stringify(r.result)};`);
42
+ }
43
+ body.push(`return ${JSON.stringify(d.default ?? null)};`);
44
+ try { fns[d.name] = new Function(...names, body.join('\n')); } catch { fns[d.name] = null; }
45
+ }
46
+ return fns;
47
+ }
48
+
49
+ // Run every decision test case through the generated code. Returns { "Test / case": actual }.
50
+ export function runTypescriptTarget(ast) {
51
+ const fns = compileDecisionFns(ast);
52
+ const out = {};
53
+ for (const t of ast.tests || []) {
54
+ const dec = (ast.decisions || []).find((d) => d.name === t.name);
55
+ if (!dec) continue;
56
+ const names = inputNames(dec);
57
+ const fn = fns[t.name];
58
+ for (const c of t.cases || []) {
59
+ const key = `${t.name} / ${c.name || 'case'}`;
60
+ if (typeof fn !== 'function') { out[key] = null; continue; }
61
+ const args = names.map((n) => coerceVal((c.given || {})[n]));
62
+ try { out[key] = fn(...args); } catch { out[key] = null; }
63
+ }
64
+ }
65
+ return out;
66
+ }
@@ -0,0 +1,95 @@
1
+ // Shared helpers for the compiled target adapters (Python/C#/Java). The statically typed targets
2
+ // (C#, Java) need a declared type for every decision parameter, which ThunderLang source leaves
3
+ // implicit, so we infer each input's type from the test-case values that will actually be passed.
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ export const inputNames = (dec) => (dec.inputs || []).map((i) => String(i).split(':')[0].trim()).filter(Boolean);
7
+
8
+ export function coerceVal(v) {
9
+ if (v == null || typeof v !== 'string') return v;
10
+ const s = v.trim();
11
+ if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
12
+ if (s === 'true') return true;
13
+ if (s === 'false') return false;
14
+ return v;
15
+ }
16
+
17
+ // Every case-call we will emit, in a stable order: { key, fn, given: {name: coercedValue} }.
18
+ export function buildCases(ast) {
19
+ const cases = [];
20
+ for (const t of ast.tests || []) {
21
+ const dec = (ast.decisions || []).find((d) => d.name === t.name);
22
+ if (!dec) continue;
23
+ const names = inputNames(dec);
24
+ for (const c of t.cases || []) {
25
+ const given = {};
26
+ for (const n of names) given[n] = coerceVal((c.given || {})[n]);
27
+ cases.push({ key: `${t.name} / ${c.name || 'case'}`, fn: t.name, given });
28
+ }
29
+ }
30
+ return cases;
31
+ }
32
+
33
+ // Infer one type per input for a decision, scanning every case that targets it. Numeric usage
34
+ // wins (a relational comparison must compile), then boolean, else string. Falls back to 'string'.
35
+ export function inferInputTypes(ast, dec) {
36
+ const names = inputNames(dec);
37
+ const types = {};
38
+ for (const n of names) {
39
+ let t = 'string';
40
+ for (const test of (ast.tests || []).filter((x) => x.name === dec.name)) {
41
+ for (const c of test.cases || []) {
42
+ const raw = (c.given || {})[n];
43
+ if (raw == null) continue;
44
+ const cv = coerceVal(raw);
45
+ if (typeof cv === 'number') { t = 'number'; break; }
46
+ if (typeof cv === 'boolean' && t !== 'number') t = 'bool';
47
+ }
48
+ if (t === 'number') break;
49
+ }
50
+ types[n] = t;
51
+ }
52
+ return types;
53
+ }
54
+
55
+ // Pick the last line of stdout that parses as a JSON object , build/tool chatter may precede it.
56
+ export function parseLastJsonObject(stdout) {
57
+ if (!stdout) return null;
58
+ const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
59
+ for (let i = lines.length - 1; i >= 0; i--) {
60
+ if (!lines[i].startsWith('{')) continue;
61
+ try { return JSON.parse(lines[i]); } catch { /* keep scanning upward */ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ // Cached toolchain-availability smoke tests. Each actually exercises the exact invocation the
67
+ // adapter uses (single-file `java`, or scaffold + `dotnet run`) so a target is only reported
68
+ // runnable when it can genuinely compile and run, not merely because a binary is on PATH.
69
+ const _cache = {};
70
+ export function cachedSmoke(key, fn) {
71
+ if (key in _cache) return _cache[key];
72
+ let ok = false;
73
+ try { ok = !!fn(); } catch { ok = false; }
74
+ _cache[key] = ok;
75
+ return ok;
76
+ }
77
+
78
+ // A source literal for a value at an inferred type. JSON string escaping is valid in Java and C#
79
+ // string literals too, so we reuse JSON.stringify for the string case.
80
+ export const strLit = (s) => JSON.stringify(String(s));
81
+ export function litFor(type, v) {
82
+ if (v == null) return type === 'string' ? '""' : type === 'bool' ? 'false' : '0';
83
+ if (type === 'number') return String(v);
84
+ if (type === 'bool') return v === true || v === 'true' ? 'true' : 'false';
85
+ return strLit(v);
86
+ }
87
+
88
+ export function toolOnPath(cmd, args = ['--version']) {
89
+ try {
90
+ const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 15000 });
91
+ return r.status === 0;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
package/src/testing.mjs CHANGED
@@ -42,7 +42,7 @@ export function runTests(ast) {
42
42
  const inputs = coerce(c.given);
43
43
  const run = evaluateDecision(dec, inputs);
44
44
  const pass = c.expect == null || String(run.result) === String(c.expect);
45
- results.push({ ...label, kind: 'decision', expected: c.expect, actual: run.result, pass, ...(run.ok ? {} : { note: 'a condition failed to evaluate' }) });
45
+ results.push({ ...label, kind: 'decision', expected: c.expect, actual: run.result, matched: run.matched, pass, ...(run.ok ? {} : { note: 'a condition failed to evaluate' }) });
46
46
  } else if (lc) {
47
47
  const sim = simulateLifecycle(lc, c.events || []);
48
48
  const passState = c.expect == null || sim.finalState === c.expect;