@skillstech/thunderlang 0.1.6 → 0.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/CHANGELOG.md +87 -47
- package/LICENSE +201 -21
- package/NOTICE +15 -0
- package/README.md +5 -8
- package/dist/core.cjs +123 -26
- package/dist/index.cjs +128 -31
- package/intent-graph.schema.json +227 -4
- package/package.json +12 -12
- package/src/ai-events.mjs +1 -1
- package/src/cli.mjs +759 -151
- package/src/codegen.mjs +1 -1
- package/src/compile.mjs +3 -3
- package/src/conformance.mjs +35 -0
- package/src/coverage.mjs +56 -0
- package/src/drift.mjs +3 -3
- package/src/emit.mjs +5 -3
- package/src/expr.mjs +11 -7
- package/src/focus.mjs +1 -1
- package/src/guard.mjs +3 -3
- package/src/intellisense.mjs +1 -1
- package/src/intent-graph.mjs +29 -0
- package/src/intent-schema.mjs +4 -4
- package/src/ledger.mjs +1 -1
- package/src/lift.mjs +2 -2
- package/src/lsp.mjs +1 -1
- package/src/mcp.mjs +1 -1
- package/src/mutate.mjs +46 -0
- package/src/parse.mjs +85 -6
- package/src/proof-schema.mjs +2 -2
- package/src/property.mjs +108 -0
- package/src/report.mjs +1 -1
- package/src/sarif.mjs +1 -1
- package/src/scan-queries.mjs +1 -1
- package/src/style.mjs +3 -3
- package/src/target-cs.mjs +98 -0
- package/src/target-java.mjs +87 -0
- package/src/target-py.mjs +90 -0
- package/src/target-ts.mjs +66 -0
- package/src/target-util.mjs +95 -0
- package/src/testing.mjs +1 -1
- package/src/twelve-factor.mjs +2 -2
package/src/property.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Property-based testing for ThunderLang decisions. Deterministic and seeded: for each
|
|
2
|
+
// `property`, generate many input cases that satisfy the `forAll` constraints, evaluate the
|
|
3
|
+
// named decision, and assert the `expect` clauses hold for every case. On failure, binary-shrink
|
|
4
|
+
// each numeric input toward its lower bound to report the smallest reproducible failure.
|
|
5
|
+
import { evaluateDecision } from './runtime.mjs';
|
|
6
|
+
import { compileExpr } from './expr.mjs';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MIN = 0, DEFAULT_MAX = 1000;
|
|
9
|
+
|
|
10
|
+
function mulberry32(seed) {
|
|
11
|
+
let a = seed >>> 0;
|
|
12
|
+
return () => {
|
|
13
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
14
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
15
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
16
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Derive an integer [min, max] for a variable from its `where` clause (>=, >, <=, <, ==).
|
|
21
|
+
function boundsFor(where, varName) {
|
|
22
|
+
let min = DEFAULT_MIN, max = DEFAULT_MAX;
|
|
23
|
+
if (!where) return { min, max };
|
|
24
|
+
const re = new RegExp(`\\b${varName}\\s*(>=|<=|>|<|==)\\s*(-?\\d+)`, 'g');
|
|
25
|
+
let m;
|
|
26
|
+
while ((m = re.exec(where))) {
|
|
27
|
+
const n = parseInt(m[2], 10);
|
|
28
|
+
if (m[1] === '>=') min = Math.max(min, n);
|
|
29
|
+
else if (m[1] === '>') min = Math.max(min, n + 1);
|
|
30
|
+
else if (m[1] === '<=') max = Math.min(max, n);
|
|
31
|
+
else if (m[1] === '<') max = Math.min(max, n - 1);
|
|
32
|
+
else if (m[1] === '==') { min = n; max = n; }
|
|
33
|
+
}
|
|
34
|
+
const unsat = min > max; // e.g. `where x >= 100 and x <= 0` , no integer satisfies it
|
|
35
|
+
if (unsat) max = min;
|
|
36
|
+
return { min, max, unsat };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isBool = (t) => /bool/i.test(t || '');
|
|
40
|
+
|
|
41
|
+
function satisfies(v, val) {
|
|
42
|
+
if (!v.where) return true;
|
|
43
|
+
try { return !!compileExpr(v.where)({ [v.name]: val }); } catch { return true; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function genValue(v, rnd) {
|
|
47
|
+
if (isBool(v.type)) return rnd() < 0.5;
|
|
48
|
+
const { min, max } = boundsFor(v.where, v.name);
|
|
49
|
+
return Math.floor(min + rnd() * (max - min + 1));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Evaluate the property's expect clauses against a decision run. Returns the first violation.
|
|
53
|
+
function checkExpects(prop, run) {
|
|
54
|
+
for (const e of prop.expects) {
|
|
55
|
+
const m = e.match(/^(\w+)\s*(==|!=)\s*(.+?)\s*$/);
|
|
56
|
+
if (!m) continue;
|
|
57
|
+
const field = m[1] === 'matchedRule' || m[1] === 'rule' ? 'matched' : 'result';
|
|
58
|
+
const lhs = field === 'matched' ? run.matched : run.result;
|
|
59
|
+
const eq = String(lhs) === m[3];
|
|
60
|
+
if (m[2] === '==' ? !eq : eq) return { ok: false, expect: e, actual: lhs };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Given the other inputs fixed, find the smallest value of `v` that still fails the property.
|
|
66
|
+
function shrinkVar(prop, dec, inputs, v) {
|
|
67
|
+
if (isBool(v.type) || typeof inputs[v.name] !== 'number') return inputs[v.name];
|
|
68
|
+
const { min } = boundsFor(v.where, v.name);
|
|
69
|
+
const fails = (val) => satisfies(v, val) && !checkExpects(prop, evaluateDecision(dec, { ...inputs, [v.name]: val })).ok;
|
|
70
|
+
if (fails(min)) return min;
|
|
71
|
+
let lo = min, hi = inputs[v.name];
|
|
72
|
+
while (hi - lo > 1) { const mid = Math.floor((lo + hi) / 2); if (fails(mid)) hi = mid; else lo = mid; }
|
|
73
|
+
return hi;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function shrink(prop, dec, failing) {
|
|
77
|
+
const inputs = { ...failing.inputs };
|
|
78
|
+
for (const v of prop.vars) inputs[v.name] = shrinkVar(prop, dec, inputs, v);
|
|
79
|
+
const run = evaluateDecision(dec, inputs);
|
|
80
|
+
const chk = checkExpects(prop, run);
|
|
81
|
+
return { inputs, expect: chk.expect || failing.expect, actual: chk.actual };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function runProperties(ast, { cases = 100, seed = 424242 } = {}) {
|
|
85
|
+
const decisions = new Map((ast.decisions || []).map((d) => [d.name, d]));
|
|
86
|
+
const results = [];
|
|
87
|
+
for (const prop of ast.properties || []) {
|
|
88
|
+
const dec = decisions.get(prop.decide);
|
|
89
|
+
if (!dec) { results.push({ property: prop.name, ok: false, cases: 0, seed, error: prop.decide ? `no decision named "${prop.decide}"` : 'property has no `decide <Decision>`' }); continue; }
|
|
90
|
+
const badVar = prop.vars.find((v) => !isBool(v.type) && boundsFor(v.where, v.name).unsat);
|
|
91
|
+
if (badVar) { results.push({ property: prop.name, ok: false, cases: 0, seed, error: `unsatisfiable constraint for "${badVar.name}": ${badVar.where}` }); continue; }
|
|
92
|
+
const rnd = mulberry32(seed);
|
|
93
|
+
let failure = null;
|
|
94
|
+
for (let i = 0; i < cases && !failure; i++) {
|
|
95
|
+
const inputs = {};
|
|
96
|
+
for (const v of prop.vars) {
|
|
97
|
+
let val, tries = 0;
|
|
98
|
+
do { val = genValue(v, rnd); tries++; } while (!satisfies(v, val) && tries < 50);
|
|
99
|
+
inputs[v.name] = val;
|
|
100
|
+
}
|
|
101
|
+
const chk = checkExpects(prop, evaluateDecision(dec, inputs));
|
|
102
|
+
if (!chk.ok) failure = { inputs, expect: chk.expect, actual: chk.actual };
|
|
103
|
+
}
|
|
104
|
+
if (failure) failure = shrink(prop, dec, failure);
|
|
105
|
+
results.push({ property: prop.name, ok: !failure, cases, seed, failure });
|
|
106
|
+
}
|
|
107
|
+
return { schema: 'thunder-properties-v1', total: results.length, passed: results.filter((r) => r.ok).length, results };
|
|
108
|
+
}
|
package/src/report.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Repo-wide intent health report (intent-report-v1). `
|
|
1
|
+
// Repo-wide intent health report (intent-report-v1). `thunder check` gates a build pass/fail;
|
|
2
2
|
// this AGGREGATES across every .intent file into a triage view a team adopting ThunderLang can
|
|
3
3
|
// act on: how many missions, diagnostics by severity + area, the most common codes, and
|
|
4
4
|
// coverage signals (are guarantees verified? do missions have tests? are outcomes contracted?).
|
package/src/sarif.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// SARIF 2.1.0 output for `
|
|
1
|
+
// SARIF 2.1.0 output for `thunder check` , so ThunderLang diagnostics show up natively in the
|
|
2
2
|
// surfaces teams already use: GitHub / GitLab code scanning (inline PR annotations + the
|
|
3
3
|
// Security tab) and any SARIF-aware IDE. Pure: reports in, one SARIF log object out.
|
|
4
4
|
//
|
package/src/scan-queries.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Scanner query views (intent-scan-view-v1) , the deterministic answers behind the Part 3
|
|
2
|
-
// CLI verbs: `
|
|
2
|
+
// CLI verbs: `thunder risks | gaps | unverified | coverage | unknowns | contradictions`.
|
|
3
3
|
// These are pure derivations over a scanProject() result (its Intent IR + Fable findings) ,
|
|
4
4
|
// no new analysis, no AI. They exist so a person can ask one focused question ("what is
|
|
5
5
|
// unverified?", "what contradicts?") instead of reading the whole scan report.
|
package/src/style.mjs
CHANGED
|
@@ -85,7 +85,7 @@ function setPath(root, path, leaf) {
|
|
|
85
85
|
/**
|
|
86
86
|
* Deterministic diagnostics for every `style_intent` block. Returns an array of
|
|
87
87
|
* { ruleId, severity, blocks, message, styleIntent, line } , the same shape the rest of
|
|
88
|
-
* the compiler emits, so it composes into `
|
|
88
|
+
* the compiler emits, so it composes into `thunder check` with no special-casing.
|
|
89
89
|
*/
|
|
90
90
|
export function styleDiagnostics(ast) {
|
|
91
91
|
const out = [];
|
|
@@ -199,7 +199,7 @@ export function toDesignTokens(ast) {
|
|
|
199
199
|
});
|
|
200
200
|
for (const t of si.tokens) {
|
|
201
201
|
const leaf = { $value: coerceValue(t.value), $type: tokenType(t.path) };
|
|
202
|
-
if (!isKnownPath(t.path)) leaf.$extensions = { 'dev.
|
|
202
|
+
if (!isKnownPath(t.path)) leaf.$extensions = { 'dev.intentlanguage': { canonical: false } };
|
|
203
203
|
setPath(root, t.path, leaf);
|
|
204
204
|
}
|
|
205
205
|
}
|
|
@@ -207,7 +207,7 @@ export function toDesignTokens(ast) {
|
|
|
207
207
|
$description: `Design tokens for ${ast.title || ast.mission || 'intent'} (generated from style_intent by @skillstech/thunderlang)`,
|
|
208
208
|
...root,
|
|
209
209
|
$extensions: {
|
|
210
|
-
'dev.
|
|
210
|
+
'dev.intentlanguage': {
|
|
211
211
|
schema: DESIGN_TOKENS_SCHEMA,
|
|
212
212
|
format: 'W3C Design Tokens (DTCG)',
|
|
213
213
|
source: ast.mission || null,
|
|
@@ -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;
|
package/src/twelve-factor.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Verdicts: 'satisfied' | 'partial' | 'absent'. Score = (satisfied + 0.5*partial) / 13.
|
|
11
11
|
// Each factor cites the IL signal it inspects, the evidence found, and a concrete fix. The
|
|
12
|
-
// finding ids (IL-12F-01..13) are in the canonical rule catalog for `
|
|
12
|
+
// finding ids (IL-12F-01..13) are in the canonical rule catalog for `thunder explain`.
|
|
13
13
|
|
|
14
14
|
export const TWELVE_FACTOR_SCHEMA = 'twelve-factor-v1';
|
|
15
15
|
|
|
@@ -44,7 +44,7 @@ const FACTORS = [
|
|
|
44
44
|
const scoped = len(ast.scope?.include) + len(ast.scope?.exclude);
|
|
45
45
|
if (scoped) return { verdict: 'satisfied', evidence: `context boundary declared (scope: ${len(ast.scope.include)} include / ${len(ast.scope.exclude)} exclude)` };
|
|
46
46
|
if (len(ast.requires)) return { verdict: 'partial', evidence: 'dependencies declared but no explicit scope boundary', fix: 'Declare `scope include/exclude` so the context window is curated, not unbounded.' };
|
|
47
|
-
return { verdict: 'absent', evidence: 'no scope declared', fix: 'Add a `scope` block to bound what belongs in context (
|
|
47
|
+
return { verdict: 'absent', evidence: 'no scope declared', fix: 'Add a `scope` block to bound what belongs in context (ThunderLens/Focus narrows it further).' };
|
|
48
48
|
},
|
|
49
49
|
},
|
|
50
50
|
{
|