@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.
- package/CHANGELOG.md +53 -0
- package/dist/core.cjs +240 -20
- package/dist/index.cjs +241 -21
- package/intent-graph.schema.json +227 -4
- package/package.json +1 -1
- package/src/cli.mjs +632 -23
- package/src/conformance.mjs +35 -0
- package/src/coverage.mjs +56 -0
- package/src/emit.mjs +3 -1
- package/src/expr.mjs +11 -7
- package/src/intent-graph.mjs +29 -0
- package/src/lift.mjs +79 -3
- package/src/mutate.mjs +46 -0
- package/src/parse.mjs +82 -3
- package/src/property.mjs +108 -0
- 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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Cross-target conformance. ThunderLang can't execute generated TypeScript/Python, so it does
|
|
2
|
+
// the honest thing: the deterministic engine defines the canonical contract (what every target
|
|
3
|
+
// must produce for each test case), and target outputs fed via results are graded against it.
|
|
4
|
+
// A target case that diverges is a CONFORMANCE FAILURE. Same tests, every implementation.
|
|
5
|
+
import { runTests } from './testing.mjs';
|
|
6
|
+
|
|
7
|
+
export function buildConformance(ast, { targets = [], results = null } = {}) {
|
|
8
|
+
const sem = runTests(ast);
|
|
9
|
+
const cases = (sem.results || []).map((r) => ({
|
|
10
|
+
key: `${r.target} / ${r.case}`, test: r.target, case: r.case,
|
|
11
|
+
inputs: r.inputs || {}, expected: r.expected, semantic: r.actual, semanticPass: !!r.pass,
|
|
12
|
+
}));
|
|
13
|
+
const columns = (targets.length ? targets : (ast.targets || [])).map((t) => String(t).toLowerCase());
|
|
14
|
+
const rows = cases.map((c) => {
|
|
15
|
+
const t = {};
|
|
16
|
+
for (const col of columns) {
|
|
17
|
+
const tr = results && results[col];
|
|
18
|
+
if (!tr || !(c.key in tr)) { t[col] = { status: 'declared' }; continue; }
|
|
19
|
+
const actual = tr[c.key];
|
|
20
|
+
t[col] = { status: String(actual) === String(c.expected) ? 'pass' : 'fail', actual };
|
|
21
|
+
}
|
|
22
|
+
return { ...c, targets: t };
|
|
23
|
+
});
|
|
24
|
+
const failures = [];
|
|
25
|
+
for (const row of rows) for (const col of columns) {
|
|
26
|
+
const tr = row.targets[col];
|
|
27
|
+
if (tr.status === 'fail') failures.push({ target: col, case: row.key, expected: row.expected, actual: tr.actual });
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
schema: 'thunder-conformance-v1', mission: ast.mission,
|
|
31
|
+
total: cases.length, columns,
|
|
32
|
+
semanticFailures: cases.filter((c) => !c.semanticPass).length,
|
|
33
|
+
graded: !!results, failures, cases: rows,
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/coverage.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Semantic coverage , meaning-level metrics, not line coverage. Answers "which goals, decision
|
|
2
|
+
// rules, guarantees, prohibitions, scenarios, and targets are actually exercised by the tests?"
|
|
3
|
+
// so "12 of 15 prohibitions challenged" is far more useful than "82% line coverage".
|
|
4
|
+
import { runTests } from './testing.mjs';
|
|
5
|
+
|
|
6
|
+
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
7
|
+
|
|
8
|
+
export function semanticCoverage(ast) {
|
|
9
|
+
const t = runTests(ast);
|
|
10
|
+
const metrics = [];
|
|
11
|
+
const unverified = [];
|
|
12
|
+
const add = (name, covered, total, missing = []) => {
|
|
13
|
+
metrics.push({ name, covered, total, pct: total ? Math.round((covered / total) * 100) : 100 });
|
|
14
|
+
for (const m of missing) unverified.push(m);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Goal
|
|
18
|
+
if (ast.mission) add('Goals', ast.goal ? 1 : 0, 1, ast.goal ? [] : ['goal , the mission declares no goal']);
|
|
19
|
+
|
|
20
|
+
// Decision rules matched by at least one test case (branch coverage for decisions)
|
|
21
|
+
const matched = new Set((t.results || []).map((r) => r.matched).filter(Boolean));
|
|
22
|
+
const rules = (ast.decisions || []).flatMap((d) => (d.rules || []).filter((r) => r.name).map((r) => ({ dec: d.name, name: r.name })));
|
|
23
|
+
add('Decision rules', rules.filter((r) => matched.has(r.name)).length, rules.length,
|
|
24
|
+
rules.filter((r) => !matched.has(r.name)).map((r) => `rule ${r.dec}/${r.name} , never matched by a test`));
|
|
25
|
+
|
|
26
|
+
// Guarantees that carry a verification
|
|
27
|
+
const g = ast.guarantees || [];
|
|
28
|
+
add('Guarantees', g.filter((x) => (x.verify || []).length).length, g.length,
|
|
29
|
+
g.filter((x) => !(x.verify || []).length).map((x) => `guarantee ${x.id} , no verification`));
|
|
30
|
+
|
|
31
|
+
// Prohibitions challenged (a `never` with a verification)
|
|
32
|
+
const n = ast.neverRules || [];
|
|
33
|
+
add('Prohibitions', n.filter((x) => (x.verify || []).length).length, n.length,
|
|
34
|
+
n.filter((x) => !(x.verify || []).length).map((x) => `never ${x.id} , not challenged`));
|
|
35
|
+
|
|
36
|
+
// Scenarios that are self-consistent (not both expected and prohibited)
|
|
37
|
+
const sc = ast.scenarios || [];
|
|
38
|
+
if (sc.length) {
|
|
39
|
+
const consistent = sc.filter((s) => {
|
|
40
|
+
const nv = new Set((s.never || []).map(norm));
|
|
41
|
+
return ![...(s.then || []), ...(s.eventually || []).flatMap((e) => e.clauses)].some((p) => nv.has(norm(p)));
|
|
42
|
+
});
|
|
43
|
+
add('Scenarios', consistent.length, sc.length, sc.filter((s) => !consistent.includes(s)).map((s) => `scenario ${s.name} , self-contradictory`));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Targets exercised by conformance (needs `thunder conform --results`; 0 until then)
|
|
47
|
+
const tg = ast.targets || [];
|
|
48
|
+
if (tg.length) add('Targets tested', 0, tg.length, tg.map((x) => `target ${x} , not conformance-tested`));
|
|
49
|
+
|
|
50
|
+
const totalCovered = metrics.reduce((a, m) => a + m.covered, 0);
|
|
51
|
+
const totalAll = metrics.reduce((a, m) => a + m.total, 0);
|
|
52
|
+
return {
|
|
53
|
+
schema: 'thunder-coverage-v1', mission: ast.mission, metrics, unverified,
|
|
54
|
+
overall: totalAll ? Math.round((totalCovered / totalAll) * 100) : 100,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/emit.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export function notesSummary(ast) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export const COMPILER_VERSION = '0.
|
|
33
|
+
export const COMPILER_VERSION = '0.3.0';
|
|
34
34
|
export const PROOF_SCHEMA_VERSION = '0.1.0';
|
|
35
35
|
// Identifies which ecosystem product emitted this proof. Consumed by SkillsTech
|
|
36
36
|
// Certified to key cert proofs to the compiler. Stable slug per the coordination bus.
|
|
@@ -494,11 +494,13 @@ export function buildProof(ast, { sourceFile, sourceHash, targetsRequested, targ
|
|
|
494
494
|
id: g.id, text: g.statement,
|
|
495
495
|
status: g.verify.length > 0 || verifiedText ? 'planned' : 'needs_verification',
|
|
496
496
|
evidence: g.verify,
|
|
497
|
+
verifications: g.verifications || [],
|
|
497
498
|
})),
|
|
498
499
|
neverRules: ast.neverRules.map((n) => ({
|
|
499
500
|
id: n.id, text: n.statement,
|
|
500
501
|
status: n.verify.length > 0 ? 'planned' : 'needs_verification',
|
|
501
502
|
evidence: n.verify,
|
|
503
|
+
verifications: n.verifications || [],
|
|
502
504
|
})),
|
|
503
505
|
errors: (ast.errors || []).map((e) => ({ name: e.name })),
|
|
504
506
|
examples: (ast.examples || []).map((ex) => ({ given: ex.given, expect: ex.expect })),
|
package/src/expr.mjs
CHANGED
|
@@ -203,19 +203,22 @@ export function evalExpr(src, inputs = {}) {
|
|
|
203
203
|
|
|
204
204
|
// Per-language rendering of the `when` grammar. `eq`/`neq` build an equality expression (JS uses
|
|
205
205
|
// ===, C# ==, Java Objects.equals for correct string value equality); `inList` builds membership.
|
|
206
|
+
// C-family boolean/logic defaults (js, csharp, java share these).
|
|
207
|
+
const C_LOGIC = { and: (a, b) => `(${a} && ${b})`, or: (a, b) => `(${a} || ${b})`, not: (a) => `!${a}`, bool: (v) => String(v) };
|
|
206
208
|
const DIALECTS = {
|
|
207
|
-
js: { eq: (a, b) => `${a} === ${b}`, neq: (a, b) => `${a} !== ${b}`, inList: (list, x) => `[${list.join(', ')}].includes(${x})`, nil: 'undefined' },
|
|
208
|
-
csharp: { eq: (a, b) => `${a} == ${b}`, neq: (a, b) => `${a} != ${b}`, inList: (list, x) => `new[]{${list.join(', ')}}.Contains(${x})`, nil: 'null' },
|
|
209
|
-
java: { eq: (a, b) => `java.util.Objects.equals(${a}, ${b})`, neq: (a, b) => `!java.util.Objects.equals(${a}, ${b})`, inList: (list, x) => `java.util.List.of(${list.join(', ')}).contains(${x})`, nil: 'null' },
|
|
209
|
+
js: { ...C_LOGIC, eq: (a, b) => `${a} === ${b}`, neq: (a, b) => `${a} !== ${b}`, inList: (list, x) => `[${list.join(', ')}].includes(${x})`, nil: 'undefined' },
|
|
210
|
+
csharp: { ...C_LOGIC, eq: (a, b) => `${a} == ${b}`, neq: (a, b) => `${a} != ${b}`, inList: (list, x) => `new[]{${list.join(', ')}}.Contains(${x})`, nil: 'null' },
|
|
211
|
+
java: { ...C_LOGIC, eq: (a, b) => `java.util.Objects.equals(${a}, ${b})`, neq: (a, b) => `!java.util.Objects.equals(${a}, ${b})`, inList: (list, x) => `java.util.List.of(${list.join(', ')}).contains(${x})`, nil: 'null' },
|
|
212
|
+
python: { and: (a, b) => `(${a} and ${b})`, or: (a, b) => `(${a} or ${b})`, not: (a) => `(not ${a})`, bool: (v) => (v ? 'True' : 'False'), eq: (a, b) => `${a} == ${b}`, neq: (a, b) => `${a} != ${b}`, inList: (list, x) => `${x} in [${list.join(', ')}]`, nil: 'None' },
|
|
210
213
|
};
|
|
211
214
|
|
|
212
215
|
function renderExpr(src, inputs, D) {
|
|
213
216
|
const known = new Set(inputs);
|
|
214
217
|
const r = (n) => {
|
|
215
218
|
switch (n.k) {
|
|
216
|
-
case 'or': return
|
|
217
|
-
case 'and': return
|
|
218
|
-
case 'not': return
|
|
219
|
+
case 'or': return D.or(r(n.a), r(n.b));
|
|
220
|
+
case 'and': return D.and(r(n.a), r(n.b));
|
|
221
|
+
case 'not': return D.not(r(n.a));
|
|
219
222
|
case 'neg': return `-${r(n.a)}`;
|
|
220
223
|
case 'arith': return `(${r(n.a)} ${n.op} ${r(n.b)})`;
|
|
221
224
|
case 'cmp':
|
|
@@ -224,7 +227,7 @@ function renderExpr(src, inputs, D) {
|
|
|
224
227
|
return `(${r(n.a)} ${n.op} ${r(n.b)})`;
|
|
225
228
|
case 'in': return D.inList(n.list.map(r), r(n.a));
|
|
226
229
|
case 'list': return `[${n.items.map(r).join(', ')}]`;
|
|
227
|
-
case 'lit': return typeof n.v === 'string' ? JSON.stringify(n.v) : String(n.v);
|
|
230
|
+
case 'lit': return typeof n.v === 'string' ? JSON.stringify(n.v) : typeof n.v === 'boolean' ? D.bool(n.v) : String(n.v);
|
|
228
231
|
case 'ref': return (known.has(n.path) || known.has(n.path.split('.')[0])) ? n.path : JSON.stringify(n.path);
|
|
229
232
|
default: return D.nil;
|
|
230
233
|
}
|
|
@@ -243,3 +246,4 @@ export function exprToCode(src, { inputs = [], dialect = 'js' } = {}) {
|
|
|
243
246
|
export const exprToJs = (src, opts = {}) => exprToCode(src, { ...opts, dialect: 'js' });
|
|
244
247
|
export const exprToCSharp = (src, opts = {}) => exprToCode(src, { ...opts, dialect: 'csharp' });
|
|
245
248
|
export const exprToJava = (src, opts = {}) => exprToCode(src, { ...opts, dialect: 'java' });
|
|
249
|
+
export const exprToPython = (src, opts = {}) => exprToCode(src, { ...opts, dialect: 'python' });
|
package/src/intent-graph.mjs
CHANGED
|
@@ -344,3 +344,32 @@ export function buildIntentGraph(ast) {
|
|
|
344
344
|
},
|
|
345
345
|
};
|
|
346
346
|
}
|
|
347
|
+
|
|
348
|
+
// Data classifications that must never be shown in an external / display context.
|
|
349
|
+
export const SENSITIVE_CLASSIFICATIONS = new Set([
|
|
350
|
+
'confidential', 'pii', 'sensitive', 'phi', 'restricted', 'personal', 'secret',
|
|
351
|
+
]);
|
|
352
|
+
|
|
353
|
+
// A display-safe projection of an intent graph for external rendering (e.g. STT's viewer).
|
|
354
|
+
// Keeps the structural intent (ids, types, titles, statuses, relationships, classification LABEL)
|
|
355
|
+
// but drops owner/source provenance and redacts free text on any node with a sensitive
|
|
356
|
+
// classification. The intent graph carries no source code; data elements are governed separately
|
|
357
|
+
// and are not emitted as graph nodes, so they cannot leak through this projection.
|
|
358
|
+
export function safeGraph(graph) {
|
|
359
|
+
let redactedNodes = 0;
|
|
360
|
+
const nodes = (graph.nodes || []).map((n) => {
|
|
361
|
+
const sensitive = SENSITIVE_CLASSIFICATIONS.has(String(n.classification || '').toLowerCase());
|
|
362
|
+
const out = {
|
|
363
|
+
id: n.id, type: n.type, title: n.title ?? null, status: n.status ?? null,
|
|
364
|
+
classification: n.classification ?? null, tags: n.tags ?? [],
|
|
365
|
+
description: sensitive ? null : (n.description ?? null),
|
|
366
|
+
};
|
|
367
|
+
if (sensitive) { out.redacted = true; redactedNodes++; }
|
|
368
|
+
return out; // owner + source intentionally omitted
|
|
369
|
+
});
|
|
370
|
+
return {
|
|
371
|
+
schema: graph.schema, missionId: graph.missionId,
|
|
372
|
+
safe: true, redactedNodes,
|
|
373
|
+
nodes, relationships: graph.relationships || [], summary: graph.summary,
|
|
374
|
+
};
|
|
375
|
+
}
|
package/src/lift.mjs
CHANGED
|
@@ -393,6 +393,74 @@ export function extractFactsRuby(source, file = 'input.rb') {
|
|
|
393
393
|
return { schemaVersion: IR_SCHEMA_VERSION, sourceLanguage: 'ruby', sourceRoot: file, functions, tests, errors };
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
// Params written "name: Type" (Kotlin, Scala). Top-level comma split so generics/tuples
|
|
397
|
+
// like Map<String, Int> or (A, B) don't split mid-type. Strips val/var/vararg/implicit and defaults.
|
|
398
|
+
function parseNameColonTypeParams(raw) {
|
|
399
|
+
return splitTopLevel(raw, ',').map((p) => p.trim()).filter(Boolean).map((p) => {
|
|
400
|
+
const cleaned = p.replace(/@\w+(\([^)]*\))?/g, '').replace(/^(?:val|var|vararg|implicit|final|lazy)\s+/, '').replace(/=.*$/, '').trim();
|
|
401
|
+
const mm = cleaned.match(/^([A-Za-z_]\w*)\s*:\s*(.+)$/);
|
|
402
|
+
if (mm) return { name: mm[1], type: mm[2].trim() };
|
|
403
|
+
return { name: cleaned.replace(/[^\w].*$/, '') || cleaned, type: null };
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── Kotlin adapter (JVM: fun name(p: Type): Ret, @Test, *Exception) ──────────
|
|
408
|
+
export function extractFactsKotlin(source, file = 'input.kt') {
|
|
409
|
+
let m; const functions = []; const tests = []; const seen = new Set();
|
|
410
|
+
const testMethods = new Set(); const ta = /@Test\b[\s\S]{0,120}?\bfun\s+(?:`([^`]+)`|(\w+))\s*\(/g;
|
|
411
|
+
while ((m = ta.exec(source))) testMethods.add(m[1] || m[2]);
|
|
412
|
+
const fnRe = /\bfun\s+(?:<[^>]*>\s*)?(?:[A-Za-z_][\w.]*\.)?(?:`([^`]+)`|([A-Za-z_]\w*))\s*\(([^)]*)\)\s*(?::\s*([^{=\n]+))?/g;
|
|
413
|
+
while ((m = fnRe.exec(source))) {
|
|
414
|
+
const name = m[1] || m[2];
|
|
415
|
+
if (testMethods.has(name)) { if (!tests.some((t) => t.name === name)) tests.push({ name, file, line: lineOf(source, m.index) }); continue; }
|
|
416
|
+
if (seen.has(name)) continue; seen.add(name);
|
|
417
|
+
functions.push({ name, file, line: lineOf(source, m.index), parameters: parseNameColonTypeParams(m[3] || ''), returnType: m[4] ? m[4].trim() : null, evidence: [{ kind: 'fun', file, line: lineOf(source, m.index) }] });
|
|
418
|
+
}
|
|
419
|
+
const errors = []; const addErr = addErrOf(errors, new Set(), { _src: source, _file: file });
|
|
420
|
+
let mm; const ce = /class\s+(\w*(?:Exception|Error))\b/g; while ((mm = ce.exec(source))) addErr(mm[1], mm.index);
|
|
421
|
+
const th = /throw\s+(\w+)\s*\(/g; while ((mm = th.exec(source))) addErr(mm[1], mm.index); // Kotlin: no `new`
|
|
422
|
+
return { schemaVersion: IR_SCHEMA_VERSION, sourceLanguage: 'kotlin', sourceRoot: file, functions, tests, errors };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Scala adapter (def name(p: Type): Ret, ScalaTest, *Exception) ─────────────
|
|
426
|
+
export function extractFactsScala(source, file = 'input.scala') {
|
|
427
|
+
let m; const functions = []; const tests = []; const seen = new Set();
|
|
428
|
+
const fnRe = /\bdef\s+([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*\(([^)]*)\)\s*(?::\s*([^={\n]+))?/g;
|
|
429
|
+
while ((m = fnRe.exec(source))) {
|
|
430
|
+
const name = m[1];
|
|
431
|
+
if (seen.has(name)) continue; seen.add(name);
|
|
432
|
+
functions.push({ name, file, line: lineOf(source, m.index), parameters: parseNameColonTypeParams(m[2] || ''), returnType: m[3] ? m[3].trim() : null, evidence: [{ kind: 'def', file, line: lineOf(source, m.index) }] });
|
|
433
|
+
}
|
|
434
|
+
// ScalaTest: `test("desc")` (FunSuite) and `"desc" in { }` / `"desc" should`/`must` (WordSpec/FlatSpec).
|
|
435
|
+
const seenT = new Set(); const addTest = (n, idx) => { const k = String(n).toLowerCase(); if (n && !seenT.has(k)) { seenT.add(k); tests.push({ name: n, file, line: lineOf(source, idx) }); } };
|
|
436
|
+
const tr = /\btest\s*\(\s*"([^"]+)"/g; while ((m = tr.exec(source))) addTest(m[1], m.index);
|
|
437
|
+
const inRe = /"([^"]+)"\s+(?:in|should|must)\b/g; while ((m = inRe.exec(source))) addTest(m[1], m.index);
|
|
438
|
+
const errors = []; const addErr = addErrOf(errors, new Set(), { _src: source, _file: file });
|
|
439
|
+
let mm; const ce = /class\s+(\w*(?:Exception|Error))\b/g; while ((mm = ce.exec(source))) addErr(mm[1], mm.index);
|
|
440
|
+
const th = /throw\s+new\s+(\w+)\s*\(/g; while ((mm = th.exec(source))) addErr(mm[1], mm.index);
|
|
441
|
+
return { schemaVersion: IR_SCHEMA_VERSION, sourceLanguage: 'scala', sourceRoot: file, functions, tests, errors };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ── Elixir adapter (dynamic: def/defp name(args), ExUnit test, raise/defexception)
|
|
445
|
+
export function extractFactsElixir(source, file = 'input.ex') {
|
|
446
|
+
let m; const functions = []; const tests = []; const seen = new Set();
|
|
447
|
+
const seenT = new Set(); const addTest = (n, idx) => { const k = String(n).toLowerCase(); if (n && !seenT.has(k)) { seenT.add(k); tests.push({ name: n, file, line: lineOf(source, idx) }); } };
|
|
448
|
+
const tr = /\btest\s+"([^"]+)"/g; while ((m = tr.exec(source))) addTest(m[1], m.index); // ExUnit: test "desc" do
|
|
449
|
+
const fnRe = /^[ \t]*defp?\s+([a-z_]\w*[!?]?)\s*(?:\(([^)]*)\))?/gm;
|
|
450
|
+
while ((m = fnRe.exec(source))) {
|
|
451
|
+
const name = m[1];
|
|
452
|
+
if (seen.has(name)) continue; seen.add(name);
|
|
453
|
+
// Elixir params are pattern matches (conn, %{id: id}, x \\ default); take the leading binding name.
|
|
454
|
+
const parameters = splitTopLevel(m[2] || '', ',').map((p) => p.trim()).filter(Boolean)
|
|
455
|
+
.map((p) => ({ name: p.split(/\\\\/)[0].trim().replace(/^%\{?/, '').replace(/[^\w].*$/, '') || p, type: null }));
|
|
456
|
+
functions.push({ name, file, line: lineOf(source, m.index), indent: (m[0].match(/^[ \t]*/) || [''])[0].length, parameters, returnType: null, evidence: [{ kind: 'def', file, line: lineOf(source, m.index) }] });
|
|
457
|
+
}
|
|
458
|
+
const errors = []; const seenErr = new Set(); const addErr = (n, idx) => { if (n && !seenErr.has(n)) { seenErr.add(n); errors.push({ name: n, file, line: lineOf(source, idx) }); } };
|
|
459
|
+
const modErr = /defmodule\s+([\w.]*(?:Error|Exception))\b/g; while ((m = modErr.exec(source))) addErr(m[1].split('.').pop(), m.index);
|
|
460
|
+
const raiseRe = /raise\s+([A-Z][\w.]*)/g; while ((m = raiseRe.exec(source))) addErr(m[1].split('.').pop(), m.index);
|
|
461
|
+
return { schemaVersion: IR_SCHEMA_VERSION, sourceLanguage: 'elixir', sourceRoot: file, functions, tests, errors };
|
|
462
|
+
}
|
|
463
|
+
|
|
396
464
|
const ADAPTERS = {
|
|
397
465
|
typescript: extractFactsTypeScript, ts: extractFactsTypeScript,
|
|
398
466
|
javascript: extractFactsTypeScript, js: extractFactsTypeScript,
|
|
@@ -405,13 +473,17 @@ const ADAPTERS = {
|
|
|
405
473
|
cpp: extractFactsCpp, 'c++': extractFactsCpp, c: extractFactsCpp, cc: extractFactsCpp,
|
|
406
474
|
php: extractFactsPhp,
|
|
407
475
|
ruby: extractFactsRuby, rb: extractFactsRuby,
|
|
476
|
+
kotlin: extractFactsKotlin, kt: extractFactsKotlin, kts: extractFactsKotlin,
|
|
477
|
+
scala: extractFactsScala, sc: extractFactsScala,
|
|
478
|
+
elixir: extractFactsElixir, ex: extractFactsElixir, exs: extractFactsElixir,
|
|
408
479
|
};
|
|
409
|
-
export const SUPPORTED_LANGUAGES = ['typescript', 'javascript', 'python', 'java', 'csharp', 'go', 'rust', 'cpp', 'php', 'ruby', 'perl'];
|
|
410
|
-
const DYNAMIC_LANGUAGES = new Set(['perl', 'javascript', 'python', 'ruby', 'php']);
|
|
480
|
+
export const SUPPORTED_LANGUAGES = ['typescript', 'javascript', 'python', 'java', 'csharp', 'go', 'rust', 'cpp', 'php', 'ruby', 'perl', 'kotlin', 'scala', 'elixir'];
|
|
481
|
+
const DYNAMIC_LANGUAGES = new Set(['perl', 'javascript', 'python', 'ruby', 'php', 'elixir']);
|
|
411
482
|
|
|
412
483
|
const LANG_DISPLAY = {
|
|
413
484
|
typescript: 'TypeScript', javascript: 'JavaScript', python: 'Python', java: 'Java',
|
|
414
485
|
csharp: 'C#', go: 'Go', rust: 'Rust', cpp: 'C++', php: 'PHP', ruby: 'Ruby', perl: 'Perl',
|
|
486
|
+
kotlin: 'Kotlin', scala: 'Scala', elixir: 'Elixir',
|
|
415
487
|
};
|
|
416
488
|
|
|
417
489
|
// Unwrap Result<T, E> / Promise<T> / T -> { output, error }
|
|
@@ -558,6 +630,9 @@ export function languageForFile(file) {
|
|
|
558
630
|
if (/\.(cpp|cc|cxx|hpp|hh|c|h)$/i.test(file)) return 'cpp';
|
|
559
631
|
if (/\.php$/i.test(file)) return 'php';
|
|
560
632
|
if (/\.rb$/i.test(file)) return 'ruby';
|
|
633
|
+
if (/\.kts?$/i.test(file)) return 'kotlin';
|
|
634
|
+
if (/\.(scala|sc)$/i.test(file)) return 'scala';
|
|
635
|
+
if (/\.exs?$/i.test(file)) return 'elixir';
|
|
561
636
|
if (/\.(mjs|cjs|jsx?)$/i.test(file)) return 'javascript';
|
|
562
637
|
return 'typescript';
|
|
563
638
|
}
|
|
@@ -577,7 +652,7 @@ function isPublicFn(fn, language) {
|
|
|
577
652
|
// Common private-helper naming conventions, across languages.
|
|
578
653
|
if (/(?:Internal|Impl|_impl|_helper|_test|Helper)$/.test(name)) return false;
|
|
579
654
|
if (language === 'go' || language === 'golang') return /^[A-Z]/.test(name) && name !== 'Test';
|
|
580
|
-
if (language === 'python' || language === 'ruby') return !name.startsWith('_') && (fn.indent == null || fn.indent <= 4);
|
|
655
|
+
if (language === 'python' || language === 'ruby' || language === 'elixir') return !name.startsWith('_') && (fn.indent == null || fn.indent <= 4);
|
|
581
656
|
return !name.startsWith('_') && name !== 'init' && name !== 'constructor';
|
|
582
657
|
}
|
|
583
658
|
|
|
@@ -643,6 +718,7 @@ export function liftRepo(files, { language } = {}) {
|
|
|
643
718
|
const LANG_EXT = {
|
|
644
719
|
typescript: 'ts', javascript: 'js', python: 'py', java: 'java', csharp: 'cs',
|
|
645
720
|
go: 'go', rust: 'rs', cpp: 'cpp', php: 'php', ruby: 'rb', perl: 'pl',
|
|
721
|
+
kotlin: 'kt', scala: 'scala', elixir: 'ex',
|
|
646
722
|
};
|
|
647
723
|
|
|
648
724
|
/**
|
package/src/mutate.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Mutation testing for ThunderLang decisions. Inject small, deterministic faults into a
|
|
2
|
+
// decision's rules, re-run the in-file tests, and check whether they catch the change. A mutant
|
|
3
|
+
// that no test detects (SURVIVED) marks a weak spot , tests that pass but do not protect the
|
|
4
|
+
// system, exactly the failure mode of AI-authored tests.
|
|
5
|
+
import { runTests } from './testing.mjs';
|
|
6
|
+
|
|
7
|
+
const FLIP = { '>=': '<', '<=': '>', '>': '<=', '<': '>=', '==': '!=', '!=': '==' };
|
|
8
|
+
const clone = (x) => JSON.parse(JSON.stringify(x));
|
|
9
|
+
const withDecisions = (ast, decisions) => ({ ...ast, decisions });
|
|
10
|
+
const passingSet = (ast) => new Set(runTests(ast).results.filter((x) => x.pass).map((x) => `${x.target}/${x.case}`));
|
|
11
|
+
|
|
12
|
+
// Generate one mutant per (rule, operator). Each returns a mutated AST plus a description.
|
|
13
|
+
function mutants(ast) {
|
|
14
|
+
const out = [];
|
|
15
|
+
const decs = ast.decisions || [];
|
|
16
|
+
decs.forEach((dec, di) => {
|
|
17
|
+
(dec.rules || []).forEach((rule, ri) => {
|
|
18
|
+
const tag = rule.name || `rule ${ri + 1}`;
|
|
19
|
+
if (rule.when) {
|
|
20
|
+
const m = rule.when.match(/(>=|<=|==|!=|>|<)/);
|
|
21
|
+
if (m) { const md = clone(decs); md[di].rules[ri].when = rule.when.replace(m[1], FLIP[m[1]]); out.push({ id: `${dec.name}/${tag}/flip`, describe: `flip ${m[1]} in ${tag} of ${dec.name}`, ast: withDecisions(ast, md) }); }
|
|
22
|
+
if (/\band\b/.test(rule.when)) { const md = clone(decs); md[di].rules[ri].when = rule.when.replace(/\band\b/, 'or'); out.push({ id: `${dec.name}/${tag}/and-or`, describe: `and -> or in ${tag} of ${dec.name}`, ast: withDecisions(ast, md) }); }
|
|
23
|
+
}
|
|
24
|
+
if (rule.result != null && dec.default != null && rule.result !== dec.default) {
|
|
25
|
+
const md = clone(decs); md[di].rules[ri].result = dec.default;
|
|
26
|
+
out.push({ id: `${dec.name}/${tag}/swap-return`, describe: `${tag} returns the default instead of ${rule.result}`, ast: withDecisions(ast, md) });
|
|
27
|
+
}
|
|
28
|
+
{ const md = clone(decs); md[di].rules.splice(ri, 1); out.push({ id: `${dec.name}/${tag}/remove`, describe: `remove ${tag} from ${dec.name}`, ast: withDecisions(ast, md) }); }
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function runMutations(ast) {
|
|
35
|
+
const base = passingSet(ast);
|
|
36
|
+
if (base.size === 0) return { schema: 'thunder-mutation-v1', total: 0, killed: 0, survived: 0, score: null, note: 'no passing decision tests to mutate against', results: [] };
|
|
37
|
+
const results = mutants(ast).map((mu) => {
|
|
38
|
+
const now = passingSet(mu.ast);
|
|
39
|
+
// Killed if any test that passed on the original now fails on the mutant.
|
|
40
|
+
const killed = [...base].some((k) => !now.has(k));
|
|
41
|
+
return { id: mu.id, describe: mu.describe, killed };
|
|
42
|
+
});
|
|
43
|
+
const killed = results.filter((r) => r.killed).length;
|
|
44
|
+
const survived = results.length - killed;
|
|
45
|
+
return { schema: 'thunder-mutation-v1', total: results.length, killed, survived, score: results.length ? Math.round((killed / results.length) * 100) : null, results };
|
|
46
|
+
}
|
package/src/parse.mjs
CHANGED
|
@@ -181,6 +181,59 @@ function parseDecision(name, node) {
|
|
|
181
181
|
return dec;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// Property-based test: `property Name` + forAll (typed vars with `where` constraints) +
|
|
185
|
+
// `decide <Decision>` + expect clauses. The runner generates many cases and shrinks failures.
|
|
186
|
+
function parseProperty(name, node) {
|
|
187
|
+
const prop = { name, vars: [], decide: null, expects: [], line: node.line };
|
|
188
|
+
for (const c of node.children.filter((x) => !isNote(x))) {
|
|
189
|
+
const k = firstWord(c.text); const a = rest(c.text);
|
|
190
|
+
if (k === 'forAll' || k === 'forall') {
|
|
191
|
+
for (const v of c.children.filter((x) => !isNote(x))) {
|
|
192
|
+
const m = v.text.trim().match(/^([A-Za-z_]\w*)\s*:\s*(\w+)(?:\s+where\s+(.+))?$/);
|
|
193
|
+
if (!m) continue;
|
|
194
|
+
const whereChild = (v.children || []).find((x) => firstWord(x.text) === 'where');
|
|
195
|
+
prop.vars.push({ name: m[1], type: m[2], where: m[3] || (whereChild ? rest(whereChild.text) : null) });
|
|
196
|
+
}
|
|
197
|
+
} else if (k === 'decide') prop.decide = a || (c.children[0] && c.children[0].text.trim()) || null;
|
|
198
|
+
else if (k === 'when') { const dm = a.match(/(?:decide|execute)\s+(\w+)/); if (dm) prop.decide = dm[1]; }
|
|
199
|
+
else if (k === 'expect') for (const e of c.children.filter((x) => !isNote(x))) prop.expects.push(e.text.trim());
|
|
200
|
+
}
|
|
201
|
+
return prop;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// AI evaluation: probabilistic behavior graded against a dataset by metric thresholds,
|
|
205
|
+
// not a binary run. `evaluation <Name>` + `dataset <id>` + `require` (metric op threshold).
|
|
206
|
+
function parseEvaluation(name, node) {
|
|
207
|
+
const ev = { name, dataset: null, requires: [], line: node.line };
|
|
208
|
+
for (const c of node.children.filter((x) => !isNote(x))) {
|
|
209
|
+
const k = firstWord(c.text); const a = rest(c.text);
|
|
210
|
+
if (k === 'dataset') ev.dataset = a || (c.children[0] && c.children[0].text.trim()) || null;
|
|
211
|
+
else if (k === 'require') {
|
|
212
|
+
for (const r of c.children.filter((x) => !isNote(x))) {
|
|
213
|
+
const m = r.text.trim().match(/^([A-Za-z_][\w.]*)\s*(>=|<=|==|!=|>|<)\s*(-?[\d.]+)$/);
|
|
214
|
+
if (m) ev.requires.push({ metric: m[1], op: m[2], threshold: parseFloat(m[3]) });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return ev;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Scenario: a workflow-level test , given / when / then / never (+ eventually within <t>).
|
|
222
|
+
// Deterministically checkable for self-contradiction (a `then` also listed under `never`).
|
|
223
|
+
function parseScenario(name, node) {
|
|
224
|
+
const sc = { name, given: [], when: [], then: [], never: [], eventually: [], line: node.line };
|
|
225
|
+
const clausesOf = (c) => { const items = leafItems(c); return items.length ? items : (rest(c.text) ? [rest(c.text)] : []); };
|
|
226
|
+
for (const c of node.children.filter((x) => !isNote(x))) {
|
|
227
|
+
const k = firstWord(c.text);
|
|
228
|
+
if (k === 'given') sc.given.push(...clausesOf(c));
|
|
229
|
+
else if (k === 'when') sc.when.push(...clausesOf(c));
|
|
230
|
+
else if (k === 'then') sc.then.push(...clausesOf(c));
|
|
231
|
+
else if (k === 'never') sc.never.push(...clausesOf(c));
|
|
232
|
+
else if (k === 'eventually') { const within = (rest(c.text).match(/within\s+(.+)/) || [])[1] || null; sc.eventually.push({ within, clauses: leafItems(c) }); }
|
|
233
|
+
}
|
|
234
|
+
return sc;
|
|
235
|
+
}
|
|
236
|
+
|
|
184
237
|
// Lifecycle state machine (intent-graph-v1 Gap 2).
|
|
185
238
|
function parseLifecycle(name, node) {
|
|
186
239
|
const lc = { name, states: [], transitions: [], terminals: [], line: node.line };
|
|
@@ -215,6 +268,7 @@ function parseInvariant(name, node) {
|
|
|
215
268
|
for (const c of kids(node)) {
|
|
216
269
|
const k = firstWord(c.text);
|
|
217
270
|
if (k === 'verify') inv.verify.push(rest(c.text));
|
|
271
|
+
else if (k === 'id') { const v = rest(c.text); if (v) { inv.id = v; inv.stableId = v; } }
|
|
218
272
|
else if (k === 'because') inv.because = rest(c.text);
|
|
219
273
|
else if (k === 'statement') inv.statement = rest(c.text) || leafItems(c).join(' ');
|
|
220
274
|
else if (k === 'scope') inv.scope = (rest(c.text) || leafItems(c).join(' ')).toLowerCase();
|
|
@@ -288,10 +342,15 @@ function parseHandler(trigger, node) {
|
|
|
288
342
|
};
|
|
289
343
|
}
|
|
290
344
|
|
|
345
|
+
// How a claim is verified (the six kinds of verification). Assertion = executable check;
|
|
346
|
+
// static = types/deps/architecture; runtime = logs/events/traces; evidence = recorded proof;
|
|
347
|
+
// tool = external scanner/CI; approval = human judgment; formal = mathematical proof.
|
|
348
|
+
export const VERIFY_KINDS = new Set(['assertion', 'static', 'runtime', 'evidence', 'tool', 'approval', 'formal']);
|
|
349
|
+
|
|
291
350
|
function upsertRule(list, statement, line) {
|
|
292
351
|
const id = slug(statement);
|
|
293
352
|
let r = list.find((x) => x.id === id);
|
|
294
|
-
if (!r) { r = { id, statement, because: null, verify: [], notes: [], line }; list.push(r); }
|
|
353
|
+
if (!r) { r = { id, statement, because: null, verify: [], verifications: [], notes: [], line }; list.push(r); }
|
|
295
354
|
return r;
|
|
296
355
|
}
|
|
297
356
|
|
|
@@ -299,7 +358,24 @@ function applyDetail(rule, node) {
|
|
|
299
358
|
for (const c of node.children) {
|
|
300
359
|
const kw = firstWord(c.text);
|
|
301
360
|
if (kw === 'because') rule.because = rest(c.text) || (c.children[0] && c.children[0].text) || null;
|
|
302
|
-
else if (kw === 'verify')
|
|
361
|
+
else if (kw === 'verify') {
|
|
362
|
+
const a = rest(c.text);
|
|
363
|
+
const m = a && a.match(/^by\s+(\w+)/i);
|
|
364
|
+
if (m) {
|
|
365
|
+
// Classified: `verify by <kind>` with the details as child lines.
|
|
366
|
+
const kind = m[1].toLowerCase();
|
|
367
|
+
const details = (c.children || []).filter((x) => !isNote(x)).map((x) => x.text.trim()).filter(Boolean);
|
|
368
|
+
rule.verifications.push({ kind: VERIFY_KINDS.has(kind) ? kind : 'unknown', declared: kind, details });
|
|
369
|
+
rule.verify.push(details.length ? `${kind}: ${details.join('; ')}` : `by ${kind}`);
|
|
370
|
+
} else {
|
|
371
|
+
// Legacy: `verify <text>` , an unclassified reference to a check.
|
|
372
|
+
const v = a || (c.children[0] && c.children[0].text) || '';
|
|
373
|
+
if (v) { rule.verify.push(v); rule.verifications.push({ kind: 'unclassified', declared: null, details: [v] }); }
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Explicit stable id (e.g. `id INV-G-001`): survives renames and moved files so semantic
|
|
377
|
+
// diffs, traceability, and durable proof stay stable. Falls back to the slug when absent.
|
|
378
|
+
else if (kw === 'id') { const v = rest(c.text) || (c.children[0] && c.children[0].text); if (v) { rule.id = v; rule.stableId = v; } }
|
|
303
379
|
else if (isNote(c)) rule.notes.push(parseNoteNode(c));
|
|
304
380
|
}
|
|
305
381
|
rule.verify = rule.verify.filter(Boolean);
|
|
@@ -329,7 +405,7 @@ export function parseIntent(source) {
|
|
|
329
405
|
// Distributed + failure semantics (Gap 3)
|
|
330
406
|
commands: [], handlers: [],
|
|
331
407
|
// Decisions, rules, process (Gap 4)
|
|
332
|
-
decisions: [],
|
|
408
|
+
decisions: [], properties: [], scenarios: [], evaluations: [],
|
|
333
409
|
// Governance: waivers , governed exceptions to blocking diagnostics (Gap 5)
|
|
334
410
|
waivers: [],
|
|
335
411
|
// Data purpose + privacy , governed data elements (Gap 6)
|
|
@@ -481,6 +557,9 @@ export function parseIntent(source) {
|
|
|
481
557
|
break;
|
|
482
558
|
}
|
|
483
559
|
case 'decision': ast.decisions.push(parseDecision(arg, node)); break;
|
|
560
|
+
case 'property': ast.properties.push(parseProperty(arg, node)); break;
|
|
561
|
+
case 'scenario': ast.scenarios.push(parseScenario(arg, node)); break;
|
|
562
|
+
case 'evaluation': ast.evaluations.push(parseEvaluation(arg, node)); break;
|
|
484
563
|
// ── System profile ──
|
|
485
564
|
case 'capability': {
|
|
486
565
|
const cap = { name: arg, description: null, implements: [], line: node.line };
|
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
|
+
}
|