@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
package/src/guard.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Runtime enforcement (intent-guard-v1) , turn intent into a guard that runs IN the application
|
|
2
|
+
// and actually blocks a forbidden action, so intent is load-bearing, not advisory. Deterministic,
|
|
3
|
+
// no AI. Pure ESM (parse + the pure runtime), so it is browser-safe , the same guard runs in a
|
|
4
|
+
// Node service or a browser app.
|
|
5
|
+
//
|
|
6
|
+
// Two things are deterministically enforceable at runtime, and this compiles both:
|
|
7
|
+
// 1. Decisions ARE policy. `assertAllowed(name, inputs)` runs the declared decision and throws
|
|
8
|
+
// if its result denies the action , the intent's rules become a hard gate in production.
|
|
9
|
+
// 2. Secrets must not leak. `redact(obj)` masks every field the intent declares secret (a
|
|
10
|
+
// Secret/Password/Token type, a pii/sensitive data element, or a secret-looking name), so
|
|
11
|
+
// wrapping a logger or a response with it enforces "never expose the token" at runtime.
|
|
12
|
+
|
|
13
|
+
import { parseIntent } from './parse.mjs';
|
|
14
|
+
import { evaluateDecision } from './runtime.mjs';
|
|
15
|
+
|
|
16
|
+
export const GUARD_SCHEMA = 'intent-guard-v1';
|
|
17
|
+
|
|
18
|
+
const SECRET_TYPES = new Set(['secret', 'password', 'passwd', 'jwt', 'token', 'apikey', 'api_key', 'privatekey', 'private_key', 'credential', 'cvv']);
|
|
19
|
+
const SENSITIVE_NAME = /^(.*[._-])?(pass(word|wd)?|secret|token|jwt|ssn|api[-_]?key|apikey|credential|cvv|private[-_]?key|card(number)?|cvc)([._-].*)?$/i;
|
|
20
|
+
// Decision results that mean "block" when the caller has not specified an explicit deny set.
|
|
21
|
+
const DENY_RE = /^(deny|denied|block|blocked|refuse|refused|reject|rejected|forbid|forbidden|escalate|escalated|review|needsreview|no)$/i;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compile an intent AST into a runtime guard.
|
|
25
|
+
* @param {object} ast
|
|
26
|
+
* @param {{ denyResults?: string[], mask?: string }} [opts] explicit deny-list + mask token
|
|
27
|
+
*/
|
|
28
|
+
export function buildGuard(ast, { denyResults, mask = '[redacted]' } = {}) {
|
|
29
|
+
const secretFields = new Set();
|
|
30
|
+
const consider = (f) => {
|
|
31
|
+
if (!f || !f.name) return;
|
|
32
|
+
if (SECRET_TYPES.has(String(f.type || '').toLowerCase()) || SENSITIVE_NAME.test(f.name)) secretFields.add(f.name);
|
|
33
|
+
};
|
|
34
|
+
for (const f of ast.inputs || []) consider(f);
|
|
35
|
+
for (const f of ast.outputs || []) consider(f);
|
|
36
|
+
for (const ev of ast.events || []) for (const f of ev.payload || []) consider(f);
|
|
37
|
+
for (const d of ast.dataElements || []) {
|
|
38
|
+
if (/pii|sensitive/i.test(d.classification || '')) {
|
|
39
|
+
const leaf = String(d.path || '').split('.').pop();
|
|
40
|
+
if (leaf) secretFields.add(leaf);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const denySet = denyResults ? new Set(denyResults.map(String)) : null;
|
|
45
|
+
const isDenied = (result) => (denySet ? denySet.has(String(result)) : DENY_RE.test(String(result ?? '').replace(/[^A-Za-z]/g, '')));
|
|
46
|
+
const isSecretKey = (k) => secretFields.has(k) || SENSITIVE_NAME.test(k);
|
|
47
|
+
const decisions = new Map((ast.decisions || []).map((d) => [d.name, d]));
|
|
48
|
+
|
|
49
|
+
// Deep-mask any field the intent declares secret. Cycle-safe AND depth-bounded: this runs in
|
|
50
|
+
// production (wrapping a logger), so it must never throw. Beyond MAX_DEPTH it stops descending
|
|
51
|
+
// rather than overflowing the stack on a pathologically deep object.
|
|
52
|
+
const MAX_DEPTH = 100;
|
|
53
|
+
function redact(value, seen = new WeakSet(), depth = 0) {
|
|
54
|
+
if (depth > MAX_DEPTH) return value;
|
|
55
|
+
if (Array.isArray(value)) return value.map((v) => redact(v, seen, depth + 1));
|
|
56
|
+
if (value && typeof value === 'object') {
|
|
57
|
+
if (seen.has(value)) return value;
|
|
58
|
+
seen.add(value);
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const [k, v] of Object.entries(value)) out[k] = isSecretKey(k) ? mask : redact(v, seen, depth + 1);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function decide(name, inputs) {
|
|
67
|
+
const d = decisions.get(name);
|
|
68
|
+
if (!d) throw new Error(`intent guard: no decision "${name}"`);
|
|
69
|
+
const r = evaluateDecision(d, inputs || {});
|
|
70
|
+
return { ...r, allowed: !isDenied(r.result) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertAllowed(name, inputs) {
|
|
74
|
+
const r = decide(name, inputs);
|
|
75
|
+
if (!r.allowed) {
|
|
76
|
+
const e = new Error(`intent guard: decision "${name}" denied the action (result: ${r.result})`);
|
|
77
|
+
e.code = 'INTENT_GUARD_DENIED';
|
|
78
|
+
e.decision = name;
|
|
79
|
+
e.result = r.result;
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
return r;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
schema: GUARD_SCHEMA,
|
|
87
|
+
secretFields: [...secretFields],
|
|
88
|
+
decisions: [...decisions.keys()],
|
|
89
|
+
neverRules: (ast.neverRules || []).map((n) => n.statement),
|
|
90
|
+
redact,
|
|
91
|
+
decide,
|
|
92
|
+
assertAllowed,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Compile intent SOURCE into a runtime guard. */
|
|
97
|
+
export function compileGuard(intentText, opts) {
|
|
98
|
+
return buildGuard(parseIntent(String(intentText ?? '')), opts);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A JSON-able summary of what a guard would enforce (for `intent guard` / audits). */
|
|
102
|
+
export function guardSummary(ast) {
|
|
103
|
+
const g = buildGuard(ast);
|
|
104
|
+
return { schema: GUARD_SCHEMA, redactsFields: g.secretFields, enforcesDecisions: g.decisions, neverRules: g.neverRules };
|
|
105
|
+
}
|
package/src/guardian.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Intent Guardian (intent-guardian-v1) , continuous drift detection between an approved intent and
|
|
2
|
+
// the evolving one. Given a BEFORE and AFTER state, it answers the directive's question: "what
|
|
3
|
+
// changed, what intent could it affect, what risk did it introduce, what must be reverified, and
|
|
4
|
+
// what learning content should be refreshed?" Deterministic, no AI. It composes the shared spine:
|
|
5
|
+
// the semantic diff (diffGraphs) + the Intent Scanner (findings) over Intent IR. Pure ESM.
|
|
6
|
+
//
|
|
7
|
+
// guardianReport(beforeFiles, afterFiles) -> a drift report
|
|
8
|
+
// where each is [{ file, source }] (a single-file change is a one-element array).
|
|
9
|
+
|
|
10
|
+
import { diffGraphs } from './semantic-diff.mjs';
|
|
11
|
+
import { scanProject } from './scan.mjs';
|
|
12
|
+
import { parseIntent } from './parse.mjs';
|
|
13
|
+
import { buildIntentGraph } from './intent-graph.mjs';
|
|
14
|
+
|
|
15
|
+
export const GUARDIAN_SCHEMA = 'intent-guardian-v1';
|
|
16
|
+
|
|
17
|
+
// Merge a project's files into ONE graph keyed by mission-based node ids (NOT file paths), so a
|
|
18
|
+
// before/after comparison aligns by logical identity even across a rename. First id wins on dupes.
|
|
19
|
+
function mergedGraph(files) {
|
|
20
|
+
const nodes = []; const relationships = []; const seen = new Set();
|
|
21
|
+
for (const { source } of files || []) {
|
|
22
|
+
const g = buildIntentGraph(parseIntent(String(source ?? '')));
|
|
23
|
+
for (const n of g.nodes) if (!seen.has(n.id)) { seen.add(n.id); nodes.push(n); }
|
|
24
|
+
relationships.push(...g.relationships);
|
|
25
|
+
}
|
|
26
|
+
return { nodes, relationships };
|
|
27
|
+
}
|
|
28
|
+
const dedupeById = (arr) => { const seen = new Set(); return arr.filter((x) => x && x.id && !seen.has(x.id) && seen.add(x.id)); };
|
|
29
|
+
|
|
30
|
+
const findingKey = (f) => `${f.ruleId}|${f.detected}`;
|
|
31
|
+
// Node types whose change invalidates verification / needs a human to re-confirm.
|
|
32
|
+
const REVERIFY_TYPES = new Set(['Guarantee', 'Never', 'VerificationRule', 'VerificationResult', 'OutcomeContract', 'Decision', 'Rule']);
|
|
33
|
+
const LEARNABLE_MISSION = (n) => n.type === 'Mission';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compare two project states and report the drift a change introduced.
|
|
37
|
+
* @param {Array<{file:string, source:string}>} beforeFiles
|
|
38
|
+
* @param {Array<{file:string, source:string}>} afterFiles
|
|
39
|
+
*/
|
|
40
|
+
export function guardianReport(beforeFiles, afterFiles) {
|
|
41
|
+
const before = scanProject(beforeFiles || []);
|
|
42
|
+
const after = scanProject(afterFiles || []);
|
|
43
|
+
// Diff by mission-based identity (not file-prefixed scan ids) so before/after align logically.
|
|
44
|
+
const diff = diffGraphs(mergedGraph(beforeFiles), mergedGraph(afterFiles));
|
|
45
|
+
|
|
46
|
+
// Risk delta: findings present after but not before were introduced by the change; vice versa.
|
|
47
|
+
const beforeKeys = new Set(before.findings.map(findingKey));
|
|
48
|
+
const afterKeys = new Set(after.findings.map(findingKey));
|
|
49
|
+
const introducedRisk = after.findings.filter((f) => !beforeKeys.has(findingKey(f)));
|
|
50
|
+
const resolvedRisk = before.findings.filter((f) => !afterKeys.has(findingKey(f)));
|
|
51
|
+
|
|
52
|
+
// What changed at the node level (added/removed/changed), and which of those need re-verification.
|
|
53
|
+
const changedNodeIds = new Set([...diff.addedNodes.map((n) => n.id), ...diff.removedNodes.map((n) => n.id), ...diff.changedNodes.map((c) => c.id)]);
|
|
54
|
+
const touched = dedupeById([...diff.addedNodes, ...diff.removedNodes, ...diff.changedNodes.map((c) => c.after || c.before)]);
|
|
55
|
+
const mustReverify = touched
|
|
56
|
+
.filter((n) => REVERIFY_TYPES.has(n.type))
|
|
57
|
+
.map((n) => ({ id: n.id, type: n.type, title: n.title || null, reason: 'contract element changed , its verification no longer holds' }));
|
|
58
|
+
for (const ap of diff.invalidatedApprovals || []) mustReverify.push({ id: ap, type: 'Approval', reason: 'the mission contract changed , this approval is invalidated' });
|
|
59
|
+
|
|
60
|
+
// Learning freshness: any mission whose intent changed has lessons to refresh (Part 6). A mission
|
|
61
|
+
// is affected if its own node changed OR any node under it (a guarantee/never/...) changed.
|
|
62
|
+
const affectedMissions = dedupeById(touched.filter(LEARNABLE_MISSION));
|
|
63
|
+
const nonMissionChanged = touched.some((n) => !LEARNABLE_MISSION(n));
|
|
64
|
+
const missionTitles = affectedMissions.length ? affectedMissions : (nonMissionChanged ? after.ir.nodes.filter(LEARNABLE_MISSION).map((n) => ({ id: n.id, title: n.title })) : []);
|
|
65
|
+
const staleLearning = dedupeById(missionTitles).map((m) => ({ scope: m.title || m.id, reason: 'a governing intent artifact changed , lessons for it may be stale' }));
|
|
66
|
+
|
|
67
|
+
const introducedBlockers = introducedRisk.filter((f) => f.severity === 'blocker' || f.severity === 'error').length;
|
|
68
|
+
// needs-attention only when the change INTRODUCED blocking risk (an improvement that merely adds
|
|
69
|
+
// verification is `review`, not an alarm); `review` when anything changed; else `clear`.
|
|
70
|
+
const verdict = introducedBlockers > 0 ? 'needs-attention'
|
|
71
|
+
: (changedNodeIds.size > 0 || mustReverify.length > 0 || introducedRisk.length > 0) ? 'review' : 'clear';
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
schema: GUARDIAN_SCHEMA,
|
|
75
|
+
verdict,
|
|
76
|
+
changed: {
|
|
77
|
+
nodesAdded: diff.addedNodes.length,
|
|
78
|
+
nodesRemoved: diff.removedNodes.length,
|
|
79
|
+
nodesChanged: diff.changedNodes.length,
|
|
80
|
+
relationshipsAdded: diff.addedRelationships.length,
|
|
81
|
+
relationshipsRemoved: diff.removedRelationships.length,
|
|
82
|
+
},
|
|
83
|
+
affectedIntent: dedupeById(missionTitles).map((n) => ({ id: n.id, title: n.title || null })),
|
|
84
|
+
introducedRisk,
|
|
85
|
+
resolvedRisk,
|
|
86
|
+
mustReverify,
|
|
87
|
+
invalidatedApprovals: diff.invalidatedApprovals || [],
|
|
88
|
+
staleLearning,
|
|
89
|
+
summary: {
|
|
90
|
+
verdict,
|
|
91
|
+
introduced: introducedRisk.length,
|
|
92
|
+
introducedBlocking: introducedBlockers,
|
|
93
|
+
resolved: resolvedRisk.length,
|
|
94
|
+
mustReverify: mustReverify.length,
|
|
95
|
+
staleLearning: staleLearning.length,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
package/src/hash.mjs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Pure, dependency-free SHA-256 (sync) , the single hash used across the whole compiler.
|
|
2
|
+
// It exists so the analysis layer has NO Node.js dependency: the same code runs in Node
|
|
3
|
+
// (CLI, OpenThunder server), in a browser bundle (SkillsTech Studio, Repo Mastery web), and
|
|
4
|
+
// in React Native (SkillsTech Mobile). Output is byte-identical to node:crypto's
|
|
5
|
+
// createHash('sha256'), so every existing proof hash, ledger hash, and test stays valid.
|
|
6
|
+
//
|
|
7
|
+
// This is the keystone of "one compiler, five consumers": with it, `@skillstech/thunderlang`
|
|
8
|
+
// and `@skillstech/thunderlang/core` are the same source of truth everywhere.
|
|
9
|
+
|
|
10
|
+
const K = new Uint32Array([
|
|
11
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
12
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
13
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
14
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
15
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
16
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
17
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
18
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// UTF-8 encode a string to bytes with ZERO global dependencies (no TextEncoder, no Buffer),
|
|
22
|
+
// so it runs in any JS engine , Node, browsers, and Hermes/React Native, where TextEncoder is
|
|
23
|
+
// not guaranteed. Handles the full BMP + surrogate pairs; output matches TextEncoder exactly.
|
|
24
|
+
function utf8(str) {
|
|
25
|
+
const s = String(str);
|
|
26
|
+
const out = [];
|
|
27
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
28
|
+
let cp = s.charCodeAt(i);
|
|
29
|
+
// combine a high+low surrogate pair into a single code point
|
|
30
|
+
if (cp >= 0xd800 && cp <= 0xdbff && i + 1 < s.length) {
|
|
31
|
+
const lo = s.charCodeAt(i + 1);
|
|
32
|
+
if (lo >= 0xdc00 && lo <= 0xdfff) { cp = 0x10000 + ((cp - 0xd800) << 10) + (lo - 0xdc00); i += 1; }
|
|
33
|
+
}
|
|
34
|
+
if (cp < 0x80) out.push(cp);
|
|
35
|
+
else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f));
|
|
36
|
+
else if (cp < 0x10000) out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
|
|
37
|
+
else out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
|
|
38
|
+
}
|
|
39
|
+
return Uint8Array.from(out);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Lowercase hex SHA-256 digest of a string (no prefix). Deterministic, sync, Node-free. */
|
|
43
|
+
export function sha256hex(input) {
|
|
44
|
+
const msg = typeof input === 'string' ? utf8(input) : input;
|
|
45
|
+
const l = msg.length;
|
|
46
|
+
// padded length: message + 0x80 + zeros + 8-byte length, rounded up to a 64-byte block.
|
|
47
|
+
const withOne = l + 1;
|
|
48
|
+
const k = (56 - (withOne % 64) + 64) % 64;
|
|
49
|
+
const total = withOne + k + 8;
|
|
50
|
+
const buf = new Uint8Array(total);
|
|
51
|
+
buf.set(msg, 0);
|
|
52
|
+
buf[l] = 0x80;
|
|
53
|
+
const bitLenHi = Math.floor((l / 0x20000000)); // (l*8) high 32 bits
|
|
54
|
+
const bitLenLo = (l << 3) >>> 0;
|
|
55
|
+
const dv = new DataView(buf.buffer);
|
|
56
|
+
dv.setUint32(total - 8, bitLenHi >>> 0, false);
|
|
57
|
+
dv.setUint32(total - 4, bitLenLo, false);
|
|
58
|
+
|
|
59
|
+
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a;
|
|
60
|
+
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19;
|
|
61
|
+
const w = new Uint32Array(64);
|
|
62
|
+
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
|
|
63
|
+
|
|
64
|
+
for (let off = 0; off < total; off += 64) {
|
|
65
|
+
for (let i = 0; i < 16; i += 1) w[i] = dv.getUint32(off + i * 4, false);
|
|
66
|
+
for (let i = 16; i < 64; i += 1) {
|
|
67
|
+
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
|
68
|
+
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
|
69
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
|
70
|
+
}
|
|
71
|
+
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, hh = h7;
|
|
72
|
+
for (let i = 0; i < 64; i += 1) {
|
|
73
|
+
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
74
|
+
const ch = (e & f) ^ (~e & g);
|
|
75
|
+
const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0;
|
|
76
|
+
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
77
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
78
|
+
const t2 = (S0 + maj) >>> 0;
|
|
79
|
+
hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
|
80
|
+
}
|
|
81
|
+
h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0;
|
|
82
|
+
h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0; h6 = (h6 + g) >>> 0; h7 = (h7 + hh) >>> 0;
|
|
83
|
+
}
|
|
84
|
+
const hex = (x) => x.toString(16).padStart(8, '0');
|
|
85
|
+
return hex(h0) + hex(h1) + hex(h2) + hex(h3) + hex(h4) + hex(h5) + hex(h6) + hex(h7);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The canonical hash the ecosystem uses: `sha256:<hex>`. */
|
|
89
|
+
export const sha256 = (s) => `sha256:${sha256hex(String(s))}`;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Round-trip IMPORT adapters , the inverse of exporters.mjs. Lift an external DMN decision
|
|
2
|
+
// table or BPMN process back into ThunderLang source, so intent can come FROM the tools teams
|
|
3
|
+
// already use, not only go to them. Deterministic and pure. The design contract is
|
|
4
|
+
// round-trip fidelity: fromDMN(toDMN(ast)) and fromBPMN(toBPMN(ast)) reconstruct source that
|
|
5
|
+
// BEHAVES identically (same decisions, same lifecycle walks), even if cosmetic names differ.
|
|
6
|
+
|
|
7
|
+
import { parseXml, findAll, find, childrenNamed, localName } from './xml.mjs';
|
|
8
|
+
|
|
9
|
+
export const IMPORT_FORMATS = ['dmn', 'bpmn'];
|
|
10
|
+
export const IMPORT_SCHEMA = 'intent-import-v1';
|
|
11
|
+
|
|
12
|
+
// A no-op warning collector, so the source-only public functions carry no cost.
|
|
13
|
+
const NOOP_WARN = () => {};
|
|
14
|
+
|
|
15
|
+
const textOf = (node) => (node ? (find(node, 'text')?.text ?? node.text ?? '').trim() : '');
|
|
16
|
+
const stripQuotes = (s) => String(s ?? '').trim().replace(/^["'](.*)["']$/s, '$1');
|
|
17
|
+
// A safe ThunderLang identifier from arbitrary text.
|
|
18
|
+
const idish = (s, fb = 'x') => {
|
|
19
|
+
const v = String(s ?? '').trim();
|
|
20
|
+
return v || fb;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Detect the format of an XML document ('dmn' | 'bpmn' | null). */
|
|
24
|
+
export function detectFormat(xml) {
|
|
25
|
+
const s = String(xml ?? '');
|
|
26
|
+
if (/<(\w+:)?definitions[^>]*DMN/i.test(s) || /<(\w+:)?decision\b/i.test(s)) return 'dmn';
|
|
27
|
+
if (/<(\w+:)?definitions[^>]*BPMN/i.test(s) || /<(\w+:)?process\b/i.test(s)) return 'bpmn';
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Reconstruct a `when` expression from a DMN rule's input entries. Handles BOTH the way we
|
|
32
|
+
// export (the whole boolean expression dumped in one cell) and proper DMN unary tests
|
|
33
|
+
// (an operator or bare value relative to the input expression).
|
|
34
|
+
function ruleWhen(inputExprs, entries) {
|
|
35
|
+
const parts = [];
|
|
36
|
+
for (let i = 0; i < entries.length; i++) {
|
|
37
|
+
const t = (entries[i] || '').trim();
|
|
38
|
+
if (t === '' || t === '-') continue;
|
|
39
|
+
const expr = (inputExprs[i] || '').trim();
|
|
40
|
+
if (/[A-Za-z_][\w.]*\s*(>=|<=|==|!=|>|<|=|\band\b|\bor\b|\bin\b)/.test(t)) {
|
|
41
|
+
parts.push(t); // already a full boolean expression (our export, or a rich cell)
|
|
42
|
+
} else if (/^(>=|<=|==|!=|>|<)/.test(t)) {
|
|
43
|
+
parts.push(`${expr} ${t}`.trim()); // unary test: "age" + ">= 18"
|
|
44
|
+
} else {
|
|
45
|
+
parts.push(`${expr} == ${stripQuotes(t)}`.trim()); // bare value: "region" == "US"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return parts.join(' and ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// DMN -> intent source. `warn(code, message, subject)` collects what could not be faithfully
|
|
52
|
+
// represented, so callers can surface the fidelity loss (via importReport).
|
|
53
|
+
function dmnToIntent(xml, warn = NOOP_WARN) {
|
|
54
|
+
const doc = parseXml(xml);
|
|
55
|
+
const defs = find(doc, 'definitions') || doc;
|
|
56
|
+
const mission = idish(defs.attrs.name, 'ImportedDecision');
|
|
57
|
+
const lines = [`mission ${mission.replace(/\s+/g, '')}`, ''];
|
|
58
|
+
let decisions = 0;
|
|
59
|
+
let rules = 0;
|
|
60
|
+
|
|
61
|
+
for (const dec of findAll(doc, 'decision')) {
|
|
62
|
+
const dname = idish(dec.attrs.name || dec.attrs.id, 'Decision').replace(/\s+/g, '');
|
|
63
|
+
const table = find(dec, 'decisionTable');
|
|
64
|
+
if (!table) { warn('IL-IMP-DMN-001', `Decision "${dname}" has no decisionTable and was skipped.`, dname); continue; }
|
|
65
|
+
decisions += 1;
|
|
66
|
+
|
|
67
|
+
const hitPolicy = (table.attrs.hitPolicy || 'UNIQUE').toUpperCase();
|
|
68
|
+
if (!['FIRST', 'UNIQUE', 'ANY', ''].includes(hitPolicy)) {
|
|
69
|
+
warn('IL-IMP-DMN-002', `Decision "${dname}" uses hit policy ${hitPolicy}; ThunderLang evaluates first-match, so semantics may differ.`, dname);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const inputEls = childrenNamed(table, 'input');
|
|
73
|
+
const inputExprs = inputEls.map((inp) => textOf(find(inp, 'inputExpression')) || inp.attrs.label || '');
|
|
74
|
+
const inputNames = inputExprs.map((e, i) => (e || `in${i + 1}`).replace(/\s+/g, ''));
|
|
75
|
+
if (childrenNamed(table, 'output').length > 1) warn('IL-IMP-DMN-004', `Decision "${dname}" has multiple output columns; only the first is imported.`, dname);
|
|
76
|
+
|
|
77
|
+
lines.push(`decision ${dname}`);
|
|
78
|
+
if (inputNames.length) {
|
|
79
|
+
lines.push(' inputs');
|
|
80
|
+
for (const n of inputNames) lines.push(` ${n}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let defaultReturn = null;
|
|
84
|
+
let ruleN = 0;
|
|
85
|
+
for (const rule of childrenNamed(table, 'rule')) {
|
|
86
|
+
const entries = childrenNamed(rule, 'inputEntry').map((e) => textOf(e));
|
|
87
|
+
const out = stripQuotes(textOf(childrenNamed(rule, 'outputEntry')[0]));
|
|
88
|
+
const when = ruleWhen(inputExprs, entries);
|
|
89
|
+
if (!when) { defaultReturn = out || defaultReturn; continue; } // all-dash rule = default
|
|
90
|
+
if (!out) warn('IL-IMP-DMN-003', `A rule in "${dname}" has a condition but no result; imported with an empty return.`, dname);
|
|
91
|
+
ruleN += 1; rules += 1;
|
|
92
|
+
lines.push(` rule r${ruleN}`);
|
|
93
|
+
lines.push(` when ${when}`);
|
|
94
|
+
lines.push(` return ${out}`);
|
|
95
|
+
}
|
|
96
|
+
if (defaultReturn != null) {
|
|
97
|
+
lines.push(' default');
|
|
98
|
+
lines.push(` return ${defaultReturn}`);
|
|
99
|
+
}
|
|
100
|
+
lines.push('');
|
|
101
|
+
}
|
|
102
|
+
return { source: lines.join('\n'), stats: { decisions, rules } };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// BPMN -> intent source, with warnings for constructs a lifecycle cannot model.
|
|
106
|
+
function bpmnToIntent(xml, warn = NOOP_WARN) {
|
|
107
|
+
const doc = parseXml(xml);
|
|
108
|
+
const defs = find(doc, 'definitions') || doc;
|
|
109
|
+
const mission = idish(defs.attrs.name || 'ImportedProcess', 'ImportedProcess');
|
|
110
|
+
const lines = [`mission ${mission.replace(/\s+/g, '')}`, ''];
|
|
111
|
+
let processes = 0;
|
|
112
|
+
let statesTotal = 0;
|
|
113
|
+
let transitionsTotal = 0;
|
|
114
|
+
|
|
115
|
+
for (const proc of findAll(doc, 'process')) {
|
|
116
|
+
processes += 1;
|
|
117
|
+
const pname = idish(proc.attrs.name || proc.attrs.id, 'Process').replace(/\s+/g, '');
|
|
118
|
+
const taskEls = (proc.children || []).filter((c) => /task$/i.test(localName(c.name)) || localName(c.name) === 'subProcess');
|
|
119
|
+
const startIds = new Set(findAll(proc, 'startEvent').map((e) => e.attrs.id));
|
|
120
|
+
const endIds = new Set(findAll(proc, 'endEvent').map((e) => e.attrs.id));
|
|
121
|
+
const nameById = {};
|
|
122
|
+
for (const t of taskEls) nameById[t.attrs.id] = idish(t.attrs.name || t.attrs.id, 'State').replace(/\s+/g, '');
|
|
123
|
+
|
|
124
|
+
// Constructs a state machine cannot represent.
|
|
125
|
+
const gateways = (proc.children || []).filter((c) => /gateway$/i.test(localName(c.name)));
|
|
126
|
+
if (gateways.length) warn('IL-IMP-BPMN-001', `Process "${pname}" has ${gateways.length} gateway(s); branching is flattened into direct transitions.`, pname);
|
|
127
|
+
const intermediates = (proc.children || []).filter((c) => /^intermediate/i.test(localName(c.name)));
|
|
128
|
+
if (intermediates.length) warn('IL-IMP-BPMN-004', `Process "${pname}" has ${intermediates.length} intermediate event(s); not modeled as lifecycle states.`, pname);
|
|
129
|
+
if (taskEls.length === 0) warn('IL-IMP-BPMN-005', `Process "${pname}" has no tasks; the lifecycle has no states.`, pname);
|
|
130
|
+
|
|
131
|
+
const states = taskEls.map((t) => nameById[t.attrs.id]);
|
|
132
|
+
const transitions = [];
|
|
133
|
+
const terminals = new Set();
|
|
134
|
+
for (const flow of childrenNamed(proc, 'sequenceFlow')) {
|
|
135
|
+
const src = flow.attrs.sourceRef;
|
|
136
|
+
const tgt = flow.attrs.targetRef;
|
|
137
|
+
if (find(flow, 'conditionExpression')) warn('IL-IMP-BPMN-002', `A sequence flow in "${pname}" carries a condition; ThunderLang transitions have no guards, so the condition is dropped.`, pname);
|
|
138
|
+
if (startIds.has(src)) continue; // start -> initial (IL infers initial from no inbound)
|
|
139
|
+
if (endIds.has(tgt)) { if (nameById[src]) terminals.add(nameById[src]); continue; }
|
|
140
|
+
if (nameById[src] && nameById[tgt]) {
|
|
141
|
+
transitions.push({ name: idish(flow.attrs.name, `t${transitions.length + 1}`).replace(/\s+/g, ''), from: nameById[src], to: nameById[tgt] });
|
|
142
|
+
} else {
|
|
143
|
+
warn('IL-IMP-BPMN-003', `A sequence flow in "${pname}" references a non-task node (a gateway or event) and was dropped.`, pname);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
statesTotal += states.length; transitionsTotal += transitions.length;
|
|
148
|
+
lines.push(`lifecycle ${pname}`);
|
|
149
|
+
for (const s of states) lines.push(` state ${s}`);
|
|
150
|
+
for (const tr of transitions) {
|
|
151
|
+
lines.push(` transition ${tr.name}`);
|
|
152
|
+
lines.push(` from ${tr.from}`);
|
|
153
|
+
lines.push(` to ${tr.to}`);
|
|
154
|
+
}
|
|
155
|
+
if (terminals.size) lines.push(` terminal ${[...terminals].join(', ')}`);
|
|
156
|
+
lines.push('');
|
|
157
|
+
}
|
|
158
|
+
return { source: lines.join('\n'), stats: { processes, states: statesTotal, transitions: transitionsTotal } };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Import a DMN 1.3 document into ThunderLang source (one `decision` block per DMN decision). */
|
|
162
|
+
export function fromDMN(xml) {
|
|
163
|
+
return dmnToIntent(xml).source;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Import a BPMN 2.0 document into ThunderLang source (one `lifecycle` block per process). */
|
|
167
|
+
export function fromBPMN(xml) {
|
|
168
|
+
return bpmnToIntent(xml).source;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Dispatch by format (or auto-detect when format is omitted). Returns ThunderLang source, or null. */
|
|
172
|
+
export function importIntent(xml, format) {
|
|
173
|
+
const fmt = format || detectFormat(xml);
|
|
174
|
+
if (fmt === 'dmn') return fromDMN(xml);
|
|
175
|
+
if (fmt === 'bpmn') return fromBPMN(xml);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Import WITH a fidelity report , the same source, plus the warnings for everything the
|
|
181
|
+
* source format expressed that ThunderLang could not faithfully represent, plus stats.
|
|
182
|
+
* This is what Studio surfaces so a user knows exactly what an import dropped.
|
|
183
|
+
* @returns {{schema, format, source, warnings, stats, ok} | null}
|
|
184
|
+
*/
|
|
185
|
+
export function importReport(xml, format) {
|
|
186
|
+
const fmt = format || detectFormat(xml);
|
|
187
|
+
const warnings = [];
|
|
188
|
+
const warn = (code, message, subject) => warnings.push({ code, message, ...(subject ? { subject } : {}) });
|
|
189
|
+
let result;
|
|
190
|
+
if (fmt === 'dmn') result = dmnToIntent(xml, warn);
|
|
191
|
+
else if (fmt === 'bpmn') result = bpmnToIntent(xml, warn);
|
|
192
|
+
else return null;
|
|
193
|
+
return { schema: IMPORT_SCHEMA, format: fmt, source: result.source, warnings, stats: result.stats, ok: warnings.length === 0 };
|
|
194
|
+
}
|