@hyperscale0/hsx 5.6.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.
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,
@@ -1488,6 +1516,7 @@ export function compile(
1488
1516
  "parameterDiagnostics",
1489
1517
  "reports",
1490
1518
  "revisioned",
1519
+ "scope",
1491
1520
  ].includes(key) &&
1492
1521
  !key.startsWith("action ")
1493
1522
  )
@@ -1586,9 +1615,11 @@ export function compile(
1586
1615
  };
1587
1616
  const val = (expr: Expr): UdlValue => {
1588
1617
  const v = resolve(expr);
1589
- return v.kind === "name" && !["true", "false"].includes(v.value)
1590
- ? { field: path(v) }
1591
- : { 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;
1592
1623
  };
1593
1624
  const data = (expr: Expr): unknown => {
1594
1625
  if (
@@ -1645,6 +1676,7 @@ export function compile(
1645
1676
  if (k === "family" || k === "instrument") continue;
1646
1677
  result[k] = data(v);
1647
1678
  }
1679
+ authored.set(result, value);
1648
1680
  return result;
1649
1681
  }
1650
1682
 
@@ -1673,10 +1705,14 @@ export function compile(
1673
1705
  };
1674
1706
  return null;
1675
1707
  }
1708
+ authored.set(result, value);
1676
1709
  return result;
1677
1710
  }
1678
- if (value.kind === "list")
1679
- 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
+ }
1680
1716
  if (
1681
1717
  value.kind === "call" &&
1682
1718
  value.name === "child" &&
@@ -1920,6 +1956,7 @@ export function compile(
1920
1956
  `unknown calculation ${constant.name}`,
1921
1957
  "use rate, sum, minimum, subtract, multiply, divide, before or after",
1922
1958
  );
1959
+ authored.set(calculations.at(-1)!, constant);
1923
1960
  } else if (type === "enum" && constant.kind === "name")
1924
1961
  f.value = constant.value;
1925
1962
  else if (type === "money" && constant.kind === "name")
@@ -1930,14 +1967,26 @@ export function compile(
1930
1967
  });
1931
1968
  else f.value = literal(constant, String(type));
1932
1969
  }
1970
+ authored.set(f, row.value);
1933
1971
  result.push(f as UdlField);
1934
1972
  }
1935
1973
  return result;
1936
1974
  };
1937
1975
  fields.push(...lowerFields(asBlock(body.get("fields"))));
1938
- const lifecycle = data(
1939
- asBlock(body.get("lifecycle")),
1940
- ) 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);
1941
1990
  lifecycle.transitions = Object.create(
1942
1991
  null,
1943
1992
  ) as UdlInstrument["lifecycle"]["transitions"];
@@ -2278,7 +2327,7 @@ export function compile(
2278
2327
  const a: UdlAction = {
2279
2328
  summary: slots.has("summary")
2280
2329
  ? String(data(slots.get("summary")!))
2281
- : `${title(name)} ${id}`,
2330
+ : title(name),
2282
2331
  publicAction: camel(`${name}_${id}`),
2283
2332
  event: `${id}.${name}`,
2284
2333
  actor: "caller",
@@ -2287,6 +2336,7 @@ export function compile(
2287
2336
  moves: [],
2288
2337
  ...(actionSubject ? { subject: actionSubject } : {}),
2289
2338
  };
2339
+ authored.set(a, row.value);
2290
2340
  for (const binding of boundaryBindings)
2291
2341
  if (
2292
2342
  !a.subject?.adapters.find((entry) => entry.binding === binding)
@@ -2308,12 +2358,12 @@ export function compile(
2308
2358
  `action ${name} needs from and to`,
2309
2359
  "declare its source and destination states",
2310
2360
  );
2311
- lifecycle.transitions[name] = {
2312
- from: (from!.kind === "list" ? from!.items : [from!]).map((e) =>
2313
- String(data(e)),
2314
- ),
2315
- to: String(data(to!)),
2316
- };
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 };
2317
2367
  }
2318
2368
  for (const [key, expr] of slots) {
2319
2369
  if (["from", "to", "input", "summary", "subject"].includes(key))
@@ -2321,6 +2371,7 @@ export function compile(
2321
2371
  if (key === "moves") {
2322
2372
  const moves = expr.kind === "list" ? expr.items : [expr];
2323
2373
  for (const [index, move] of moves.entries()) {
2374
+ const lowered = a.moves.length;
2324
2375
  const parts = entries(asBlock(move));
2325
2376
  const op = parts.has("operation")
2326
2377
  ? String(data(parts.get("operation")!))
@@ -2570,6 +2621,8 @@ export function compile(
2570
2621
  "split percentages must sum to 100%",
2571
2622
  "give the recipients exactly 100% together",
2572
2623
  );
2624
+ for (const item of a.moves.slice(lowered))
2625
+ authored.set(item, move);
2573
2626
  continue;
2574
2627
  }
2575
2628
  if (!amount || !from || !to)
@@ -2766,6 +2819,8 @@ export function compile(
2766
2819
  `unknown transfer instruction ${op}`,
2767
2820
  "choose create, reserve, post, or void from internal_transfer",
2768
2821
  );
2822
+ for (const item of a.moves.slice(lowered))
2823
+ authored.set(item, move);
2769
2824
  }
2770
2825
  } else (a as unknown as Record<string, unknown>)[key] = data(expr);
2771
2826
  }
@@ -2839,11 +2894,43 @@ export function compile(
2839
2894
  inst.actions[name] = a;
2840
2895
  inst.actionOrder.push(name);
2841
2896
  }
2842
- for (const key of ["invariants", "reports", "revisioned"] as const)
2897
+ for (const key of [
2898
+ "invariants",
2899
+ "reports",
2900
+ "revisioned",
2901
+ "scope",
2902
+ ] as const)
2843
2903
  if (body.has(key))
2844
2904
  (inst as unknown as Record<string, unknown>)[key] = data(
2845
2905
  body.get(key)!,
2846
2906
  );
2907
+ let usesTax = false;
2908
+ for (const action of Object.values(inst.actions)) {
2909
+ for (const move of action.moves) {
2910
+ if (!("from" in move)) continue;
2911
+ for (const endpoint of ["from", "to"] as const) {
2912
+ if (move[endpoint] === "party.programTax") {
2913
+ move[endpoint] = "self.programTaxPayable";
2914
+ usesTax = true;
2915
+ }
2916
+ }
2917
+ }
2918
+ }
2919
+ if (usesTax) {
2920
+ if (inst.fields.some((field) => field.name === "programTaxPayable"))
2921
+ fail(
2922
+ decl,
2923
+ "programTaxPayable is reserved for the tax account",
2924
+ "rename the authored field",
2925
+ );
2926
+ inst.fields.push({
2927
+ name: "programTaxPayable",
2928
+ type: "account",
2929
+ owner: "programOperator",
2930
+ book: "cash",
2931
+ key: "programTax",
2932
+ });
2933
+ }
2847
2934
  const shape = udlInstrumentSchema.safeParse(inst);
2848
2935
  if (!shape.success) {
2849
2936
  const issue = shape.error.issues[0]!;
@@ -2878,6 +2965,7 @@ export function compile(
2878
2965
  };
2879
2966
  }
2880
2967
  document.instruments.push(inst);
2968
+ declarations.set(inst, decl);
2881
2969
  for (const [key, definition] of records) {
2882
2970
  const childAttachment = attachmentInfo
2883
2971
  ? {
@@ -2943,12 +3031,13 @@ export function compile(
2943
3031
  if (
2944
3032
  key !== "fields" &&
2945
3033
  key !== "columns" &&
3034
+ key !== "entryActions" &&
2946
3035
  !key.startsWith("attach ")
2947
3036
  ) {
2948
3037
  fail(
2949
3038
  decl,
2950
3039
  `unknown object clause ${key}`,
2951
- "use fields, columns, or attach",
3040
+ "use fields, columns, entryActions, or attach",
2952
3041
  );
2953
3042
  }
2954
3043
  }
@@ -3180,9 +3269,21 @@ export function compile(
3180
3269
  }
3181
3270
  }
3182
3271
 
3272
+ const entryExpr = body.get("entryActions");
3273
+ let entryActions: string[] | undefined;
3274
+ if (entryExpr) {
3275
+ if (entryExpr.kind !== "list" || !entryExpr.items.length)
3276
+ fail(
3277
+ entryExpr,
3278
+ "entryActions needs a nonempty list",
3279
+ "name exposed create actions bound to actor",
3280
+ );
3281
+ entryActions = entryExpr.items.map(text);
3282
+ }
3183
3283
  document.objects.push({
3184
3284
  id: decl.name,
3185
3285
  title: decl.title,
3286
+ ...(entryActions ? { entryActions } : {}),
3186
3287
  authoredFields: authoredNames,
3187
3288
  attachments,
3188
3289
  fields: authoredFields,
@@ -3221,10 +3322,35 @@ export function compile(
3221
3322
  if (bindingDiagnostics.length)
3222
3323
  return {
3223
3324
  verdict: "invalid",
3224
- diagnostics: bindingDiagnostics
3325
+ diagnostics: distinct(bindingDiagnostics)
3225
3326
  .sort((a, b) => a.span.start - b.span.start)
3226
3327
  .map((d) => diagnostic(d, "check")),
3227
3328
  };
3329
+ // Resolve selectors after every attachment and child has its family.
3330
+ const retainSelectionFamilies = (value: unknown): void => {
3331
+ if (!value || typeof value !== "object") return;
3332
+ const record = value as Record<string, unknown>;
3333
+ const selection = record.selection as
3334
+ | { instrument: string | string[]; family?: UdlFamily }
3335
+ | undefined;
3336
+ if (selection && !selection.family) {
3337
+ const families = [selection.instrument]
3338
+ .flat()
3339
+ .map(
3340
+ (id) => document.instruments.find((item) => item.id === id)?.family,
3341
+ );
3342
+ const family = families[0];
3343
+ if (
3344
+ family &&
3345
+ families.every(
3346
+ (item) => canonicalJson(item) === canonicalJson(family),
3347
+ )
3348
+ )
3349
+ selection.family = family;
3350
+ }
3351
+ for (const child of Object.values(record)) retainSelectionFamilies(child);
3352
+ };
3353
+ retainSelectionFamilies(document);
3228
3354
  // Propagate invoked requirements with the conditions on each invocation path.
3229
3355
  let changedInvocations = true;
3230
3356
  let invocationIterations = 0;
@@ -3446,6 +3572,287 @@ export function compile(
3446
3572
  );
3447
3573
  }
3448
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
+ }
3449
3856
  const eliminatedStates = new Map<string, Set<string>>();
3450
3857
  // Remove branches excluded by immutable tunables before checking reference states.
3451
3858
  for (const inst of document.instruments) {
@@ -3577,6 +3984,27 @@ export function compile(
3577
3984
  "give each exposed action a distinct public name",
3578
3985
  );
3579
3986
  }
3987
+ for (const kind of document.objects) {
3988
+ for (const name of kind.entryActions ?? []) {
3989
+ const eligible = kind.attachments.some((attachment) => {
3990
+ const create = document.instruments.find(
3991
+ (instrument) => instrument.id === attachment.instrument,
3992
+ )?.actions.create;
3993
+ return (
3994
+ create?.publicAction === name &&
3995
+ typeof create.actor === "object" &&
3996
+ "party" in create.actor &&
3997
+ create.actor.party === "actor"
3998
+ );
3999
+ });
4000
+ if (!eligible)
4001
+ fail(
4002
+ objects.get(kind.id)!,
4003
+ `entry action ${name} must create an attachment as actor`,
4004
+ "expose its create action and bind its acting party to actor",
4005
+ );
4006
+ }
4007
+ }
3580
4008
  const usedParties = new Set<string>();
3581
4009
  const collectParties = (value: unknown): void => {
3582
4010
  if (typeof value === "string") {
@@ -3662,8 +4090,8 @@ export function compile(
3662
4090
  if (error instanceof CompileFailure)
3663
4091
  return {
3664
4092
  verdict: "invalid",
3665
- diagnostics: [...bindingDiagnostics, error.diagnostic].map((d) =>
3666
- diagnostic(d, "check"),
4093
+ diagnostics: distinct([...bindingDiagnostics, error.diagnostic]).map(
4094
+ (d) => diagnostic(d, "check"),
3667
4095
  ),
3668
4096
  };
3669
4097
  throw error;