@hyperscale0/hsx 5.7.0 → 5.8.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.
@@ -1 +1 @@
1
- {"version":3,"file":"std-bundle.js","sourceRoot":"","sources":["../../src/std-bundle.ts"],"names":[],"mappings":"AAAA,oEAAoE;AAEpE,MAAM,CAAC,MAAM,iBAAiB,GAAgC,IAAI,GAAG,CAAiB;IACpF,CAAC,WAAW,EAAE,2oKAA2oK,CAAC;IAC1pK,CAAC,iBAAiB,EAAE,2sNAA2sN,CAAC;IAChuN,CAAC,YAAY,EAAE,+qHAA+qH,CAAC;IAC/rH,CAAC,eAAe,EAAE,qu9DAAqu9D,CAAC;IACxv9D,CAAC,eAAe,EAAE,01KAA01K,CAAC;IAC72K,CAAC,aAAa,EAAE,qwTAAqwT,CAAC;IACtxT,CAAC,iBAAiB,EAAE,s+TAAs+T,CAAC;IAC3/T,CAAC,WAAW,EAAE,u1VAAu1V,CAAC;IACt2V,CAAC,eAAe,EAAE,649CAA649C,CAAC;IACh69C,CAAC,aAAa,EAAE,85JAA85J,CAAC;IAC/6J,CAAC,YAAY,EAAE,o7SAAo7S,CAAC;IACp8S,CAAC,YAAY,EAAE,2jGAA2jG,CAAC;CAC5kG,CAAC,CAAC"}
1
+ {"version":3,"file":"std-bundle.js","sourceRoot":"","sources":["../../src/std-bundle.ts"],"names":[],"mappings":"AAAA,oEAAoE;AAEpE,MAAM,CAAC,MAAM,iBAAiB,GAAgC,IAAI,GAAG,CAAiB;IACpF,CAAC,WAAW,EAAE,2oKAA2oK,CAAC;IAC1pK,CAAC,iBAAiB,EAAE,2sNAA2sN,CAAC;IAChuN,CAAC,YAAY,EAAE,+qHAA+qH,CAAC;IAC/rH,CAAC,eAAe,EAAE,6m+DAA6m+D,CAAC;IACho+D,CAAC,eAAe,EAAE,01KAA01K,CAAC;IAC72K,CAAC,aAAa,EAAE,qwTAAqwT,CAAC;IACtxT,CAAC,iBAAiB,EAAE,woTAAwoT,CAAC;IAC7pT,CAAC,WAAW,EAAE,u1VAAu1V,CAAC;IACt2V,CAAC,eAAe,EAAE,649CAA649C,CAAC;IACh69C,CAAC,aAAa,EAAE,85JAA85J,CAAC;IAC/6J,CAAC,YAAY,EAAE,o7SAAo7S,CAAC;IACp8S,CAAC,YAAY,EAAE,2jGAA2jG,CAAC;CAC5kG,CAAC,CAAC"}
@@ -1,2 +1,2 @@
1
- export declare const HSX_VERSION = "5.7.0";
1
+ export declare const HSX_VERSION = "5.8.0";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1,2 +1,2 @@
1
- export const HSX_VERSION = "5.7.0";
1
+ export const HSX_VERSION = "5.8.0";
2
2
  //# sourceMappingURL=version.js.map
package/docs/headers.md CHANGED
@@ -16,7 +16,7 @@ Bind party parameters to owner, actor, operator or declared parties. Configure d
16
16
  | money.metered | `payer: party`, `payee: party`, `unit_price: money`, `units: integer = 1`, `fee: fee = { seller: 0% }` |
17
17
  | marketplace.listing | `seller: party` |
18
18
  | marketplace.order | `listing: ref<marketplace.listing>`, `buyer: party` |
19
- | marketplace.reservation | `listing: ref<marketplace.listing>`, `funds: ref<escrow.hold>`, `plans: ref<financing.installments>[]`, `converters: ref[]`, `buyer: party`, `seller: party`, `expires_after: duration = 48h`, `deposit: money = 1000 SAR`, `cancellation_refund: percent = 100%`, `expiry_refund: percent = 100%`, `retained_to: party = programOperator`, `operator: party = programOperator`, `competing_quotes: enum(allowed, blocked) = allowed` |
19
+ | marketplace.reservation | `listing: ref<marketplace.listing>`, `funds: ref<escrow.hold>`, `converters: ref[]`, `buyer: party`, `seller: party`, `expires_after: duration = 48h`, `deposit: money = 1000 SAR`, `cancellation_refund: percent = 100%`, `expiry_refund: percent = 100%`, `retained_to: party = programOperator`, `operator: party = programOperator`, `competing_quotes: enum(allowed, blocked) = allowed` |
20
20
  | escrow.hold | `payer: party = party(person)`, `payee: party = programOperator`, `accept_within: duration = 48h`, `fee: fee = { seller: 0%, tax: 0% }`, `dispute: policy = { refund_after: return_verified }` |
21
21
  | wallet.balance | `holder: party`, `spend_limit: money = 100000 SAR` |
22
22
  | wallet.spend | `wallet: ref<wallet.balance>`, `payee: party` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperscale0/hsx",
3
- "version": "5.7.0",
3
+ "version": "5.8.0",
4
4
  "description": "The strictly typed HSX language: compile total financial programs into canonical UDL.",
5
5
  "keywords": [
6
6
  "hsx",
@@ -98,5 +98,5 @@
98
98
  "Amir Ayub",
99
99
  "Sara AlBakaawi"
100
100
  ],
101
- "gitHead": "e1e36df7b7182c7e22264414c622076a044e974a"
101
+ "gitHead": "d368f9b62ba1bd8d75e48e77acca613c750ad0b0"
102
102
  }
package/src/compile.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { CompileFailure, fail, failWithCode } from "./diagnostics.ts";
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",
@@ -589,6 +607,10 @@ export function compile(
589
607
  }
590
608
  }
591
609
  const origins: CompileOriginMapEntry[] = [];
610
+ // Lowered objects lose their spans, so each keeps the expression that
611
+ // produced it for the name check after lowering.
612
+ const authored = new WeakMap<object, Expr>();
613
+ const declarations = new WeakMap<UdlInstrument, InstrumentDecl>();
592
614
  const exposures: { instrument: string; action: string; entry: Entry }[] =
593
615
  [];
594
616
  const refundBindings = new Map<
@@ -1179,14 +1201,11 @@ export function compile(
1179
1201
  param.value.kind === "default" ? param.value.type : param.value;
1180
1202
  const type =
1181
1203
  t.kind === "type" || t.kind === "call" ? t.name : text(t);
1182
- let v =
1183
- (supplied.has(param.key) && !attachmentInfo) || type === "enum"
1184
- ? actual
1185
- : resolve(
1186
- actual,
1187
- new Set(),
1188
- !!attachmentInfo && type === "party",
1189
- );
1204
+ const asWritten =
1205
+ (supplied.has(param.key) && !attachmentInfo) || type === "enum";
1206
+ let v = asWritten
1207
+ ? actual
1208
+ : resolve(actual, new Set(), !!attachmentInfo && type === "party");
1190
1209
  if (
1191
1210
  type === "duration" &&
1192
1211
  (v.kind === "text" || v.kind === "name") &&
@@ -1237,7 +1256,16 @@ export function compile(
1237
1256
  ? { role: v.value as SubjectPartyRole }
1238
1257
  : { party: v.value };
1239
1258
  } else if (type === "ref") {
1240
- const values = v.kind === "list" ? v.items : [v];
1259
+ // Listed objects resolve like a single binding, so `on: [plan]`
1260
+ // names the sibling attachment that `on: plan` names.
1261
+ const values =
1262
+ v.kind !== "list"
1263
+ ? [v]
1264
+ : asWritten
1265
+ ? v.items
1266
+ : v.items.map((item) => resolve(item));
1267
+ if (v.kind === "list")
1268
+ environment.set(param.key, { ...v, items: values });
1241
1269
  if (v.kind === "list" && (t.kind !== "type" || !t.many))
1242
1270
  fail(
1243
1271
  v,
@@ -1587,9 +1615,11 @@ export function compile(
1587
1615
  };
1588
1616
  const val = (expr: Expr): UdlValue => {
1589
1617
  const v = resolve(expr);
1590
- return v.kind === "name" && !["true", "false"].includes(v.value)
1591
- ? { field: path(v) }
1592
- : { literal: literal(v) };
1618
+ if (v.kind !== "name" || ["true", "false"].includes(v.value))
1619
+ return { literal: literal(v) };
1620
+ const value = { field: path(v) };
1621
+ authored.set(value, expr);
1622
+ return value;
1593
1623
  };
1594
1624
  const data = (expr: Expr): unknown => {
1595
1625
  if (
@@ -1646,6 +1676,7 @@ export function compile(
1646
1676
  if (k === "family" || k === "instrument") continue;
1647
1677
  result[k] = data(v);
1648
1678
  }
1679
+ authored.set(result, value);
1649
1680
  return result;
1650
1681
  }
1651
1682
 
@@ -1674,10 +1705,14 @@ export function compile(
1674
1705
  };
1675
1706
  return null;
1676
1707
  }
1708
+ authored.set(result, value);
1677
1709
  return result;
1678
1710
  }
1679
- if (value.kind === "list")
1680
- return value.items.map(data).filter((item) => item !== null);
1711
+ if (value.kind === "list") {
1712
+ const items = value.items.map(data).filter((item) => item !== null);
1713
+ authored.set(items, value);
1714
+ return items;
1715
+ }
1681
1716
  if (
1682
1717
  value.kind === "call" &&
1683
1718
  value.name === "child" &&
@@ -1921,6 +1956,7 @@ export function compile(
1921
1956
  `unknown calculation ${constant.name}`,
1922
1957
  "use rate, sum, minimum, subtract, multiply, divide, before or after",
1923
1958
  );
1959
+ authored.set(calculations.at(-1)!, constant);
1924
1960
  } else if (type === "enum" && constant.kind === "name")
1925
1961
  f.value = constant.value;
1926
1962
  else if (type === "money" && constant.kind === "name")
@@ -1931,14 +1967,26 @@ export function compile(
1931
1967
  });
1932
1968
  else f.value = literal(constant, String(type));
1933
1969
  }
1970
+ authored.set(f, row.value);
1934
1971
  result.push(f as UdlField);
1935
1972
  }
1936
1973
  return result;
1937
1974
  };
1938
1975
  fields.push(...lowerFields(asBlock(body.get("fields"))));
1939
- const lifecycle = data(
1940
- asBlock(body.get("lifecycle")),
1941
- ) as UdlInstrument["lifecycle"];
1976
+ const lifecycleBlock = asBlock(body.get("lifecycle"));
1977
+ const lifecycle = data(lifecycleBlock) as UdlInstrument["lifecycle"];
1978
+ const states = Array.isArray(lifecycle.states) ? lifecycle.states : [];
1979
+ const checkState = (expr: Expr, state: unknown) => {
1980
+ if (typeof state !== "string" || !states.length) return;
1981
+ if (states.includes(state)) return;
1982
+ fail(
1983
+ expr,
1984
+ `\`${state}\` is not a state of \`${decl.name}\`.${didYouMean(state, states)}`,
1985
+ `Use one of ${states.join(", ")}, or add \`${state}\` to \`lifecycle.states\`.`,
1986
+ );
1987
+ };
1988
+ const initialExpr = entries(lifecycleBlock).get("initial");
1989
+ if (initialExpr) checkState(initialExpr, lifecycle.initial);
1942
1990
  lifecycle.transitions = Object.create(
1943
1991
  null,
1944
1992
  ) as UdlInstrument["lifecycle"]["transitions"];
@@ -2288,6 +2336,7 @@ export function compile(
2288
2336
  moves: [],
2289
2337
  ...(actionSubject ? { subject: actionSubject } : {}),
2290
2338
  };
2339
+ authored.set(a, row.value);
2291
2340
  for (const binding of boundaryBindings)
2292
2341
  if (
2293
2342
  !a.subject?.adapters.find((entry) => entry.binding === binding)
@@ -2309,12 +2358,12 @@ export function compile(
2309
2358
  `action ${name} needs from and to`,
2310
2359
  "declare its source and destination states",
2311
2360
  );
2312
- lifecycle.transitions[name] = {
2313
- from: (from!.kind === "list" ? from!.items : [from!]).map((e) =>
2314
- String(data(e)),
2315
- ),
2316
- to: String(data(to!)),
2317
- };
2361
+ const sources = from!.kind === "list" ? from!.items : [from!];
2362
+ const fromStates = sources.map((e) => String(data(e)));
2363
+ const toState = String(data(to!));
2364
+ sources.forEach((e, index) => checkState(e, fromStates[index]));
2365
+ if (toState !== "preserve") checkState(to!, toState);
2366
+ lifecycle.transitions[name] = { from: fromStates, to: toState };
2318
2367
  }
2319
2368
  for (const [key, expr] of slots) {
2320
2369
  if (["from", "to", "input", "summary", "subject"].includes(key))
@@ -2322,6 +2371,7 @@ export function compile(
2322
2371
  if (key === "moves") {
2323
2372
  const moves = expr.kind === "list" ? expr.items : [expr];
2324
2373
  for (const [index, move] of moves.entries()) {
2374
+ const lowered = a.moves.length;
2325
2375
  const parts = entries(asBlock(move));
2326
2376
  const op = parts.has("operation")
2327
2377
  ? String(data(parts.get("operation")!))
@@ -2571,6 +2621,8 @@ export function compile(
2571
2621
  "split percentages must sum to 100%",
2572
2622
  "give the recipients exactly 100% together",
2573
2623
  );
2624
+ for (const item of a.moves.slice(lowered))
2625
+ authored.set(item, move);
2574
2626
  continue;
2575
2627
  }
2576
2628
  if (!amount || !from || !to)
@@ -2767,6 +2819,8 @@ export function compile(
2767
2819
  `unknown transfer instruction ${op}`,
2768
2820
  "choose create, reserve, post, or void from internal_transfer",
2769
2821
  );
2822
+ for (const item of a.moves.slice(lowered))
2823
+ authored.set(item, move);
2770
2824
  }
2771
2825
  } else (a as unknown as Record<string, unknown>)[key] = data(expr);
2772
2826
  }
@@ -2911,6 +2965,7 @@ export function compile(
2911
2965
  };
2912
2966
  }
2913
2967
  document.instruments.push(inst);
2968
+ declarations.set(inst, decl);
2914
2969
  for (const [key, definition] of records) {
2915
2970
  const childAttachment = attachmentInfo
2916
2971
  ? {
@@ -3267,7 +3322,7 @@ export function compile(
3267
3322
  if (bindingDiagnostics.length)
3268
3323
  return {
3269
3324
  verdict: "invalid",
3270
- diagnostics: bindingDiagnostics
3325
+ diagnostics: distinct(bindingDiagnostics)
3271
3326
  .sort((a, b) => a.span.start - b.span.start)
3272
3327
  .map((d) => diagnostic(d, "check")),
3273
3328
  };
@@ -3517,6 +3572,287 @@ export function compile(
3517
3572
  );
3518
3573
  }
3519
3574
  }
3575
+ // Names resolve here, once tunables, attachments and derived fields are
3576
+ // final, and each failure points at the expression that wrote the name.
3577
+ type Located = { span: Span; source?: string };
3578
+ type Scope = { inst: UdlInstrument; label: string; action?: UdlAction };
3579
+ const byId = new Map(document.instruments.map((inst) => [inst.id, inst]));
3580
+ const site = (owner: object, key: string | number, fallback: Located) => {
3581
+ const expr = authored.get(owner);
3582
+ if (expr?.kind === "block")
3583
+ return expr.entries.find((entry) => entry.key === key)?.value ?? expr;
3584
+ if (expr?.kind === "list" && Array.isArray(owner))
3585
+ return expr.items.length === owner.length
3586
+ ? expr.items[Number(key)]!
3587
+ : expr;
3588
+ return expr ?? fallback;
3589
+ };
3590
+ const keySite = (owner: object, key: string, fallback: Located) => {
3591
+ const expr = authored.get(owner);
3592
+ const entry =
3593
+ expr?.kind === "block"
3594
+ ? expr.entries.find((item) => item.key === key)
3595
+ : undefined;
3596
+ if (!entry) return fallback;
3597
+ return {
3598
+ ...entry,
3599
+ span: { start: entry.span.start, end: entry.span.start + key.length },
3600
+ };
3601
+ };
3602
+ const builtins = (inst: UdlInstrument) => [
3603
+ "id",
3604
+ "now",
3605
+ "createdAt",
3606
+ "productRevision",
3607
+ "status",
3608
+ ...(inst.subject ? ["subject"] : []),
3609
+ ];
3610
+ const parties = (inst: UdlInstrument) => [
3611
+ ...Object.keys(document.parties),
3612
+ ...(inst.subject ? subjectPartyRoles : []),
3613
+ ];
3614
+ const members = (field: UdlField): string[] => {
3615
+ if (field.type === "account") return ["balance", "reserved"];
3616
+ if (field.type !== "ref") return [];
3617
+ const [id] = [field.target].flat();
3618
+ const target =
3619
+ field.targetKind === "object"
3620
+ ? document.objects.find((object) => object.id === id)
3621
+ : byId.get(id!);
3622
+ if (!target) return [];
3623
+ return [
3624
+ ...target.fields.map((item) => item.name),
3625
+ ...("lifecycle" in target ? builtins(target) : []),
3626
+ ];
3627
+ };
3628
+ const checkParty = (name: string, at: Located, inst: UdlInstrument) => {
3629
+ const names = parties(inst);
3630
+ if (!names.includes(name))
3631
+ fail(
3632
+ at,
3633
+ `\`${name}\` is not a declared party.${didYouMean(name, names)}`,
3634
+ `Declare it with \`party ${name}: business\`, or name a declared party (${names.join(", ")}).`,
3635
+ );
3636
+ };
3637
+ const checkInput = (
3638
+ name: string,
3639
+ at: Located,
3640
+ label: string,
3641
+ input: readonly UdlField[],
3642
+ ) => {
3643
+ const names = input.map((field) => field.name);
3644
+ if (!names.includes(name))
3645
+ fail(
3646
+ at,
3647
+ `\`${name}\` is not an input of \`${label}\`.${didYouMean(name, names)}`,
3648
+ `Declare \`${name}\` in the input of \`${label}\`${names.length ? `, or use one of ${names.join(", ")}` : ""}.`,
3649
+ );
3650
+ };
3651
+ const checkTarget = (id: string, at: Located, objects = false) => {
3652
+ const ids = [
3653
+ ...byId.keys(),
3654
+ ...(objects ? document.objects.map((object) => object.id) : []),
3655
+ ];
3656
+ const noun = objects ? "instrument or object" : "instrument";
3657
+ if (!ids.includes(id))
3658
+ fail(
3659
+ at,
3660
+ `\`${id}\` is not a declared ${noun}.${didYouMean(id, ids)}`,
3661
+ `Name a declared ${noun}.`,
3662
+ );
3663
+ };
3664
+ const checkPath = (path: string, at: Located, scope: Scope): void => {
3665
+ const { inst, action } = scope;
3666
+ const parts = path.split(".");
3667
+ const found = (count: number) =>
3668
+ resolveField(
3669
+ document,
3670
+ inst,
3671
+ parts.slice(0, count).join("."),
3672
+ action?.input,
3673
+ action,
3674
+ );
3675
+ // Subject paths keep their own check against the action's requirements.
3676
+ if (found(parts.length) || parts[0] === "subject") return;
3677
+ if (parts[0] === "party" && parts[1]) {
3678
+ checkParty(parts[1], at, inst);
3679
+ if (!found(2)) return;
3680
+ }
3681
+ if (!["self", "input", "party"].includes(parts[0]!))
3682
+ parts.unshift("self");
3683
+ let count = 2;
3684
+ while (count <= parts.length && found(count)) count++;
3685
+ if (count > parts.length) return;
3686
+ const name = parts[count - 1]!;
3687
+ if (count > 2) {
3688
+ const prefix = parts.slice(0, count - 1).join(".");
3689
+ const names = members(found(count - 1)!);
3690
+ fail(
3691
+ at,
3692
+ `\`${name}\` is not a field of \`${prefix}\`.${didYouMean(name, names)}`,
3693
+ names.length
3694
+ ? `Use one of ${names.join(", ")}.`
3695
+ : `\`${prefix}\` has no fields.`,
3696
+ );
3697
+ }
3698
+ if (parts[0] === "input")
3699
+ return checkInput(name, at, scope.label, action?.input ?? []);
3700
+ const declared = declarations.get(inst)!.name;
3701
+ const fields = inst.fields.map((field) => field.name);
3702
+ fail(
3703
+ at,
3704
+ `\`${name}\` is not a field or party of \`${declared}\`.${didYouMean(name, [...fields, ...builtins(inst), ...parties(inst)])}`,
3705
+ `Use a field declared in \`${declared}\` (${fields.join(", ")}) or a declared party (${parties(inst).join(", ")}).`,
3706
+ );
3707
+ };
3708
+ const scopeOf = (inst: UdlInstrument): Scope => ({
3709
+ inst,
3710
+ label: declarations.get(inst)!.name,
3711
+ });
3712
+ const checkSelection = (
3713
+ selection: UdlSelection,
3714
+ at: Located,
3715
+ scope: Scope,
3716
+ ) => {
3717
+ walk(selection.anchor, "anchor", site(selection, "anchor", at), scope);
3718
+ walk(selection.where, "where", site(selection, "where", at), scope);
3719
+ for (const id of [selection.instrument].flat()) {
3720
+ checkTarget(id, site(selection, "instrument", at));
3721
+ const target = scopeOf(byId.get(id)!);
3722
+ const member = (key: string, where: Located) =>
3723
+ checkPath(
3724
+ key.startsWith("self.") ? key : `self.${key}`,
3725
+ where,
3726
+ target,
3727
+ );
3728
+ for (const key of Object.keys(selection.where ?? {}))
3729
+ member(key, keySite(selection.where!, key, at));
3730
+ member(selection.reference, site(selection, "reference", at));
3731
+ selection.order?.forEach((key, index) =>
3732
+ member(key, site(selection.order!, index, at)),
3733
+ );
3734
+ if (selection.window)
3735
+ member(selection.window.field, site(selection.window, "field", at));
3736
+ }
3737
+ };
3738
+ const checkCall = (
3739
+ call: NonNullable<UdlAction["invoke"]>[number],
3740
+ at: Located,
3741
+ scope: Scope,
3742
+ ) => {
3743
+ const bind = "range" in call ? call.range?.bind : undefined;
3744
+ for (const [key, value] of Object.entries(call))
3745
+ if (key !== "input") walk(value, key, site(call, key, at), scope);
3746
+ for (const [key, value] of Object.entries(call.input))
3747
+ if (!("field" in value) || value.field !== bind)
3748
+ walk(value, key, site(call.input, key, at), scope);
3749
+ if ("instrument" in call)
3750
+ checkTarget(call.instrument, site(call, "instrument", at));
3751
+ const reference =
3752
+ "reference" in call
3753
+ ? resolveField(
3754
+ document,
3755
+ scope.inst,
3756
+ call.reference,
3757
+ scope.action?.input,
3758
+ scope.action,
3759
+ )
3760
+ : undefined;
3761
+ const ids =
3762
+ "instrument" in call
3763
+ ? [call.instrument]
3764
+ : "selection" in call
3765
+ ? [call.selection.instrument].flat()
3766
+ : reference?.type === "ref" && reference.targetKind === "instrument"
3767
+ ? [reference.target].flat()
3768
+ : [];
3769
+ for (const id of ids) {
3770
+ const target = byId.get(id);
3771
+ if (!target) continue;
3772
+ const action = target.actions[call.action];
3773
+ if (!action)
3774
+ fail(
3775
+ site(call, "action", at),
3776
+ `\`${call.action}\` is not an action of \`${id}\`.${didYouMean(call.action, target.actionOrder)}`,
3777
+ `Use one of ${target.actionOrder.join(", ")}.`,
3778
+ );
3779
+ for (const key of Object.keys(call.input))
3780
+ checkInput(
3781
+ key,
3782
+ keySite(call.input, key, at),
3783
+ `${id}.${call.action}`,
3784
+ action.input,
3785
+ );
3786
+ }
3787
+ };
3788
+ const pathKeys = [
3789
+ "field",
3790
+ "reference",
3791
+ "anchor",
3792
+ "at",
3793
+ "list",
3794
+ "from",
3795
+ "to",
3796
+ "transfer",
3797
+ "subject",
3798
+ "instruction",
3799
+ "installment",
3800
+ "recipient",
3801
+ "dueAt",
3802
+ "fields",
3803
+ ];
3804
+ const walk = (
3805
+ value: unknown,
3806
+ key: string,
3807
+ at: Located,
3808
+ scope: Scope,
3809
+ ): void => {
3810
+ if (typeof value === "string") {
3811
+ if (key === "party") checkParty(value, at, scope.inst);
3812
+ if (key === "target") checkPath(`self.${value}`, at, scope);
3813
+ if (pathKeys.includes(key)) checkPath(value, at, scope);
3814
+ return;
3815
+ }
3816
+ if (!value || typeof value !== "object") return;
3817
+ if (Array.isArray(value))
3818
+ return value.forEach((item, index) =>
3819
+ walk(item, key, site(value, index, at), scope),
3820
+ );
3821
+ if (key === "selection")
3822
+ return checkSelection(value as UdlSelection, at, scope);
3823
+ if (key === "invoke")
3824
+ return checkCall(
3825
+ value as NonNullable<UdlAction["invoke"]>[number],
3826
+ at,
3827
+ scope,
3828
+ );
3829
+ if (key === "set")
3830
+ for (const name of Object.keys(value))
3831
+ checkPath(`self.${name}`, keySite(value, name, at), scope);
3832
+ for (const [child, item] of Object.entries(value))
3833
+ walk(item, child, site(value, child, at), scope);
3834
+ };
3835
+ for (const inst of document.instruments) {
3836
+ const decl = declarations.get(inst)!;
3837
+ const scope = scopeOf(inst);
3838
+ for (const field of [
3839
+ ...inst.fields,
3840
+ ...Object.values(inst.actions).flatMap((action) => action.input),
3841
+ ])
3842
+ if ("target" in field && field.target !== undefined)
3843
+ for (const id of [field.target].flat())
3844
+ checkTarget(id, site(field, "target", decl), true);
3845
+ walk(inst.calculate, "calculate", decl, scope);
3846
+ walk(inst.invariants, "invariants", decl, scope);
3847
+ for (const [name, action] of Object.entries(inst.actions))
3848
+ for (const [key, value] of Object.entries(action))
3849
+ if (key !== "input" && key !== "subject")
3850
+ walk(value, key, site(action, key, decl), {
3851
+ inst,
3852
+ label: `${decl.name}.${name}`,
3853
+ action,
3854
+ });
3855
+ }
3520
3856
  const eliminatedStates = new Map<string, Set<string>>();
3521
3857
  // Remove branches excluded by immutable tunables before checking reference states.
3522
3858
  for (const inst of document.instruments) {
@@ -3754,8 +4090,8 @@ export function compile(
3754
4090
  if (error instanceof CompileFailure)
3755
4091
  return {
3756
4092
  verdict: "invalid",
3757
- diagnostics: [...bindingDiagnostics, error.diagnostic].map((d) =>
3758
- diagnostic(d, "check"),
4093
+ diagnostics: distinct([...bindingDiagnostics, error.diagnostic]).map(
4094
+ (d) => diagnostic(d, "check"),
3759
4095
  ),
3760
4096
  };
3761
4097
  throw error;
@@ -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
+ }