@hyperscale0/hsx 5.7.0 → 6.0.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 +32 -0
- package/README.md +3 -4
- package/bin/hsx.ts +0 -5
- package/dist/bin/hsx.d.ts +0 -4
- package/dist/bin/hsx.d.ts.map +1 -1
- package/dist/bin/hsx.js +0 -4
- package/dist/bin/hsx.js.map +1 -1
- package/dist/src/compile.d.ts.map +1 -1
- package/dist/src/compile.js +278 -35
- package/dist/src/compile.js.map +1 -1
- package/dist/src/diagnostics.d.ts +2 -0
- package/dist/src/diagnostics.d.ts.map +1 -1
- package/dist/src/diagnostics.js +23 -0
- package/dist/src/diagnostics.js.map +1 -1
- package/dist/src/examples-bundle.d.ts.map +1 -1
- package/dist/src/examples-bundle.js +0 -1
- package/dist/src/examples-bundle.js.map +1 -1
- package/dist/src/headers.d.ts +1 -1
- package/dist/src/headers.d.ts.map +1 -1
- package/dist/src/headers.js +0 -1
- package/dist/src/headers.js.map +1 -1
- package/dist/src/std-bundle.d.ts.map +1 -1
- package/dist/src/std-bundle.js +3 -4
- package/dist/src/std-bundle.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/docs/README.md +16 -19
- package/package.json +5 -6
- package/src/compile.ts +363 -48
- package/src/diagnostics.ts +34 -0
- package/src/examples-bundle.ts +0 -1
- package/src/headers.ts +0 -1
- package/src/std-bundle.ts +3 -4
- package/src/version.ts +1 -1
- package/std/financing.hsx +3 -1
- package/std/marketplace.hsx +3 -9
- package/std/reporting.hsx +105 -676
- package/docs/examples.md +0 -22
- package/docs/headers.md +0 -46
- package/examples/travel.hsx +0 -29
- package/std/travel.hsx +0 -123
package/src/compile.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CompileFailure,
|
|
3
|
+
didYouMean,
|
|
4
|
+
fail,
|
|
5
|
+
failWithCode,
|
|
6
|
+
} from "./diagnostics.ts";
|
|
2
7
|
import { hash as sha256 } from "fast-sha256";
|
|
3
8
|
import { buildUdlCostManifest, type UdlCostManifest } from "./cost.ts";
|
|
4
9
|
import {
|
|
@@ -21,6 +26,7 @@ import {
|
|
|
21
26
|
type UdlField,
|
|
22
27
|
type UdlInstrument,
|
|
23
28
|
type UdlObjectField,
|
|
29
|
+
type UdlSelection,
|
|
24
30
|
type UdlSubjectRequirement,
|
|
25
31
|
type UdlValue,
|
|
26
32
|
} from "@hyperscale0/udl";
|
|
@@ -398,6 +404,18 @@ export function compile(
|
|
|
398
404
|
severity: "error",
|
|
399
405
|
stage,
|
|
400
406
|
});
|
|
407
|
+
// A program instrument lowers once standalone and once per attachment, so
|
|
408
|
+
// one authored mistake can fail each copy at the same place.
|
|
409
|
+
const distinct = (list: Diagnostic[]) =>
|
|
410
|
+
list.filter(
|
|
411
|
+
(d, index) =>
|
|
412
|
+
list.findIndex(
|
|
413
|
+
(other) =>
|
|
414
|
+
other.source === d.source &&
|
|
415
|
+
other.span.start === d.span.start &&
|
|
416
|
+
other.message === d.message,
|
|
417
|
+
) === index,
|
|
418
|
+
);
|
|
401
419
|
if (parsed.diagnostics.length)
|
|
402
420
|
return {
|
|
403
421
|
verdict: "invalid",
|
|
@@ -531,8 +549,6 @@ export function compile(
|
|
|
531
549
|
parties: Object.assign(Object.create(null) as UdlDocument["parties"], {
|
|
532
550
|
programOperator: { kind: "business", role: "program_operator" },
|
|
533
551
|
programTax: { kind: "business", role: "tax_payable" },
|
|
534
|
-
programFines: { kind: "business", role: "fine_payable" },
|
|
535
|
-
programCosts: { kind: "business", role: "cost_recovery" },
|
|
536
552
|
}),
|
|
537
553
|
objects: [],
|
|
538
554
|
instruments: [],
|
|
@@ -589,6 +605,10 @@ export function compile(
|
|
|
589
605
|
}
|
|
590
606
|
}
|
|
591
607
|
const origins: CompileOriginMapEntry[] = [];
|
|
608
|
+
// Lowered objects lose their spans, so each keeps the expression that
|
|
609
|
+
// produced it for the name check after lowering.
|
|
610
|
+
const authored = new WeakMap<object, Expr>();
|
|
611
|
+
const declarations = new WeakMap<UdlInstrument, InstrumentDecl>();
|
|
592
612
|
const exposures: { instrument: string; action: string; entry: Entry }[] =
|
|
593
613
|
[];
|
|
594
614
|
const refundBindings = new Map<
|
|
@@ -1179,14 +1199,11 @@ export function compile(
|
|
|
1179
1199
|
param.value.kind === "default" ? param.value.type : param.value;
|
|
1180
1200
|
const type =
|
|
1181
1201
|
t.kind === "type" || t.kind === "call" ? t.name : text(t);
|
|
1182
|
-
|
|
1183
|
-
(supplied.has(param.key) && !attachmentInfo) || type === "enum"
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
new Set(),
|
|
1188
|
-
!!attachmentInfo && type === "party",
|
|
1189
|
-
);
|
|
1202
|
+
const asWritten =
|
|
1203
|
+
(supplied.has(param.key) && !attachmentInfo) || type === "enum";
|
|
1204
|
+
let v = asWritten
|
|
1205
|
+
? actual
|
|
1206
|
+
: resolve(actual, new Set(), !!attachmentInfo && type === "party");
|
|
1190
1207
|
if (
|
|
1191
1208
|
type === "duration" &&
|
|
1192
1209
|
(v.kind === "text" || v.kind === "name") &&
|
|
@@ -1237,7 +1254,16 @@ export function compile(
|
|
|
1237
1254
|
? { role: v.value as SubjectPartyRole }
|
|
1238
1255
|
: { party: v.value };
|
|
1239
1256
|
} else if (type === "ref") {
|
|
1240
|
-
|
|
1257
|
+
// Listed objects resolve like a single binding, so `on: [plan]`
|
|
1258
|
+
// names the sibling attachment that `on: plan` names.
|
|
1259
|
+
const values =
|
|
1260
|
+
v.kind !== "list"
|
|
1261
|
+
? [v]
|
|
1262
|
+
: asWritten
|
|
1263
|
+
? v.items
|
|
1264
|
+
: v.items.map((item) => resolve(item));
|
|
1265
|
+
if (v.kind === "list")
|
|
1266
|
+
environment.set(param.key, { ...v, items: values });
|
|
1241
1267
|
if (v.kind === "list" && (t.kind !== "type" || !t.many))
|
|
1242
1268
|
fail(
|
|
1243
1269
|
v,
|
|
@@ -1587,9 +1613,11 @@ export function compile(
|
|
|
1587
1613
|
};
|
|
1588
1614
|
const val = (expr: Expr): UdlValue => {
|
|
1589
1615
|
const v = resolve(expr);
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1616
|
+
if (v.kind !== "name" || ["true", "false"].includes(v.value))
|
|
1617
|
+
return { literal: literal(v) };
|
|
1618
|
+
const value = { field: path(v) };
|
|
1619
|
+
authored.set(value, expr);
|
|
1620
|
+
return value;
|
|
1593
1621
|
};
|
|
1594
1622
|
const data = (expr: Expr): unknown => {
|
|
1595
1623
|
if (
|
|
@@ -1646,6 +1674,7 @@ export function compile(
|
|
|
1646
1674
|
if (k === "family" || k === "instrument") continue;
|
|
1647
1675
|
result[k] = data(v);
|
|
1648
1676
|
}
|
|
1677
|
+
authored.set(result, value);
|
|
1649
1678
|
return result;
|
|
1650
1679
|
}
|
|
1651
1680
|
|
|
@@ -1674,10 +1703,14 @@ export function compile(
|
|
|
1674
1703
|
};
|
|
1675
1704
|
return null;
|
|
1676
1705
|
}
|
|
1706
|
+
authored.set(result, value);
|
|
1677
1707
|
return result;
|
|
1678
1708
|
}
|
|
1679
|
-
if (value.kind === "list")
|
|
1680
|
-
|
|
1709
|
+
if (value.kind === "list") {
|
|
1710
|
+
const items = value.items.map(data).filter((item) => item !== null);
|
|
1711
|
+
authored.set(items, value);
|
|
1712
|
+
return items;
|
|
1713
|
+
}
|
|
1681
1714
|
if (
|
|
1682
1715
|
value.kind === "call" &&
|
|
1683
1716
|
value.name === "child" &&
|
|
@@ -1811,12 +1844,6 @@ export function compile(
|
|
|
1811
1844
|
f.book = t.args[1] ? text(t.args[1]) : "cash";
|
|
1812
1845
|
if (t.args[2]) {
|
|
1813
1846
|
const mode = text(t.args[2]);
|
|
1814
|
-
if (mode === "external")
|
|
1815
|
-
fail(
|
|
1816
|
-
t,
|
|
1817
|
-
"external account mode was removed",
|
|
1818
|
-
"use a reservation and instruction-bound evidence",
|
|
1819
|
-
);
|
|
1820
1847
|
if (mode === "contra") f.contra = true;
|
|
1821
1848
|
else f.key = mode;
|
|
1822
1849
|
}
|
|
@@ -1921,6 +1948,7 @@ export function compile(
|
|
|
1921
1948
|
`unknown calculation ${constant.name}`,
|
|
1922
1949
|
"use rate, sum, minimum, subtract, multiply, divide, before or after",
|
|
1923
1950
|
);
|
|
1951
|
+
authored.set(calculations.at(-1)!, constant);
|
|
1924
1952
|
} else if (type === "enum" && constant.kind === "name")
|
|
1925
1953
|
f.value = constant.value;
|
|
1926
1954
|
else if (type === "money" && constant.kind === "name")
|
|
@@ -1931,14 +1959,26 @@ export function compile(
|
|
|
1931
1959
|
});
|
|
1932
1960
|
else f.value = literal(constant, String(type));
|
|
1933
1961
|
}
|
|
1962
|
+
authored.set(f, row.value);
|
|
1934
1963
|
result.push(f as UdlField);
|
|
1935
1964
|
}
|
|
1936
1965
|
return result;
|
|
1937
1966
|
};
|
|
1938
1967
|
fields.push(...lowerFields(asBlock(body.get("fields"))));
|
|
1939
|
-
const
|
|
1940
|
-
|
|
1941
|
-
)
|
|
1968
|
+
const lifecycleBlock = asBlock(body.get("lifecycle"));
|
|
1969
|
+
const lifecycle = data(lifecycleBlock) as UdlInstrument["lifecycle"];
|
|
1970
|
+
const states = Array.isArray(lifecycle.states) ? lifecycle.states : [];
|
|
1971
|
+
const checkState = (expr: Expr, state: unknown) => {
|
|
1972
|
+
if (typeof state !== "string" || !states.length) return;
|
|
1973
|
+
if (states.includes(state)) return;
|
|
1974
|
+
fail(
|
|
1975
|
+
expr,
|
|
1976
|
+
`\`${state}\` is not a state of \`${decl.name}\`.${didYouMean(state, states)}`,
|
|
1977
|
+
`Use one of ${states.join(", ")}, or add \`${state}\` to \`lifecycle.states\`.`,
|
|
1978
|
+
);
|
|
1979
|
+
};
|
|
1980
|
+
const initialExpr = entries(lifecycleBlock).get("initial");
|
|
1981
|
+
if (initialExpr) checkState(initialExpr, lifecycle.initial);
|
|
1942
1982
|
lifecycle.transitions = Object.create(
|
|
1943
1983
|
null,
|
|
1944
1984
|
) as UdlInstrument["lifecycle"]["transitions"];
|
|
@@ -2288,6 +2328,7 @@ export function compile(
|
|
|
2288
2328
|
moves: [],
|
|
2289
2329
|
...(actionSubject ? { subject: actionSubject } : {}),
|
|
2290
2330
|
};
|
|
2331
|
+
authored.set(a, row.value);
|
|
2291
2332
|
for (const binding of boundaryBindings)
|
|
2292
2333
|
if (
|
|
2293
2334
|
!a.subject?.adapters.find((entry) => entry.binding === binding)
|
|
@@ -2309,12 +2350,12 @@ export function compile(
|
|
|
2309
2350
|
`action ${name} needs from and to`,
|
|
2310
2351
|
"declare its source and destination states",
|
|
2311
2352
|
);
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
};
|
|
2353
|
+
const sources = from!.kind === "list" ? from!.items : [from!];
|
|
2354
|
+
const fromStates = sources.map((e) => String(data(e)));
|
|
2355
|
+
const toState = String(data(to!));
|
|
2356
|
+
sources.forEach((e, index) => checkState(e, fromStates[index]));
|
|
2357
|
+
if (toState !== "preserve") checkState(to!, toState);
|
|
2358
|
+
lifecycle.transitions[name] = { from: fromStates, to: toState };
|
|
2318
2359
|
}
|
|
2319
2360
|
for (const [key, expr] of slots) {
|
|
2320
2361
|
if (["from", "to", "input", "summary", "subject"].includes(key))
|
|
@@ -2322,6 +2363,7 @@ export function compile(
|
|
|
2322
2363
|
if (key === "moves") {
|
|
2323
2364
|
const moves = expr.kind === "list" ? expr.items : [expr];
|
|
2324
2365
|
for (const [index, move] of moves.entries()) {
|
|
2366
|
+
const lowered = a.moves.length;
|
|
2325
2367
|
const parts = entries(asBlock(move));
|
|
2326
2368
|
const op = parts.has("operation")
|
|
2327
2369
|
? String(data(parts.get("operation")!))
|
|
@@ -2571,6 +2613,8 @@ export function compile(
|
|
|
2571
2613
|
"split percentages must sum to 100%",
|
|
2572
2614
|
"give the recipients exactly 100% together",
|
|
2573
2615
|
);
|
|
2616
|
+
for (const item of a.moves.slice(lowered))
|
|
2617
|
+
authored.set(item, move);
|
|
2574
2618
|
continue;
|
|
2575
2619
|
}
|
|
2576
2620
|
if (!amount || !from || !to)
|
|
@@ -2767,6 +2811,8 @@ export function compile(
|
|
|
2767
2811
|
`unknown transfer instruction ${op}`,
|
|
2768
2812
|
"choose create, reserve, post, or void from internal_transfer",
|
|
2769
2813
|
);
|
|
2814
|
+
for (const item of a.moves.slice(lowered))
|
|
2815
|
+
authored.set(item, move);
|
|
2770
2816
|
}
|
|
2771
2817
|
} else (a as unknown as Record<string, unknown>)[key] = data(expr);
|
|
2772
2818
|
}
|
|
@@ -2911,6 +2957,7 @@ export function compile(
|
|
|
2911
2957
|
};
|
|
2912
2958
|
}
|
|
2913
2959
|
document.instruments.push(inst);
|
|
2960
|
+
declarations.set(inst, decl);
|
|
2914
2961
|
for (const [key, definition] of records) {
|
|
2915
2962
|
const childAttachment = attachmentInfo
|
|
2916
2963
|
? {
|
|
@@ -3267,7 +3314,7 @@ export function compile(
|
|
|
3267
3314
|
if (bindingDiagnostics.length)
|
|
3268
3315
|
return {
|
|
3269
3316
|
verdict: "invalid",
|
|
3270
|
-
diagnostics: bindingDiagnostics
|
|
3317
|
+
diagnostics: distinct(bindingDiagnostics)
|
|
3271
3318
|
.sort((a, b) => a.span.start - b.span.start)
|
|
3272
3319
|
.map((d) => diagnostic(d, "check")),
|
|
3273
3320
|
};
|
|
@@ -3517,6 +3564,287 @@ export function compile(
|
|
|
3517
3564
|
);
|
|
3518
3565
|
}
|
|
3519
3566
|
}
|
|
3567
|
+
// Names resolve here, once tunables, attachments and derived fields are
|
|
3568
|
+
// final, and each failure points at the expression that wrote the name.
|
|
3569
|
+
type Located = { span: Span; source?: string };
|
|
3570
|
+
type Scope = { inst: UdlInstrument; label: string; action?: UdlAction };
|
|
3571
|
+
const byId = new Map(document.instruments.map((inst) => [inst.id, inst]));
|
|
3572
|
+
const site = (owner: object, key: string | number, fallback: Located) => {
|
|
3573
|
+
const expr = authored.get(owner);
|
|
3574
|
+
if (expr?.kind === "block")
|
|
3575
|
+
return expr.entries.find((entry) => entry.key === key)?.value ?? expr;
|
|
3576
|
+
if (expr?.kind === "list" && Array.isArray(owner))
|
|
3577
|
+
return expr.items.length === owner.length
|
|
3578
|
+
? expr.items[Number(key)]!
|
|
3579
|
+
: expr;
|
|
3580
|
+
return expr ?? fallback;
|
|
3581
|
+
};
|
|
3582
|
+
const keySite = (owner: object, key: string, fallback: Located) => {
|
|
3583
|
+
const expr = authored.get(owner);
|
|
3584
|
+
const entry =
|
|
3585
|
+
expr?.kind === "block"
|
|
3586
|
+
? expr.entries.find((item) => item.key === key)
|
|
3587
|
+
: undefined;
|
|
3588
|
+
if (!entry) return fallback;
|
|
3589
|
+
return {
|
|
3590
|
+
...entry,
|
|
3591
|
+
span: { start: entry.span.start, end: entry.span.start + key.length },
|
|
3592
|
+
};
|
|
3593
|
+
};
|
|
3594
|
+
const builtins = (inst: UdlInstrument) => [
|
|
3595
|
+
"id",
|
|
3596
|
+
"now",
|
|
3597
|
+
"createdAt",
|
|
3598
|
+
"productRevision",
|
|
3599
|
+
"status",
|
|
3600
|
+
...(inst.subject ? ["subject"] : []),
|
|
3601
|
+
];
|
|
3602
|
+
const parties = (inst: UdlInstrument) => [
|
|
3603
|
+
...Object.keys(document.parties),
|
|
3604
|
+
...(inst.subject ? subjectPartyRoles : []),
|
|
3605
|
+
];
|
|
3606
|
+
const members = (field: UdlField): string[] => {
|
|
3607
|
+
if (field.type === "account") return ["balance", "reserved"];
|
|
3608
|
+
if (field.type !== "ref") return [];
|
|
3609
|
+
const [id] = [field.target].flat();
|
|
3610
|
+
const target =
|
|
3611
|
+
field.targetKind === "object"
|
|
3612
|
+
? document.objects.find((object) => object.id === id)
|
|
3613
|
+
: byId.get(id!);
|
|
3614
|
+
if (!target) return [];
|
|
3615
|
+
return [
|
|
3616
|
+
...target.fields.map((item) => item.name),
|
|
3617
|
+
...("lifecycle" in target ? builtins(target) : []),
|
|
3618
|
+
];
|
|
3619
|
+
};
|
|
3620
|
+
const checkParty = (name: string, at: Located, inst: UdlInstrument) => {
|
|
3621
|
+
const names = parties(inst);
|
|
3622
|
+
if (!names.includes(name))
|
|
3623
|
+
fail(
|
|
3624
|
+
at,
|
|
3625
|
+
`\`${name}\` is not a declared party.${didYouMean(name, names)}`,
|
|
3626
|
+
`Declare it with \`party ${name}: business\`, or name a declared party (${names.join(", ")}).`,
|
|
3627
|
+
);
|
|
3628
|
+
};
|
|
3629
|
+
const checkInput = (
|
|
3630
|
+
name: string,
|
|
3631
|
+
at: Located,
|
|
3632
|
+
label: string,
|
|
3633
|
+
input: readonly UdlField[],
|
|
3634
|
+
) => {
|
|
3635
|
+
const names = input.map((field) => field.name);
|
|
3636
|
+
if (!names.includes(name))
|
|
3637
|
+
fail(
|
|
3638
|
+
at,
|
|
3639
|
+
`\`${name}\` is not an input of \`${label}\`.${didYouMean(name, names)}`,
|
|
3640
|
+
`Declare \`${name}\` in the input of \`${label}\`${names.length ? `, or use one of ${names.join(", ")}` : ""}.`,
|
|
3641
|
+
);
|
|
3642
|
+
};
|
|
3643
|
+
const checkTarget = (id: string, at: Located, objects = false) => {
|
|
3644
|
+
const ids = [
|
|
3645
|
+
...byId.keys(),
|
|
3646
|
+
...(objects ? document.objects.map((object) => object.id) : []),
|
|
3647
|
+
];
|
|
3648
|
+
const noun = objects ? "instrument or object" : "instrument";
|
|
3649
|
+
if (!ids.includes(id))
|
|
3650
|
+
fail(
|
|
3651
|
+
at,
|
|
3652
|
+
`\`${id}\` is not a declared ${noun}.${didYouMean(id, ids)}`,
|
|
3653
|
+
`Name a declared ${noun}.`,
|
|
3654
|
+
);
|
|
3655
|
+
};
|
|
3656
|
+
const checkPath = (path: string, at: Located, scope: Scope): void => {
|
|
3657
|
+
const { inst, action } = scope;
|
|
3658
|
+
const parts = path.split(".");
|
|
3659
|
+
const found = (count: number) =>
|
|
3660
|
+
resolveField(
|
|
3661
|
+
document,
|
|
3662
|
+
inst,
|
|
3663
|
+
parts.slice(0, count).join("."),
|
|
3664
|
+
action?.input,
|
|
3665
|
+
action,
|
|
3666
|
+
);
|
|
3667
|
+
// Subject paths keep their own check against the action's requirements.
|
|
3668
|
+
if (found(parts.length) || parts[0] === "subject") return;
|
|
3669
|
+
if (parts[0] === "party" && parts[1]) {
|
|
3670
|
+
checkParty(parts[1], at, inst);
|
|
3671
|
+
if (!found(2)) return;
|
|
3672
|
+
}
|
|
3673
|
+
if (!["self", "input", "party"].includes(parts[0]!))
|
|
3674
|
+
parts.unshift("self");
|
|
3675
|
+
let count = 2;
|
|
3676
|
+
while (count <= parts.length && found(count)) count++;
|
|
3677
|
+
if (count > parts.length) return;
|
|
3678
|
+
const name = parts[count - 1]!;
|
|
3679
|
+
if (count > 2) {
|
|
3680
|
+
const prefix = parts.slice(0, count - 1).join(".");
|
|
3681
|
+
const names = members(found(count - 1)!);
|
|
3682
|
+
fail(
|
|
3683
|
+
at,
|
|
3684
|
+
`\`${name}\` is not a field of \`${prefix}\`.${didYouMean(name, names)}`,
|
|
3685
|
+
names.length
|
|
3686
|
+
? `Use one of ${names.join(", ")}.`
|
|
3687
|
+
: `\`${prefix}\` has no fields.`,
|
|
3688
|
+
);
|
|
3689
|
+
}
|
|
3690
|
+
if (parts[0] === "input")
|
|
3691
|
+
return checkInput(name, at, scope.label, action?.input ?? []);
|
|
3692
|
+
const declared = declarations.get(inst)!.name;
|
|
3693
|
+
const fields = inst.fields.map((field) => field.name);
|
|
3694
|
+
fail(
|
|
3695
|
+
at,
|
|
3696
|
+
`\`${name}\` is not a field or party of \`${declared}\`.${didYouMean(name, [...fields, ...builtins(inst), ...parties(inst)])}`,
|
|
3697
|
+
`Use a field declared in \`${declared}\` (${fields.join(", ")}) or a declared party (${parties(inst).join(", ")}).`,
|
|
3698
|
+
);
|
|
3699
|
+
};
|
|
3700
|
+
const scopeOf = (inst: UdlInstrument): Scope => ({
|
|
3701
|
+
inst,
|
|
3702
|
+
label: declarations.get(inst)!.name,
|
|
3703
|
+
});
|
|
3704
|
+
const checkSelection = (
|
|
3705
|
+
selection: UdlSelection,
|
|
3706
|
+
at: Located,
|
|
3707
|
+
scope: Scope,
|
|
3708
|
+
) => {
|
|
3709
|
+
walk(selection.anchor, "anchor", site(selection, "anchor", at), scope);
|
|
3710
|
+
walk(selection.where, "where", site(selection, "where", at), scope);
|
|
3711
|
+
for (const id of [selection.instrument].flat()) {
|
|
3712
|
+
checkTarget(id, site(selection, "instrument", at));
|
|
3713
|
+
const target = scopeOf(byId.get(id)!);
|
|
3714
|
+
const member = (key: string, where: Located) =>
|
|
3715
|
+
checkPath(
|
|
3716
|
+
key.startsWith("self.") ? key : `self.${key}`,
|
|
3717
|
+
where,
|
|
3718
|
+
target,
|
|
3719
|
+
);
|
|
3720
|
+
for (const key of Object.keys(selection.where ?? {}))
|
|
3721
|
+
member(key, keySite(selection.where!, key, at));
|
|
3722
|
+
member(selection.reference, site(selection, "reference", at));
|
|
3723
|
+
selection.order?.forEach((key, index) =>
|
|
3724
|
+
member(key, site(selection.order!, index, at)),
|
|
3725
|
+
);
|
|
3726
|
+
if (selection.window)
|
|
3727
|
+
member(selection.window.field, site(selection.window, "field", at));
|
|
3728
|
+
}
|
|
3729
|
+
};
|
|
3730
|
+
const checkCall = (
|
|
3731
|
+
call: NonNullable<UdlAction["invoke"]>[number],
|
|
3732
|
+
at: Located,
|
|
3733
|
+
scope: Scope,
|
|
3734
|
+
) => {
|
|
3735
|
+
const bind = "range" in call ? call.range?.bind : undefined;
|
|
3736
|
+
for (const [key, value] of Object.entries(call))
|
|
3737
|
+
if (key !== "input") walk(value, key, site(call, key, at), scope);
|
|
3738
|
+
for (const [key, value] of Object.entries(call.input))
|
|
3739
|
+
if (!("field" in value) || value.field !== bind)
|
|
3740
|
+
walk(value, key, site(call.input, key, at), scope);
|
|
3741
|
+
if ("instrument" in call)
|
|
3742
|
+
checkTarget(call.instrument, site(call, "instrument", at));
|
|
3743
|
+
const reference =
|
|
3744
|
+
"reference" in call
|
|
3745
|
+
? resolveField(
|
|
3746
|
+
document,
|
|
3747
|
+
scope.inst,
|
|
3748
|
+
call.reference,
|
|
3749
|
+
scope.action?.input,
|
|
3750
|
+
scope.action,
|
|
3751
|
+
)
|
|
3752
|
+
: undefined;
|
|
3753
|
+
const ids =
|
|
3754
|
+
"instrument" in call
|
|
3755
|
+
? [call.instrument]
|
|
3756
|
+
: "selection" in call
|
|
3757
|
+
? [call.selection.instrument].flat()
|
|
3758
|
+
: reference?.type === "ref" && reference.targetKind === "instrument"
|
|
3759
|
+
? [reference.target].flat()
|
|
3760
|
+
: [];
|
|
3761
|
+
for (const id of ids) {
|
|
3762
|
+
const target = byId.get(id);
|
|
3763
|
+
if (!target) continue;
|
|
3764
|
+
const action = target.actions[call.action];
|
|
3765
|
+
if (!action)
|
|
3766
|
+
fail(
|
|
3767
|
+
site(call, "action", at),
|
|
3768
|
+
`\`${call.action}\` is not an action of \`${id}\`.${didYouMean(call.action, target.actionOrder)}`,
|
|
3769
|
+
`Use one of ${target.actionOrder.join(", ")}.`,
|
|
3770
|
+
);
|
|
3771
|
+
for (const key of Object.keys(call.input))
|
|
3772
|
+
checkInput(
|
|
3773
|
+
key,
|
|
3774
|
+
keySite(call.input, key, at),
|
|
3775
|
+
`${id}.${call.action}`,
|
|
3776
|
+
action.input,
|
|
3777
|
+
);
|
|
3778
|
+
}
|
|
3779
|
+
};
|
|
3780
|
+
const pathKeys = [
|
|
3781
|
+
"field",
|
|
3782
|
+
"reference",
|
|
3783
|
+
"anchor",
|
|
3784
|
+
"at",
|
|
3785
|
+
"list",
|
|
3786
|
+
"from",
|
|
3787
|
+
"to",
|
|
3788
|
+
"transfer",
|
|
3789
|
+
"subject",
|
|
3790
|
+
"instruction",
|
|
3791
|
+
"installment",
|
|
3792
|
+
"recipient",
|
|
3793
|
+
"dueAt",
|
|
3794
|
+
"fields",
|
|
3795
|
+
];
|
|
3796
|
+
const walk = (
|
|
3797
|
+
value: unknown,
|
|
3798
|
+
key: string,
|
|
3799
|
+
at: Located,
|
|
3800
|
+
scope: Scope,
|
|
3801
|
+
): void => {
|
|
3802
|
+
if (typeof value === "string") {
|
|
3803
|
+
if (key === "party") checkParty(value, at, scope.inst);
|
|
3804
|
+
if (key === "target") checkPath(`self.${value}`, at, scope);
|
|
3805
|
+
if (pathKeys.includes(key)) checkPath(value, at, scope);
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
if (!value || typeof value !== "object") return;
|
|
3809
|
+
if (Array.isArray(value))
|
|
3810
|
+
return value.forEach((item, index) =>
|
|
3811
|
+
walk(item, key, site(value, index, at), scope),
|
|
3812
|
+
);
|
|
3813
|
+
if (key === "selection")
|
|
3814
|
+
return checkSelection(value as UdlSelection, at, scope);
|
|
3815
|
+
if (key === "invoke")
|
|
3816
|
+
return checkCall(
|
|
3817
|
+
value as NonNullable<UdlAction["invoke"]>[number],
|
|
3818
|
+
at,
|
|
3819
|
+
scope,
|
|
3820
|
+
);
|
|
3821
|
+
if (key === "set")
|
|
3822
|
+
for (const name of Object.keys(value))
|
|
3823
|
+
checkPath(`self.${name}`, keySite(value, name, at), scope);
|
|
3824
|
+
for (const [child, item] of Object.entries(value))
|
|
3825
|
+
walk(item, child, site(value, child, at), scope);
|
|
3826
|
+
};
|
|
3827
|
+
for (const inst of document.instruments) {
|
|
3828
|
+
const decl = declarations.get(inst)!;
|
|
3829
|
+
const scope = scopeOf(inst);
|
|
3830
|
+
for (const field of [
|
|
3831
|
+
...inst.fields,
|
|
3832
|
+
...Object.values(inst.actions).flatMap((action) => action.input),
|
|
3833
|
+
])
|
|
3834
|
+
if ("target" in field && field.target !== undefined)
|
|
3835
|
+
for (const id of [field.target].flat())
|
|
3836
|
+
checkTarget(id, site(field, "target", decl), true);
|
|
3837
|
+
walk(inst.calculate, "calculate", decl, scope);
|
|
3838
|
+
walk(inst.invariants, "invariants", decl, scope);
|
|
3839
|
+
for (const [name, action] of Object.entries(inst.actions))
|
|
3840
|
+
for (const [key, value] of Object.entries(action))
|
|
3841
|
+
if (key !== "input" && key !== "subject")
|
|
3842
|
+
walk(value, key, site(action, key, decl), {
|
|
3843
|
+
inst,
|
|
3844
|
+
label: `${decl.name}.${name}`,
|
|
3845
|
+
action,
|
|
3846
|
+
});
|
|
3847
|
+
}
|
|
3520
3848
|
const eliminatedStates = new Map<string, Set<string>>();
|
|
3521
3849
|
// Remove branches excluded by immutable tunables before checking reference states.
|
|
3522
3850
|
for (const inst of document.instruments) {
|
|
@@ -3669,19 +3997,6 @@ export function compile(
|
|
|
3669
3997
|
);
|
|
3670
3998
|
}
|
|
3671
3999
|
}
|
|
3672
|
-
const usedParties = new Set<string>();
|
|
3673
|
-
const collectParties = (value: unknown): void => {
|
|
3674
|
-
if (typeof value === "string") {
|
|
3675
|
-
usedParties.add(value);
|
|
3676
|
-
if (value.startsWith("party.")) usedParties.add(value.split(".")[1]!);
|
|
3677
|
-
} else if (Array.isArray(value)) value.forEach(collectParties);
|
|
3678
|
-
else if (value && typeof value === "object")
|
|
3679
|
-
Object.values(value).forEach(collectParties);
|
|
3680
|
-
};
|
|
3681
|
-
collectParties(document.instruments);
|
|
3682
|
-
for (const party of ["programFines", "programCosts"])
|
|
3683
|
-
if (!usedParties.has(party) && !names.has(party))
|
|
3684
|
-
delete document.parties[party];
|
|
3685
4000
|
const validated = validateUdl(document);
|
|
3686
4001
|
if (!validated.ok)
|
|
3687
4002
|
return {
|
|
@@ -3754,8 +4069,8 @@ export function compile(
|
|
|
3754
4069
|
if (error instanceof CompileFailure)
|
|
3755
4070
|
return {
|
|
3756
4071
|
verdict: "invalid",
|
|
3757
|
-
diagnostics: [...bindingDiagnostics, error.diagnostic].map(
|
|
3758
|
-
diagnostic(d, "check"),
|
|
4072
|
+
diagnostics: distinct([...bindingDiagnostics, error.diagnostic]).map(
|
|
4073
|
+
(d) => diagnostic(d, "check"),
|
|
3759
4074
|
),
|
|
3760
4075
|
};
|
|
3761
4076
|
throw error;
|
package/src/diagnostics.ts
CHANGED
|
@@ -26,3 +26,37 @@ export function failWithCode(
|
|
|
26
26
|
...(expr.source ? { source: expr.source } : {}),
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
// Levenshtein distance where swapping two adjacent letters counts as one edit.
|
|
31
|
+
function editDistance(a: string, b: string): number {
|
|
32
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) =>
|
|
33
|
+
Array.from({ length: b.length + 1 }, (_, j) =>
|
|
34
|
+
i === 0 ? j : j === 0 ? i : 0,
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
for (let i = 1; i <= a.length; i++)
|
|
38
|
+
for (let j = 1; j <= b.length; j++) {
|
|
39
|
+
d[i]![j] = Math.min(
|
|
40
|
+
d[i - 1]![j]! + 1,
|
|
41
|
+
d[i]![j - 1]! + 1,
|
|
42
|
+
d[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
43
|
+
);
|
|
44
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
|
|
45
|
+
d[i]![j] = Math.min(d[i]![j]!, d[i - 2]![j - 2]! + 1);
|
|
46
|
+
}
|
|
47
|
+
return d[a.length]![b.length]!;
|
|
48
|
+
}
|
|
49
|
+
/** " Did you mean `x`?" for the closest candidate within a third of the name's length, else "". */
|
|
50
|
+
export function didYouMean(
|
|
51
|
+
name: string,
|
|
52
|
+
candidates: readonly string[],
|
|
53
|
+
): string {
|
|
54
|
+
const limit = Math.max(1, Math.floor(name.length / 3));
|
|
55
|
+
let best: string | undefined;
|
|
56
|
+
let bestDistance = limit + 1;
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
const distance = editDistance(name, candidate);
|
|
59
|
+
if (distance < bestDistance) [best, bestDistance] = [candidate, distance];
|
|
60
|
+
}
|
|
61
|
+
return best === undefined ? "" : ` Did you mean \`${best}\`?`;
|
|
62
|
+
}
|
package/src/examples-bundle.ts
CHANGED
|
@@ -15,6 +15,5 @@ export const examples: HsxExample[] = [
|
|
|
15
15
|
{"id":"reporting","title":"Equipment loan reports","summary":"An equipment lender keeps an ageing report over its financed purchases and recorded repayments.","headers":["reporting","financing"],"source":"// An equipment lender keeps an ageing report over its financed purchases and recorded repayments.\nprogram equipment_loan_reports \"Equipment loan reports\"\nuse reporting\nuse financing\n\nobject purchase \"Equipment loan\" {\n fields { businessName: text, equipment: text, price: money }\n columns: [businessName, equipment, price]\n attach allowance = financing.limits {\n expose create as create_allowance\n borrower: owner, per_borrower: 200000 SAR\n expose approve as approve_business\n }\n attach budget = financing.portfolio_limit {\n expose create as create_budget\n limit: 5000000 SAR\n expose approve as approve_budget\n }\n attach loan = financing.installments {\n expose create as create_loan\n borrower: owner, capital: operator\n months: 12, profit: 6%, disburse_to: borrower\n limits: allowance, portfolio: budget\n expose sign as sign_purchase_loan\n expose disburse as fund_purchase\n }\n attach reports = reporting.portfolio { expose create as create_reports, on: loan }\n}\n"},
|
|
16
16
|
{"id":"savings","title":"Savings circle","summary":"Six members contribute to a shared pot and receive it in their agreed calendar order.","headers":["savings"],"source":"// Six members contribute to a shared pot and receive it in their agreed calendar order.\nprogram neighbourhood_savings \"Savings circle\"\nuse savings\n\nobject group \"Savings group\" {\n fields { groupName: text, purpose: text }\n columns: [groupName, purpose]\n attach circle = savings.circle {\n expose create as create_circle\n contribution: 500 SAR, members: 6, starts: 2027-01-01\n expose disband as disband_circle\n expose close as close_circle\n }\n attach membership = savings.membership {\n expose create as create_membership\n circle: circle, member: actor\n expose activate as confirm_membership\n expose withdraw as leave_circle\n }\n // Contributions and each member's payout follow the declared calendar.\n}\n"},
|
|
17
17
|
{"id":"serviced","title":"Car financing","summary":"A car finance company services loans with late charges, payment reminders and early repayment.","headers":["escrow","financing","collections"],"source":"// A car finance company services loans with late charges, payment reminders and early repayment.\nprogram car_financing \"Car financing\"\nuse escrow\nuse financing\nuse collections\nobject car \"Car\" {\n fields { make: text, model: text, year: integer, vin: text, price: money }\n columns: [make, model, year, vin, price]\n attach sale = escrow.hold { expose create as create_sale, payer: actor, payee: operator, expose fund as purchase }\n attach limits = financing.limits { expose create as create_limits, borrower: actor, per_borrower: 250000 SAR }\n attach ceiling = financing.portfolio_limit { expose create as create_ceiling, limit: 10000000 SAR }\n attach plan = financing.installments {\n borrower: actor, capital: operator\n months: 36, profit: 8.5%, down_payment: 10%\n apply: fines_profit_principal, payoff_rebate: 100%, write_off_after: 90d\n funds: sale, limits: limits, portfolio: ceiling\n expose create as finance, expose sign as sign_contract, expose payoff as payoff\n }\n attach late = financing.late_charge {\n expose create as create_late\n on: plan, grace: 3d, fine: 50 SAR, cap: 25 SAR\n fines_to: operator, costs_to: operator, borrower: actor\n }\n attach reminders = collections.reminder { expose create as create_reminders, on: plan, operator: operator, before_days: 3, overdue_days: 1 }\n}\n"},
|
|
18
|
-
{"id":"travel","title":"Travel agency","summary":"A travel agency takes deposits for an insured holiday and pays its supplier after confirmation.","headers":["travel","insurance"],"source":"// A travel agency takes deposits for an insured holiday and pays its supplier after confirmation.\nprogram travel_agency \"Travel agency\"\nuse travel\nuse insurance\nparty hotel: business\n\nobject holiday \"Holiday booking\" {\n fields { destination: text, leadTraveller: text }\n columns: [destination, leadTraveller]\n attach trip = travel.package {\n expose create as create_trip\n price: 5000 SAR, supplier_cost: 3500 SAR, departure: 2027-07-01\n expose publish as publish_trip\n }\n // Bind travel_insurer to an ADL adapter before creating cover. Premiums net of commission go to its account.\n attach protection = insurance.cover {\n expose create as create_protection\n holder: actor, adapter: \"travel_insurer\", commission: 10%, covers: reservation\n expose activate as activate_cover\n }\n attach reservation = travel.booking {\n expose create as create_reservation\n package: trip, buyer: actor, supplier: hotel, cover: protection\n expose deposit as pay_deposit\n expose balance as pay_balance\n expose confirm as confirm_completion\n expose cancel_early as cancel_booking\n }\n}\n"},
|
|
19
18
|
{"id":"wallet","title":"Workshop credits","summary":"Members prepay for workshop visits and the studio reserves each spend before charging it.","headers":["wallet"],"source":"// Members prepay for workshop visits and the studio reserves each spend before charging it.\nprogram workshop_credits \"Workshop credits\"\nuse wallet\n\nobject membership \"Membership\" {\n fields { memberName: text, membershipNumber: text }\n columns: [memberName, membershipNumber]\n attach balance = wallet.balance {\n expose create as create_balance\n holder: owner\n expose activate as open_balance\n expose topup as add_credit\n expose withdraw as withdraw_credit\n expose close as close_balance\n }\n attach visit = wallet.spend {\n expose create as create_visit\n wallet: balance, payee: operator\n expose reserve as reserve_visit\n expose pay as charge_visit\n expose cancel as cancel_visit\n }\n}\n"},
|
|
20
19
|
];
|