@skillstech/thunderlang 0.1.6
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 +375 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/core.cjs +6768 -0
- package/dist/index.cjs +9308 -0
- package/intent-graph.schema.json +687 -0
- package/package.json +84 -0
- package/src/ai-core.mjs +67 -0
- package/src/ai-events.mjs +56 -0
- package/src/ai.mjs +324 -0
- package/src/arch.mjs +55 -0
- package/src/atlas.mjs +118 -0
- package/src/changes.mjs +74 -0
- package/src/classification.mjs +36 -0
- package/src/cli.mjs +1534 -0
- package/src/codegen.mjs +214 -0
- package/src/compile.mjs +142 -0
- package/src/comprehension.mjs +130 -0
- package/src/conflict.mjs +0 -0
- package/src/core.d.ts +99 -0
- package/src/core.mjs +92 -0
- package/src/data-schema.mjs +137 -0
- package/src/decision.mjs +38 -0
- package/src/distributed.mjs +48 -0
- package/src/draft.mjs +101 -0
- package/src/drift.mjs +177 -0
- package/src/emit.mjs +519 -0
- package/src/exporters.mjs +245 -0
- package/src/expr.mjs +245 -0
- package/src/fable.mjs +110 -0
- package/src/focus.mjs +151 -0
- package/src/format.mjs +55 -0
- package/src/governance.mjs +100 -0
- package/src/graph-source.mjs +292 -0
- package/src/guard.mjs +105 -0
- package/src/guardian.mjs +98 -0
- package/src/hash.mjs +89 -0
- package/src/importers.mjs +194 -0
- package/src/index.d.ts +725 -0
- package/src/index.mjs +185 -0
- package/src/intellisense.mjs +210 -0
- package/src/intent-atlas.mjs +77 -0
- package/src/intent-graph.mjs +346 -0
- package/src/intent-ir.mjs +144 -0
- package/src/intent-schema.mjs +215 -0
- package/src/ledger.mjs +109 -0
- package/src/lifecycle.mjs +56 -0
- package/src/lift.mjs +693 -0
- package/src/lsp.mjs +152 -0
- package/src/mcp.mjs +158 -0
- package/src/migrate.mjs +118 -0
- package/src/outcome.mjs +93 -0
- package/src/parse.mjs +733 -0
- package/src/patch.mjs +364 -0
- package/src/privacy.mjs +61 -0
- package/src/proof-schema.mjs +129 -0
- package/src/report.mjs +84 -0
- package/src/runtime.mjs +96 -0
- package/src/sarif.mjs +88 -0
- package/src/scan-queries.mjs +97 -0
- package/src/scan.mjs +87 -0
- package/src/security.mjs +73 -0
- package/src/select.mjs +80 -0
- package/src/semantic-diff.mjs +125 -0
- package/src/simulate.mjs +106 -0
- package/src/style.mjs +250 -0
- package/src/sync.mjs +103 -0
- package/src/testing.mjs +59 -0
- package/src/twelve-factor.mjs +173 -0
- package/src/verify-diff.mjs +105 -0
- package/src/xml.mjs +87 -0
- package/syntaxes/intent.tmLanguage.json +55 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Export adapters (founder roadmap, IL-owned interop). The Intent Graph is the source of
|
|
2
|
+
// truth; these render slices of it into industry-standard formats so intent can be checked
|
|
3
|
+
// by existing tooling without leaving ThunderLang:
|
|
4
|
+
// - toDMN(ast) , decisions -> DMN 1.3 decision tables (OMG DMN)
|
|
5
|
+
// - toBPMN(ast) , lifecycles -> BPMN 2.0 processes (OMG BPMN)
|
|
6
|
+
// - toSMV(ast) , lifecycles + temporal -> NuSMV/nuXmv finite-state model + specs
|
|
7
|
+
// All three are deterministic and pure (string in, string out). They export ONLY what is
|
|
8
|
+
// declared; a mission with no decisions/lifecycles yields an empty-but-valid document.
|
|
9
|
+
|
|
10
|
+
import { buildLifecycle } from './lifecycle.mjs';
|
|
11
|
+
import { toJSONSchema, toOpenAPI } from './data-schema.mjs';
|
|
12
|
+
import { toDesignTokens, toCss } from './style.mjs';
|
|
13
|
+
import { buildIntentGraph } from './intent-graph.mjs';
|
|
14
|
+
|
|
15
|
+
export { toDesignTokens, toCss };
|
|
16
|
+
export const EXPORT_FORMATS = ['dmn', 'bpmn', 'smv', 'jsonschema', 'openapi', 'tokens', 'mermaid', 'css', 'playwright'];
|
|
17
|
+
|
|
18
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
19
|
+
// A safe XML NCName / SMV identifier from arbitrary text (letters, digits, _; never leading digit).
|
|
20
|
+
const xmlId = (s, fallback = 'id') => {
|
|
21
|
+
const v = String(s ?? '').trim().replace(/[^A-Za-z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
|
|
22
|
+
return /^[A-Za-z_]/.test(v) ? v : `${fallback}_${v || '0'}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ── DMN 1.3 ──────────────────────────────────────────────────────────────────
|
|
26
|
+
/**
|
|
27
|
+
* Render declared decisions (Gap 4) as DMN 1.3 decision tables. Hit policy FIRST:
|
|
28
|
+
* rules are ordered, the first match wins, and the mission `default` becomes the final
|
|
29
|
+
* catch-all rule. The `when` expression lands in the first input column (the rest are
|
|
30
|
+
* "-" / any) so the full condition is preserved verbatim.
|
|
31
|
+
*/
|
|
32
|
+
export function toDMN(ast) {
|
|
33
|
+
const decisions = ast.decisions || [];
|
|
34
|
+
const defsId = xmlId(ast.mission, 'intent');
|
|
35
|
+
const body = decisions.map((dec) => {
|
|
36
|
+
const decId = xmlId(dec.name, 'decision');
|
|
37
|
+
const inputs = dec.inputs.length ? dec.inputs : ['condition'];
|
|
38
|
+
const inputCols = inputs.map((inp) => ` <input id="in_${decId}_${xmlId(inp)}" label="${esc(inp)}">
|
|
39
|
+
<inputExpression id="ie_${decId}_${xmlId(inp)}" typeRef="string"><text>${esc(inp)}</text></inputExpression>
|
|
40
|
+
</input>`).join('\n');
|
|
41
|
+
const ruleRow = (when, result, id) => {
|
|
42
|
+
const entries = inputs.map((_inp, i) => ` <inputEntry id="ien_${id}_${i}"><text>${esc(i === 0 ? (when ?? '-') : '-')}</text></inputEntry>`).join('\n');
|
|
43
|
+
return ` <rule id="rule_${id}">
|
|
44
|
+
${entries}
|
|
45
|
+
<outputEntry id="oen_${id}"><text>${esc(result != null ? `"${result}"` : '-')}</text></outputEntry>
|
|
46
|
+
</rule>`;
|
|
47
|
+
};
|
|
48
|
+
const rules = dec.rules.map((r, i) => ruleRow(r.when, r.result, `${decId}_${i}`)).join('\n');
|
|
49
|
+
const defRule = dec.default != null ? '\n' + ruleRow(null, dec.default, `${decId}_default`) : '';
|
|
50
|
+
return ` <decision id="dec_${decId}" name="${esc(dec.name)}">
|
|
51
|
+
<decisionTable id="dt_${decId}" hitPolicy="FIRST">
|
|
52
|
+
${inputCols}
|
|
53
|
+
<output id="out_${decId}" name="result" typeRef="string" />
|
|
54
|
+
${rules}${defRule}
|
|
55
|
+
</decisionTable>
|
|
56
|
+
</decision>`;
|
|
57
|
+
}).join('\n');
|
|
58
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
59
|
+
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/" id="defs_${defsId}" name="${esc(ast.mission || 'intent')}" namespace="https://skillstech.dev/intent/dmn">
|
|
60
|
+
${body}
|
|
61
|
+
</definitions>
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── BPMN 2.0 ─────────────────────────────────────────────────────────────────
|
|
66
|
+
/**
|
|
67
|
+
* Render declared lifecycles (Gap 2) as BPMN 2.0 processes. Each lifecycle state becomes a
|
|
68
|
+
* task, each transition a sequence flow (named after the transition), the initial state is
|
|
69
|
+
* entered from a start event, and terminal states flow to an end event.
|
|
70
|
+
*/
|
|
71
|
+
export function toBPMN(ast) {
|
|
72
|
+
const procs = (ast.lifecycles || []).map((lc) => {
|
|
73
|
+
const ir = buildLifecycle(lc);
|
|
74
|
+
const pid = xmlId(lc.name, 'process');
|
|
75
|
+
const sid = (s) => `t_${pid}_${xmlId(s)}`;
|
|
76
|
+
const tasks = ir.states.map((s) => ` <task id="${sid(s)}" name="${esc(s)}" />`).join('\n');
|
|
77
|
+
const flows = [];
|
|
78
|
+
if (ir.initial) flows.push(` <startEvent id="start_${pid}" />`, ` <sequenceFlow id="f_start_${pid}" sourceRef="start_${pid}" targetRef="${sid(ir.initial)}" />`);
|
|
79
|
+
ir.transitions.forEach((t, i) => {
|
|
80
|
+
if (!t.from || !t.to) return;
|
|
81
|
+
flows.push(` <sequenceFlow id="f_${pid}_${i}" name="${esc(t.name || '')}" sourceRef="${sid(t.from)}" targetRef="${sid(t.to)}" />`);
|
|
82
|
+
});
|
|
83
|
+
const terminals = ir.terminals.length ? ir.terminals : ir.states.filter((s) => (ir.out[s] || []).length === 0);
|
|
84
|
+
if (terminals.length) {
|
|
85
|
+
flows.push(` <endEvent id="end_${pid}" />`);
|
|
86
|
+
terminals.forEach((s, i) => flows.push(` <sequenceFlow id="f_end_${pid}_${i}" sourceRef="${sid(s)}" targetRef="end_${pid}" />`));
|
|
87
|
+
}
|
|
88
|
+
return ` <process id="proc_${pid}" name="${esc(lc.name)}" isExecutable="false">
|
|
89
|
+
${tasks}
|
|
90
|
+
${flows.join('\n')}
|
|
91
|
+
</process>`;
|
|
92
|
+
}).join('\n');
|
|
93
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
94
|
+
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" id="defs_${xmlId(ast.mission, 'intent')}" targetNamespace="https://skillstech.dev/intent/bpmn">
|
|
95
|
+
${procs}
|
|
96
|
+
</definitions>
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── NuSMV / nuXmv model checking ─────────────────────────────────────────────
|
|
101
|
+
/**
|
|
102
|
+
* Render declared lifecycles (Gap 2) as a NuSMV finite-state model, with the transition
|
|
103
|
+
* relation taken faithfully from the state machine. Emits derivable, checkable specs:
|
|
104
|
+
* - EF(state = T) for each terminal T (the terminal is reachable), and
|
|
105
|
+
* - a SPEC skeleton for each temporal declaration (always/eventually/until), with the
|
|
106
|
+
* intent text as a comment and a bound-me boolean, ready for a human to bind the AP.
|
|
107
|
+
* One MODULE per lifecycle (the first is `main` so the file is runnable as-is).
|
|
108
|
+
*/
|
|
109
|
+
export function toSMV(ast) {
|
|
110
|
+
const lcs = ast.lifecycles || [];
|
|
111
|
+
if (!lcs.length) return '-- no lifecycle declared; nothing to model-check\n';
|
|
112
|
+
|
|
113
|
+
const txt = (s) => (typeof s === 'string' ? s : (s.statement || s.text || '')).trim();
|
|
114
|
+
const temporal = [
|
|
115
|
+
...(ast.always || []).map((s) => ({ kind: 'AG', text: txt(s) })),
|
|
116
|
+
...(ast.eventually || []).map((s) => ({ kind: 'AF', text: txt(s), within: s.within })),
|
|
117
|
+
...(ast.until || []).map((s) => ({ kind: 'AU', text: txt(s) })),
|
|
118
|
+
].filter((t) => t.text);
|
|
119
|
+
|
|
120
|
+
return lcs.map((lc, idx) => {
|
|
121
|
+
const ir = buildLifecycle(lc);
|
|
122
|
+
const name = idx === 0 ? 'main' : xmlId(lc.name, 'lifecycle');
|
|
123
|
+
const stateVals = ir.states.map((s) => xmlId(s, 's'));
|
|
124
|
+
const initial = ir.initial ? xmlId(ir.initial, 's') : (stateVals[0] || 's0');
|
|
125
|
+
const cases = ir.states.map((s) => {
|
|
126
|
+
const targets = (ir.out[s] || []).map((t) => xmlId(t, 's'));
|
|
127
|
+
const rhs = targets.length ? (targets.length === 1 ? targets[0] : `{${targets.join(', ')}}`) : xmlId(s, 's'); // self-loop if no outgoing (deadlock modeled)
|
|
128
|
+
return ` state = ${xmlId(s, 's')} : ${rhs};`;
|
|
129
|
+
}).join('\n');
|
|
130
|
+
const reachSpecs = (ir.terminals.length ? ir.terminals : []).map((t) => `-- terminal "${t}" is reachable\nSPEC EF (state = ${xmlId(t, 's')});`).join('\n');
|
|
131
|
+
const tempSpecs = idx === 0 ? temporal.map((t, i) => {
|
|
132
|
+
const op = t.kind === 'AF' ? 'AF' : t.kind === 'AU' ? 'A[ p_bind U q_bind ]' : 'AG';
|
|
133
|
+
const spec = t.kind === 'AU' ? `SPEC ${op};` : `SPEC ${op} (p_${i});`;
|
|
134
|
+
return `-- ${t.kind === 'AF' ? 'eventually' : t.kind === 'AU' ? 'until' : 'always'}: ${t.text}${t.within ? ` (within ${t.within})` : ''}\n-- bind p_${i}/q_bind to the atomic proposition, then check:\n-- ${spec}`;
|
|
135
|
+
}).join('\n') : '';
|
|
136
|
+
return `MODULE ${name}
|
|
137
|
+
-- generated from lifecycle "${lc.name}" (intent-graph-v1)
|
|
138
|
+
VAR
|
|
139
|
+
state : {${stateVals.join(', ')}};
|
|
140
|
+
ASSIGN
|
|
141
|
+
init(state) := ${initial};
|
|
142
|
+
next(state) := case
|
|
143
|
+
${cases}
|
|
144
|
+
esac;
|
|
145
|
+
${reachSpecs ? '\n' + reachSpecs : ''}${tempSpecs ? '\n' + tempSpecs : ''}`;
|
|
146
|
+
}).join('\n\n') + '\n';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Dispatch by format name. Returns { format, ext, content } or null for unknown formats. */
|
|
150
|
+
// ── Mermaid (full Intent Graph flowchart) ───────────────────────────────────
|
|
151
|
+
/**
|
|
152
|
+
* Render the whole Intent Graph as a Mermaid `graph TD` , every canonical node with a
|
|
153
|
+
* shape by category and every typed relationship as a labeled edge. Unlike the mission
|
|
154
|
+
* summary emitted into the build's `.mmd` artifact, this is the complete graph, so it
|
|
155
|
+
* pastes into any Markdown/GitHub/Notion surface as a live diagram. Deterministic.
|
|
156
|
+
*/
|
|
157
|
+
export function toMermaid(ast) {
|
|
158
|
+
const graph = buildIntentGraph(ast);
|
|
159
|
+
const L = ['graph TD'];
|
|
160
|
+
const mid = (id) => `n_${String(id).replace(/[^A-Za-z0-9]+/g, '_')}`;
|
|
161
|
+
// Mermaid labels break on quotes/brackets/pipes/angles; normalize to plain text.
|
|
162
|
+
const lbl = (s) => String(s ?? '').replace(/"/g, "'").replace(/[[\]{}()<>|#]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
163
|
+
// Node shape by category (rounded = states/lifecycle, hexagon = prohibitions/constraints,
|
|
164
|
+
// rhombus = decisions/rules, rectangle = everything else).
|
|
165
|
+
const shape = (type) => {
|
|
166
|
+
if (/State$/.test(type) || type === 'Lifecycle' || type === 'Journey') return ['(["', '"])'];
|
|
167
|
+
if (type === 'Never' || type === 'Constraint' || type === 'Guarantee') return ['{{"', '"}}'];
|
|
168
|
+
if (type === 'Decision' || type === 'Rule') return ['{"', '"}'];
|
|
169
|
+
return ['["', '"]'];
|
|
170
|
+
};
|
|
171
|
+
for (const n of graph.nodes) {
|
|
172
|
+
const [open, close] = shape(n.type);
|
|
173
|
+
L.push(` ${mid(n.id)}${open}${lbl(`${n.type}: ${n.title || n.id}`)}${close}`);
|
|
174
|
+
}
|
|
175
|
+
for (const r of graph.relationships) {
|
|
176
|
+
L.push(` ${mid(r.from)} -->|${lbl(r.type)}| ${mid(r.to)}`);
|
|
177
|
+
}
|
|
178
|
+
return `${L.join('\n')}\n`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Playwright (experience -> E2E test scaffold) ─────────────────────────────
|
|
182
|
+
// A SKELETON, not a passing test: declared experiences/journeys/states become structured
|
|
183
|
+
// Playwright stubs (describe/test/test.step) with TODOs for selectors + assertions. This is
|
|
184
|
+
// the test-plan target for the experience profile , it turns "what the UI must do" into the
|
|
185
|
+
// shape of the test that proves it, deterministically. Consistent with the compiler's scope
|
|
186
|
+
// (test scaffolds, not production code).
|
|
187
|
+
const jsStr = (s) => `'${String(s ?? '').replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\r?\n/g, ' ')}'`;
|
|
188
|
+
|
|
189
|
+
export function toPlaywright(ast) {
|
|
190
|
+
const exps = ast.experiences || [];
|
|
191
|
+
const L = [];
|
|
192
|
+
L.push('// Playwright test scaffold generated from experience intent by @skillstech/thunderlang.');
|
|
193
|
+
L.push('// SKELETON: fill in selectors and assertions. It is not a passing test until you do.');
|
|
194
|
+
L.push("import { test, expect } from '@playwright/test';");
|
|
195
|
+
L.push('');
|
|
196
|
+
if (!exps.length) {
|
|
197
|
+
L.push('// No `experience` blocks declared, so there is nothing to scaffold.');
|
|
198
|
+
return `${L.join('\n')}\n`;
|
|
199
|
+
}
|
|
200
|
+
for (const exp of exps) {
|
|
201
|
+
L.push(`test.describe(${jsStr(exp.name || 'experience')}, () => {`);
|
|
202
|
+
if (exp.accessible && exp.accessible.target) {
|
|
203
|
+
L.push(` // accessibility goal: ${exp.accessible.target} (proposed , verify with an a11y audit, do not assume met)`);
|
|
204
|
+
}
|
|
205
|
+
for (const j of exp.journeys || []) {
|
|
206
|
+
L.push(` test(${jsStr(j.name || 'journey')}, async ({ page }) => {`);
|
|
207
|
+
if (!(j.steps || []).length) L.push(' // TODO: no steps declared for this journey');
|
|
208
|
+
for (const step of j.steps || []) {
|
|
209
|
+
L.push(` await test.step(${jsStr(step)}, async () => {`);
|
|
210
|
+
L.push(' // TODO: implement this step');
|
|
211
|
+
L.push(' });');
|
|
212
|
+
}
|
|
213
|
+
L.push(' });');
|
|
214
|
+
}
|
|
215
|
+
for (const st of exp.states || []) {
|
|
216
|
+
if (st.hasRecovery) {
|
|
217
|
+
L.push(` test(${jsStr(`failure state "${st.name}" offers a recovery path`)}, async ({ page }) => {`);
|
|
218
|
+
L.push(` // TODO: drive the UI into "${st.name}", assert a recovery action is available`);
|
|
219
|
+
L.push(' });');
|
|
220
|
+
} else {
|
|
221
|
+
L.push(` test(${jsStr(`reaches state: ${st.name}`)}, async ({ page }) => {`);
|
|
222
|
+
L.push(` // TODO: drive the UI to "${st.name}" and assert it is shown`);
|
|
223
|
+
L.push(' });');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
L.push('});');
|
|
227
|
+
L.push('');
|
|
228
|
+
}
|
|
229
|
+
return `${L.join('\n')}\n`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function exportIntent(ast, format) {
|
|
233
|
+
switch (format) {
|
|
234
|
+
case 'dmn': return { format, ext: 'dmn', content: toDMN(ast) };
|
|
235
|
+
case 'bpmn': return { format, ext: 'bpmn', content: toBPMN(ast) };
|
|
236
|
+
case 'smv': return { format, ext: 'smv', content: toSMV(ast) };
|
|
237
|
+
case 'jsonschema': return { format, ext: 'schema.json', content: JSON.stringify(toJSONSchema(ast, { which: 'both' }), null, 2) + '\n' };
|
|
238
|
+
case 'openapi': return { format, ext: 'openapi.json', content: JSON.stringify(toOpenAPI(ast), null, 2) + '\n' };
|
|
239
|
+
case 'tokens': return { format, ext: 'tokens.json', content: JSON.stringify(toDesignTokens(ast), null, 2) + '\n' };
|
|
240
|
+
case 'mermaid': return { format, ext: 'mmd', content: toMermaid(ast) };
|
|
241
|
+
case 'css': return { format, ext: 'css', content: toCss(ast) };
|
|
242
|
+
case 'playwright': return { format, ext: 'spec.ts', content: toPlaywright(ast) };
|
|
243
|
+
default: return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
package/src/expr.mjs
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// A tiny, safe, DETERMINISTIC expression evaluator for decision conditions. No eval, no
|
|
2
|
+
// host access , it evaluates a `when` string against a plain inputs object and returns a
|
|
3
|
+
// value. This is what makes ThunderLang decisions EXECUTABLE: the same condition + inputs
|
|
4
|
+
// always yields the same result, with zero AI and zero code generation.
|
|
5
|
+
//
|
|
6
|
+
// Grammar (lowest -> highest precedence):
|
|
7
|
+
// or := and (('or' | '||') and)*
|
|
8
|
+
// and := not (('and' | '&&') not)*
|
|
9
|
+
// not := ('not' | '!') not | comparison
|
|
10
|
+
// compare := add ( ('>='|'<='|'=='|'!='|'='|'>'|'<') add | 'in' '[' list ']' )?
|
|
11
|
+
// add := mul (('+' | '-') mul)*
|
|
12
|
+
// mul := unary (('*' | '/' | '%') unary)*
|
|
13
|
+
// unary := '-' unary | primary
|
|
14
|
+
// primary := number | string | bool | ident(.ident)* | '(' or ')' | '[' list ']'
|
|
15
|
+
|
|
16
|
+
export class ExprError extends Error {}
|
|
17
|
+
|
|
18
|
+
const OPS = ['>=', '<=', '==', '!=', '&&', '||', '>', '<', '=', '+', '-', '*', '/', '%', '!', '(', ')', '[', ']', ','];
|
|
19
|
+
|
|
20
|
+
export function tokenize(src) {
|
|
21
|
+
const toks = [];
|
|
22
|
+
const s = String(src ?? '');
|
|
23
|
+
let i = 0;
|
|
24
|
+
const isIdStart = (c) => /[A-Za-z_]/.test(c);
|
|
25
|
+
const isId = (c) => /[A-Za-z0-9_.]/.test(c);
|
|
26
|
+
while (i < s.length) {
|
|
27
|
+
const c = s[i];
|
|
28
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
29
|
+
if (c === '"' || c === "'") {
|
|
30
|
+
let j = i + 1; let str = '';
|
|
31
|
+
while (j < s.length && s[j] !== c) { str += s[j]; j++; }
|
|
32
|
+
if (j >= s.length) throw new ExprError(`unterminated string in: ${src}`);
|
|
33
|
+
toks.push({ t: 'str', v: str }); i = j + 1; continue;
|
|
34
|
+
}
|
|
35
|
+
if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(s[i + 1] || ''))) {
|
|
36
|
+
let j = i; let num = '';
|
|
37
|
+
while (j < s.length && /[0-9.]/.test(s[j])) { num += s[j]; j++; }
|
|
38
|
+
toks.push({ t: 'num', v: Number(num) }); i = j; continue;
|
|
39
|
+
}
|
|
40
|
+
if (isIdStart(c)) {
|
|
41
|
+
let j = i; let id = '';
|
|
42
|
+
while (j < s.length && isId(s[j])) { id += s[j]; j++; }
|
|
43
|
+
const lower = id.toLowerCase();
|
|
44
|
+
if (lower === 'and' || lower === 'or' || lower === 'not' || lower === 'in') toks.push({ t: 'kw', v: lower });
|
|
45
|
+
else if (lower === 'true' || lower === 'false') toks.push({ t: 'bool', v: lower === 'true' });
|
|
46
|
+
else toks.push({ t: 'id', v: id });
|
|
47
|
+
i = j; continue;
|
|
48
|
+
}
|
|
49
|
+
const two = s.slice(i, i + 2);
|
|
50
|
+
if (OPS.includes(two)) { toks.push({ t: 'op', v: two }); i += 2; continue; }
|
|
51
|
+
if (OPS.includes(c)) { toks.push({ t: 'op', v: c }); i += 1; continue; }
|
|
52
|
+
throw new ExprError(`unexpected character "${c}" in: ${src}`);
|
|
53
|
+
}
|
|
54
|
+
return toks;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parse(toks) {
|
|
58
|
+
let pos = 0;
|
|
59
|
+
const peek = () => toks[pos];
|
|
60
|
+
const eat = (v) => { const t = toks[pos]; if (v && (!t || t.v !== v)) throw new ExprError(`expected "${v}"`); pos++; return t; };
|
|
61
|
+
const isKw = (v) => peek() && peek().t === 'kw' && peek().v === v;
|
|
62
|
+
const isOp = (v) => peek() && peek().t === 'op' && peek().v === v;
|
|
63
|
+
|
|
64
|
+
function orExpr() {
|
|
65
|
+
let node = andExpr();
|
|
66
|
+
while (isKw('or') || isOp('||')) { eat(); node = { k: 'or', a: node, b: andExpr() }; }
|
|
67
|
+
return node;
|
|
68
|
+
}
|
|
69
|
+
function andExpr() {
|
|
70
|
+
let node = notExpr();
|
|
71
|
+
while (isKw('and') || isOp('&&')) { eat(); node = { k: 'and', a: node, b: notExpr() }; }
|
|
72
|
+
return node;
|
|
73
|
+
}
|
|
74
|
+
function notExpr() {
|
|
75
|
+
if (isKw('not') || isOp('!')) { eat(); return { k: 'not', a: notExpr() }; }
|
|
76
|
+
return compare();
|
|
77
|
+
}
|
|
78
|
+
function compare() {
|
|
79
|
+
const left = add();
|
|
80
|
+
const t = peek();
|
|
81
|
+
if (t && t.t === 'op' && ['>=', '<=', '==', '!=', '=', '>', '<'].includes(t.v)) {
|
|
82
|
+
eat(); return { k: 'cmp', op: t.v === '=' ? '==' : t.v, a: left, b: add() };
|
|
83
|
+
}
|
|
84
|
+
if (isKw('in')) {
|
|
85
|
+
eat(); eat('['); const items = listExpr(); eat(']');
|
|
86
|
+
return { k: 'in', a: left, list: items };
|
|
87
|
+
}
|
|
88
|
+
return left;
|
|
89
|
+
}
|
|
90
|
+
function listExpr() {
|
|
91
|
+
const items = [];
|
|
92
|
+
if (isOp(']')) return items;
|
|
93
|
+
items.push(orExpr());
|
|
94
|
+
while (isOp(',')) { eat(); items.push(orExpr()); }
|
|
95
|
+
return items;
|
|
96
|
+
}
|
|
97
|
+
function add() {
|
|
98
|
+
let node = mul();
|
|
99
|
+
while (isOp('+') || isOp('-')) { const op = eat().v; node = { k: 'arith', op, a: node, b: mul() }; }
|
|
100
|
+
return node;
|
|
101
|
+
}
|
|
102
|
+
function mul() {
|
|
103
|
+
let node = unary();
|
|
104
|
+
while (isOp('*') || isOp('/') || isOp('%')) { const op = eat().v; node = { k: 'arith', op, a: node, b: unary() }; }
|
|
105
|
+
return node;
|
|
106
|
+
}
|
|
107
|
+
function unary() {
|
|
108
|
+
if (isOp('-')) { eat(); return { k: 'neg', a: unary() }; }
|
|
109
|
+
return primary();
|
|
110
|
+
}
|
|
111
|
+
function primary() {
|
|
112
|
+
const t = peek();
|
|
113
|
+
if (!t) throw new ExprError('unexpected end of expression');
|
|
114
|
+
if (t.t === 'num') { eat(); return { k: 'lit', v: t.v }; }
|
|
115
|
+
if (t.t === 'str') { eat(); return { k: 'lit', v: t.v }; }
|
|
116
|
+
if (t.t === 'bool') { eat(); return { k: 'lit', v: t.v }; }
|
|
117
|
+
if (t.t === 'id') { eat(); return { k: 'ref', path: t.v }; }
|
|
118
|
+
if (isOp('(')) { eat(); const n = orExpr(); eat(')'); return n; }
|
|
119
|
+
if (isOp('[')) { eat(); const items = listExpr(); eat(']'); return { k: 'list', items }; }
|
|
120
|
+
throw new ExprError(`unexpected token "${t.v}"`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const tree = orExpr();
|
|
124
|
+
if (pos !== toks.length) throw new ExprError('trailing tokens in expression');
|
|
125
|
+
return tree;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Resolve a dotted path (a.b.c) against the inputs object. Bare identifiers that are not
|
|
129
|
+
// present resolve to a Symbol so that `status == active` treats `active` as the literal
|
|
130
|
+
// string "active" (decision tables commonly use bare enum tokens on the right-hand side).
|
|
131
|
+
const UNRESOLVED = Symbol('unresolved');
|
|
132
|
+
function resolve(path, inputs) {
|
|
133
|
+
let cur = inputs;
|
|
134
|
+
for (const part of path.split('.')) {
|
|
135
|
+
if (cur != null && typeof cur === 'object' && part in cur) cur = cur[part];
|
|
136
|
+
else return UNRESOLVED;
|
|
137
|
+
}
|
|
138
|
+
return cur;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Numeric coercion for comparisons: if both sides look numeric, compare as numbers.
|
|
142
|
+
const asNum = (v) => (typeof v === 'number' ? v : (typeof v === 'string' && v.trim() !== '' && !isNaN(Number(v)) ? Number(v) : null));
|
|
143
|
+
|
|
144
|
+
function evalNode(n, inputs) {
|
|
145
|
+
switch (n.k) {
|
|
146
|
+
case 'lit': return n.v;
|
|
147
|
+
case 'list': return n.items.map((x) => evalNode(x, inputs));
|
|
148
|
+
case 'ref': {
|
|
149
|
+
const v = resolve(n.path, inputs);
|
|
150
|
+
return v === UNRESOLVED ? n.path : v; // bare token -> its own name (enum literal)
|
|
151
|
+
}
|
|
152
|
+
case 'or': return !!(evalNode(n.a, inputs) || evalNode(n.b, inputs));
|
|
153
|
+
case 'and': return !!(evalNode(n.a, inputs) && evalNode(n.b, inputs));
|
|
154
|
+
case 'not': return !evalNode(n.a, inputs);
|
|
155
|
+
case 'neg': { const r = -Number(evalNode(n.a, inputs)); return Number.isFinite(r) ? r : null; }
|
|
156
|
+
case 'arith': {
|
|
157
|
+
const a = Number(evalNode(n.a, inputs)); const b = Number(evalNode(n.b, inputs));
|
|
158
|
+
const r = n.op === '+' ? a + b : n.op === '-' ? a - b : n.op === '*' ? a * b : n.op === '/' ? a / b : a % b;
|
|
159
|
+
// Non-finite results (divide/modulo by zero, NaN from non-numeric operands) are
|
|
160
|
+
// neutralized to null so they can never leak a surprising truthy comparison (e.g.
|
|
161
|
+
// Infinity > 1). A deterministic decision must not silently match on 10 / 0.
|
|
162
|
+
return Number.isFinite(r) ? r : null;
|
|
163
|
+
}
|
|
164
|
+
case 'in': {
|
|
165
|
+
const a = evalNode(n.a, inputs);
|
|
166
|
+
return n.list.map((x) => evalNode(x, inputs)).some((v) => eq(a, v));
|
|
167
|
+
}
|
|
168
|
+
case 'cmp': {
|
|
169
|
+
const a = evalNode(n.a, inputs); const b = evalNode(n.b, inputs);
|
|
170
|
+
if (n.op === '==') return eq(a, b);
|
|
171
|
+
if (n.op === '!=') return !eq(a, b);
|
|
172
|
+
// An unknown / neutralized operand (null: a missing value or a divide-by-zero) cannot be
|
|
173
|
+
// ordered; every ordering comparison against it is false, never JS's null->0 coercion.
|
|
174
|
+
if (a === null || b === null) return false;
|
|
175
|
+
const na = asNum(a); const nb = asNum(b);
|
|
176
|
+
const [x, y] = (na != null && nb != null) ? [na, nb] : [a, b];
|
|
177
|
+
if (n.op === '>=') return x >= y;
|
|
178
|
+
if (n.op === '<=') return x <= y;
|
|
179
|
+
if (n.op === '>') return x > y;
|
|
180
|
+
if (n.op === '<') return x < y;
|
|
181
|
+
throw new ExprError(`unknown operator ${n.op}`);
|
|
182
|
+
}
|
|
183
|
+
default: throw new ExprError(`unknown node ${n.k}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function eq(a, b) {
|
|
188
|
+
const na = asNum(a); const nb = asNum(b);
|
|
189
|
+
if (na != null && nb != null) return na === nb;
|
|
190
|
+
return String(a) === String(b);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Compile a `when` string into a reusable predicate (throws ExprError on bad syntax). */
|
|
194
|
+
export function compileExpr(src) {
|
|
195
|
+
const tree = parse(tokenize(src));
|
|
196
|
+
return (inputs = {}) => evalNode(tree, inputs);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Evaluate a `when` string against inputs. Returns the value (booleans for conditions). */
|
|
200
|
+
export function evalExpr(src, inputs = {}) {
|
|
201
|
+
return compileExpr(src)(inputs);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Per-language rendering of the `when` grammar. `eq`/`neq` build an equality expression (JS uses
|
|
205
|
+
// ===, C# ==, Java Objects.equals for correct string value equality); `inList` builds membership.
|
|
206
|
+
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' },
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
function renderExpr(src, inputs, D) {
|
|
213
|
+
const known = new Set(inputs);
|
|
214
|
+
const r = (n) => {
|
|
215
|
+
switch (n.k) {
|
|
216
|
+
case 'or': return `(${r(n.a)} || ${r(n.b)})`;
|
|
217
|
+
case 'and': return `(${r(n.a)} && ${r(n.b)})`;
|
|
218
|
+
case 'not': return `!${r(n.a)}`;
|
|
219
|
+
case 'neg': return `-${r(n.a)}`;
|
|
220
|
+
case 'arith': return `(${r(n.a)} ${n.op} ${r(n.b)})`;
|
|
221
|
+
case 'cmp':
|
|
222
|
+
if (n.op === '==') return `(${D.eq(r(n.a), r(n.b))})`;
|
|
223
|
+
if (n.op === '!=') return `(${D.neq(r(n.a), r(n.b))})`;
|
|
224
|
+
return `(${r(n.a)} ${n.op} ${r(n.b)})`;
|
|
225
|
+
case 'in': return D.inList(n.list.map(r), r(n.a));
|
|
226
|
+
case 'list': return `[${n.items.map(r).join(', ')}]`;
|
|
227
|
+
case 'lit': return typeof n.v === 'string' ? JSON.stringify(n.v) : String(n.v);
|
|
228
|
+
case 'ref': return (known.has(n.path) || known.has(n.path.split('.')[0])) ? n.path : JSON.stringify(n.path);
|
|
229
|
+
default: return D.nil;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
return r(parse(tokenize(src)));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Render a `when` condition as an equivalent expression in a target language, for code generation.
|
|
237
|
+
* Input names render as identifiers; any other bare token is an enum literal (a string). Reuses
|
|
238
|
+
* the parser, so precedence is exact. `dialect` is js (default) | csharp | java.
|
|
239
|
+
*/
|
|
240
|
+
export function exprToCode(src, { inputs = [], dialect = 'js' } = {}) {
|
|
241
|
+
return renderExpr(src, inputs, DIALECTS[dialect] || DIALECTS.js);
|
|
242
|
+
}
|
|
243
|
+
export const exprToJs = (src, opts = {}) => exprToCode(src, { ...opts, dialect: 'js' });
|
|
244
|
+
export const exprToCSharp = (src, opts = {}) => exprToCode(src, { ...opts, dialect: 'csharp' });
|
|
245
|
+
export const exprToJava = (src, opts = {}) => exprToCode(src, { ...opts, dialect: 'java' });
|
package/src/fable.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Fable (intent-fable-v1) , the versioned, explainable rule authority the Intent Scanner runs.
|
|
2
|
+
// Anti-fork: Fable is NOT a new rule engine. It is a rule-metadata layer OVER ThunderLang's shipped
|
|
3
|
+
// DIAGNOSTIC_RULES catalog (which already carries id/severity/blocks/area), adding what a Scanner
|
|
4
|
+
// finding needs: a risk category, detection strategy, remediation, required evidence, suggested
|
|
5
|
+
// ThunderLang, and suppression/risk-acceptance policy. Every finding is explainable , never
|
|
6
|
+
// "AI detected a possible issue." Pure ESM, browser-safe.
|
|
7
|
+
|
|
8
|
+
import { ALL_DIAGNOSTICS } from './intent-schema.mjs';
|
|
9
|
+
|
|
10
|
+
export const FABLE_SCHEMA = 'intent-fable-v1';
|
|
11
|
+
|
|
12
|
+
// The Scanner's risk taxonomy (directive Part 3). Every finding rolls up to exactly one.
|
|
13
|
+
export const RISK_CATEGORIES = [
|
|
14
|
+
'Intent risk', 'Implementation risk', 'Architecture risk', 'Product risk', 'UX risk',
|
|
15
|
+
'Security risk', 'Privacy risk', 'Reliability risk', 'Operational risk', 'Data risk',
|
|
16
|
+
'Dependency risk', 'Knowledge risk', 'Verification risk', 'AI-generated-code risk',
|
|
17
|
+
'Organizational-policy risk',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Diagnostic area -> risk category. Deterministic; every catalog area is mapped.
|
|
21
|
+
const AREA_RISK = {
|
|
22
|
+
product: 'Product risk', evidence: 'Intent risk', graph: 'Intent risk', experience: 'UX risk',
|
|
23
|
+
conflict: 'Intent risk', governance: 'Organizational-policy risk', privacy: 'Privacy risk',
|
|
24
|
+
outcome: 'Product risk', decision: 'Implementation risk', distributed: 'Reliability risk',
|
|
25
|
+
lifecycle: 'Reliability risk', temporal: 'Verification risk', style: 'UX risk',
|
|
26
|
+
security: 'Security risk', type: 'Implementation risk', core: 'Intent risk',
|
|
27
|
+
architecture: 'Architecture risk', ai: 'AI-generated-code risk', note: 'Knowledge risk',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Remediation + suggested-verification guidance per area (a finding says how to fix it).
|
|
31
|
+
const AREA_REMEDIATION = {
|
|
32
|
+
security: 'Remove the secret from the surface, or gate it behind an auth requirement; add a never-rule and a scan test.',
|
|
33
|
+
privacy: 'State the purpose, retention, and lawful basis for the data; restrict exposure.',
|
|
34
|
+
core: 'Make the intent complete: state the goal, and attach a `verify` to each guarantee/never-rule.',
|
|
35
|
+
outcome: 'Give the outcome a metric, a target better than baseline, and a measurement window.',
|
|
36
|
+
decision: 'Add a default and resolve overlapping/contradictory rules.',
|
|
37
|
+
distributed: 'Add an idempotency key, a timeout, duplicate handling, or a compensation as required.',
|
|
38
|
+
lifecycle: 'Fix the transition/state so every state is reachable and can terminate.',
|
|
39
|
+
conflict: 'Resolve the contradiction or record a governed decision.',
|
|
40
|
+
style: 'Bind tokens to the canonical address space; declare the accessibility target as a proposed claim.',
|
|
41
|
+
type: 'Use a known semantic type or a PascalCase entity name.',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The Fable rule for a diagnostic code , the shipped catalog entry augmented with Scanner
|
|
46
|
+
* metadata. Returns a synthetic rule for unknown codes so a finding is always explainable.
|
|
47
|
+
*/
|
|
48
|
+
export function fableRuleFor(code) {
|
|
49
|
+
const base = ALL_DIAGNOSTICS.find((r) => r.ruleId === code);
|
|
50
|
+
const area = base?.area || 'core';
|
|
51
|
+
return {
|
|
52
|
+
ruleId: code,
|
|
53
|
+
ruleVersion: '1',
|
|
54
|
+
title: base?.summary || code,
|
|
55
|
+
category: AREA_RISK[area] || 'Intent risk',
|
|
56
|
+
area,
|
|
57
|
+
detection: 'deterministic', // every catalog check is deterministic, no AI
|
|
58
|
+
severity: base?.severity || 'warning',
|
|
59
|
+
defaultConfidence: 'Observed',
|
|
60
|
+
blocks: base?.blocks || [],
|
|
61
|
+
requiredEvidence: 'the source location(s) where the check fired',
|
|
62
|
+
remediation: AREA_REMEDIATION[area] || (base?.summary ? `Address: ${base.summary}` : 'Review and resolve.'),
|
|
63
|
+
suggestedVerification: area === 'core' || area === 'outcome' ? 'Add a test that proves the property, then re-scan.' : 'Add a check that would catch a regression.',
|
|
64
|
+
suppressible: base?.severity !== 'blocker',
|
|
65
|
+
riskAcceptable: true,
|
|
66
|
+
deprecated: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The universal Fable rule pack , every check-surface catalog rule as a Fable rule. */
|
|
71
|
+
export function universalPack() {
|
|
72
|
+
return {
|
|
73
|
+
schema: FABLE_SCHEMA,
|
|
74
|
+
pack: 'universal',
|
|
75
|
+
version: '1',
|
|
76
|
+
rules: ALL_DIAGNOSTICS.map((r) => fableRuleFor(r.ruleId)),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Map an intent-graph classification/provenance-free deterministic diagnostic to a confidence.
|
|
81
|
+
const confidenceFor = (rule) => (rule.detection === 'deterministic' ? 'Observed' : 'Inferred');
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Turn a compiler diagnostic (from semanticDiagnostics) into a rich Fable Finding , the directive's
|
|
85
|
+
* finding model: what/why/evidence/affected/severity/confidence/detection/remediation/... Every
|
|
86
|
+
* field is present so a finding is never unexplained.
|
|
87
|
+
*/
|
|
88
|
+
export function toFinding(diag, { file = null, index = 0, affectedNodes = [] } = {}) {
|
|
89
|
+
const rule = fableRuleFor(diag.code);
|
|
90
|
+
const isBlocker = diag.severity === 'blocker';
|
|
91
|
+
return {
|
|
92
|
+
findingId: `${(diag.code || 'FINDING').toLowerCase()}-${file ? file.replace(/[^a-z0-9]+/gi, '-') : 'x'}-${index}`,
|
|
93
|
+
ruleId: rule.ruleId,
|
|
94
|
+
ruleVersion: rule.ruleVersion,
|
|
95
|
+
category: rule.category,
|
|
96
|
+
detected: diag.message,
|
|
97
|
+
why: diag.why || rule.title,
|
|
98
|
+
evidence: [{ file, line: diag.line ?? null, kind: 'diagnostic', detail: diag.message }],
|
|
99
|
+
affectedNodes,
|
|
100
|
+
severity: isBlocker ? 'blocker' : (diag.level || 'warning'),
|
|
101
|
+
confidence: confidenceFor(rule),
|
|
102
|
+
detectionType: rule.detection,
|
|
103
|
+
potentialImpact: isBlocker ? 'Blocks a phase (e.g. release) until resolved.' : 'Weakens the intent; not blocking.',
|
|
104
|
+
remediation: rule.remediation,
|
|
105
|
+
suggestedVerification: rule.suggestedVerification,
|
|
106
|
+
humanReviewRequired: rule.detection !== 'deterministic',
|
|
107
|
+
suppressed: false,
|
|
108
|
+
riskAccepted: false,
|
|
109
|
+
};
|
|
110
|
+
}
|