@hyperscale0/hsx 1.0.334 → 1.0.336

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.
Files changed (40) hide show
  1. package/dist/src/attachment-economics.d.ts +16 -2
  2. package/dist/src/attachment-economics.d.ts.map +1 -1
  3. package/dist/src/attachment-economics.js +58 -2
  4. package/dist/src/attachment-economics.js.map +1 -1
  5. package/dist/src/catalog-program.d.ts +1 -1
  6. package/dist/src/catalog-program.d.ts.map +1 -1
  7. package/dist/src/catalog-program.js +1 -0
  8. package/dist/src/catalog-program.js.map +1 -1
  9. package/dist/src/cli.js +2 -2
  10. package/dist/src/cli.js.map +1 -1
  11. package/dist/src/compile.d.ts +5 -0
  12. package/dist/src/compile.d.ts.map +1 -1
  13. package/dist/src/compile.js +47 -5
  14. package/dist/src/compile.js.map +1 -1
  15. package/dist/src/examples-bundle.d.ts.map +1 -1
  16. package/dist/src/examples-bundle.js +1 -0
  17. package/dist/src/examples-bundle.js.map +1 -1
  18. package/dist/src/index.d.ts +1 -1
  19. package/dist/src/index.d.ts.map +1 -1
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/parse.js +13 -0
  22. package/dist/src/parse.js.map +1 -1
  23. package/dist/src/std-bundle.js +1 -1
  24. package/dist/src/std-bundle.js.map +1 -1
  25. package/dist/src/template-guide.d.ts.map +1 -1
  26. package/dist/src/template-guide.js +18 -4
  27. package/dist/src/template-guide.js.map +1 -1
  28. package/docs/README.md +11 -1
  29. package/examples/camera-rental.hsx +25 -0
  30. package/package.json +4 -4
  31. package/src/attachment-economics.ts +77 -2
  32. package/src/catalog-program.ts +1 -0
  33. package/src/cli.ts +2 -2
  34. package/src/compile.ts +73 -8
  35. package/src/examples-bundle.ts +1 -0
  36. package/src/index.ts +1 -0
  37. package/src/parse.ts +15 -0
  38. package/src/std-bundle.ts +1 -1
  39. package/src/template-guide.ts +21 -4
  40. package/std/money.hsx +46 -6
package/docs/README.md CHANGED
@@ -459,13 +459,23 @@ attach visit = wallet.spend {
459
459
  ```
460
460
 
461
461
  Name the original action, before its public alias. If it has several moves,
462
- select a move key, for example `economics pay.move1 { ... }`. The compiler
462
+ select a move key, for example `economics pay.move1 { ... }`. A record's
463
+ action takes the record name first: a schedule's payments are
464
+ `economics occurrence.pay { ... }`. The compiler
463
465
  refuses an unknown action or move, a repeated selection, a void, or economics
464
466
  that conflict with the instrument or its reservation. Cash purposes require
465
467
  cash accounts. `sourceParty` resolves a party parameter, a subject role or a
466
468
  declared business. The attachment retains this binding even when only economics
467
469
  uses it. The credited account supplies the destination participant.
468
470
 
471
+ Stock transfers, splits and schedules declare no purpose, because the purpose
472
+ depends on how the attach binds its parties. When an attached move that
473
+ creates or reserves money has none, `hsx check` and `composer.check` return an
474
+ `economic_purpose_missing` warning on the attach line. Its fix is the line to
475
+ add, with `earning` when the payee binds `operator`, `participant_payout` when
476
+ the payer does, and `pass_through` otherwise. A warning does not block a
477
+ freeze, but the books leave an unclassified payment out of revenue.
478
+
469
479
  The compiler writes the declaration into that attachment's UDL move. Imported
470
480
  instruments stay generic. New Builds carry the purpose; existing agreements
471
481
  and postings keep their retained Build and classification.
@@ -0,0 +1,25 @@
1
+ // Riyadh camera shop holds a 1,000 SAR deposit on each rental and charges 50 SAR
2
+ // for each day a camera comes back late, after one day of grace, up to 300 SAR.
3
+ program camera_rentals "Camera rentals"
4
+ use money
5
+
6
+ // The customer owns the rental and pays both the deposit and any late fee.
7
+ // The shop is payee, so its own session keeps a damaged camera's deposit with
8
+ // keep_deposit; released_by stays on the default payee. Late fees are earnings.
9
+ object rental "Camera rental" {
10
+ fields { camera: text, returnBy: date }
11
+ columns: [camera, returnBy]
12
+ attach deposit = money.hold {
13
+ payer: owner, payee: operator, amount: 1000 SAR
14
+ expose create as agree_deposit
15
+ expose fund as pay_deposit
16
+ expose release as keep_deposit
17
+ expose refund as return_deposit
18
+ }
19
+ attach late = money.late_fee {
20
+ payer: owner, payee: operator, amount: 50 SAR, cap: 300 SAR, period: 1d, grace: 1d
21
+ rename { dueAt: returnBy }
22
+ expose create as agree_late_fees
23
+ expose stop as record_return
24
+ }
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperscale0/hsx",
3
- "version": "1.0.334",
3
+ "version": "1.0.336",
4
4
  "description": "The strictly typed HSX language: compile total financial programs into canonical UDL.",
5
5
  "keywords": [
6
6
  "hsx",
@@ -90,8 +90,8 @@
90
90
  "bases:bundle": "bun scripts/bundle-bases.ts"
91
91
  },
92
92
  "dependencies": {
93
- "@hyperscale0/adl": "1.0.334",
94
- "@hyperscale0/udl": "1.0.334",
93
+ "@hyperscale0/adl": "1.0.336",
94
+ "@hyperscale0/udl": "1.0.336",
95
95
  "fast-sha256": "1.3.0"
96
96
  },
97
97
  "devDependencies": {
@@ -103,5 +103,5 @@
103
103
  "Amir Ayub",
104
104
  "Sara AlBakaawi"
105
105
  ],
106
- "gitHead": "63ce2f153ec13ff17f2ea8a910cc134bfecdab16"
106
+ "gitHead": "67d9a9c1813a060a75fb3788960247f9dc86000f"
107
107
  }
@@ -6,7 +6,7 @@ import {
6
6
  type UdlDocument,
7
7
  type UdlInstrument,
8
8
  } from "@hyperscale0/udl";
9
- import type { Entry, Expr } from "./ast.ts";
9
+ import type { Diagnostic, Entry, Expr, InstrumentDecl } from "./ast.ts";
10
10
  import { fail } from "./diagnostics.ts";
11
11
 
12
12
  /** An attachment specializes its own instrument; imported definitions stay generic. */
@@ -16,6 +16,7 @@ export function applyAttachmentEconomics(
16
16
  parties: Record<string, AttachmentPartyBinding>,
17
17
  declarations: Entry[],
18
18
  data: (expression: Expr) => unknown,
19
+ records: readonly string[] = [],
19
20
  ): void {
20
21
  const selected = new Set<object>();
21
22
  for (const declaration of declarations) {
@@ -28,7 +29,7 @@ export function applyAttachmentEconomics(
28
29
  fail(
29
30
  declaration,
30
31
  `unknown economics action ${actionName}`,
31
- `choose ${instrument.actionOrder.join(", ")}, before its public alias`,
32
+ `choose ${instrument.actionOrder.join(", ")}, before its public alias${records.length ? `, or record.action for a record (${records.join(", ")})` : ""}`,
32
33
  );
33
34
  const move = moveKey
34
35
  ? action.moves.find((move) => move.key === moveKey)
@@ -101,3 +102,77 @@ export function applyAttachmentEconomics(
101
102
  move.economics = economics;
102
103
  }
103
104
  }
105
+
106
+ /**
107
+ * A money move an attachment leaves without a purpose books as neither
108
+ * revenue nor a payout. The warning names the economics line to add; its
109
+ * purpose follows the bound parties: earning into the operator, a payout out
110
+ * of it, money passing through otherwise.
111
+ */
112
+ export function purposeWarnings(
113
+ document: UdlDocument,
114
+ selectors: ReadonlyMap<string, { prefix: string; root: string }>,
115
+ attaches: ReadonlyMap<
116
+ string,
117
+ { entry: Entry; name: string; template: InstrumentDecl }
118
+ >,
119
+ ): Diagnostic[] {
120
+ const warnings: Diagnostic[] = [];
121
+ const bindings = new Map(
122
+ document.objects.flatMap((object) =>
123
+ object.attachments.map((item) => [item.instrument, item.parties]),
124
+ ),
125
+ );
126
+ const party = (path: string | undefined) =>
127
+ /^party\.(\w+)$/.exec(path ?? "")?.[1];
128
+ const operator = (name: string | undefined) =>
129
+ name === "operator" || name === "programOperator";
130
+ for (const instrument of document.instruments) {
131
+ const selector = selectors.get(instrument.id);
132
+ const attach = selector && attaches.get(selector.root);
133
+ if (!selector || !attach) continue;
134
+ const parties = bindings.get(instrument.id) ?? {};
135
+ const parameters = attach.template.parameters
136
+ .filter((row) => row.value.kind === "name" && row.value.value === "party")
137
+ .map((row) => row.key);
138
+ // The template's own parameter reads back in the founder's terms.
139
+ const parameter = (name: string) =>
140
+ parameters.find((key) => {
141
+ const binding = parties[key];
142
+ return (
143
+ binding && ("role" in binding ? binding.role : binding.party) === name
144
+ );
145
+ }) ?? name;
146
+ for (const actionName of instrument.actionOrder) {
147
+ const action = instrument.actions[actionName]!;
148
+ for (const move of action.moves) {
149
+ if (
150
+ move.economics ||
151
+ (move.operation !== "internal_transfer.create" &&
152
+ move.operation !== "internal_transfer.reserve")
153
+ )
154
+ continue;
155
+ const path = `${selector.prefix}${actionName}${action.moves.length > 1 ? `.${move.key}` : ""}`;
156
+ const from = party(move.from);
157
+ const to = party(move.to);
158
+ const purpose = operator(to)
159
+ ? "earning"
160
+ : operator(from)
161
+ ? "participant_payout"
162
+ : "pass_through";
163
+ const source = from ? parameter(from) : (parameters[0] ?? "owner");
164
+ warnings.push({
165
+ code: "economic_purpose_missing",
166
+ message: `${attach.name}: ${path} moves money with no declared purpose, so the books will not count it as revenue or a payout`,
167
+ fix: `add economics ${path} { purpose: ${purpose}, sourceParty: ${source} } inside attach ${attach.name}`,
168
+ // Underline `attach <name>`, the line the declaration goes under.
169
+ span: {
170
+ start: attach.entry.span.start,
171
+ end: attach.entry.span.start + `attach ${attach.name}`.length,
172
+ },
173
+ });
174
+ }
175
+ }
176
+ }
177
+ return warnings;
178
+ }
@@ -30,6 +30,7 @@ object item "Item" {
30
30
  attach payout = money.payout { payer: actor, payee: owner, adapter: "conformance_boundary" }
31
31
  attach mandate = money.mandate { payer: actor, payee: owner }
32
32
  attach metered = money.metered { payer: actor, payee: owner, unit_price: 10 SAR }
33
+ attach late_fee = money.late_fee { payer: actor, payee: owner, amount: 50 SAR, cap: 500 SAR }
33
34
  attach policy = insurance.cover { holder: actor, adapter: "conformance_boundary" }
34
35
  attach claim = insurance.claim { cover: policy, inspector: inspector }
35
36
  attach cov = insurance.cover { holder: actor, broker: supplier, adapter: "conformance_boundary", covers: sale }
package/src/cli.ts CHANGED
@@ -85,9 +85,9 @@ export async function runCli(argv: readonly string[], io: Io): Promise<number> {
85
85
  text = result.formatted;
86
86
  } else {
87
87
  const result = compile(source, { standardLibrary });
88
- for (const d of result.diagnostics)
88
+ for (const d of [...result.diagnostics, ...result.warnings])
89
89
  io.err(
90
- `${d.source && d.source !== "program" ? d.source : file}:${d.line}:${d.column}: ${d.code} ${d.message.replace(/[^.?!]$/, "$&.")} ${d.fix}`,
90
+ `${d.source && d.source !== "program" ? d.source : file}:${d.line}:${d.column}: ${d.severity === "warning" ? "warning " : ""}${d.code} ${d.message.replace(/[^.?!]$/, "$&.")} ${d.fix}`,
91
91
  );
92
92
  if (!result.artifacts || result.verdict !== "valid") return 1;
93
93
  if (command === "check") return 0;
package/src/compile.ts CHANGED
@@ -40,7 +40,10 @@ import {
40
40
  } from "./binding-contract.ts";
41
41
  import { tunableBounds } from "./tunables.ts";
42
42
  import { parseProgram } from "./parse.ts";
43
- import { applyAttachmentEconomics } from "./attachment-economics.ts";
43
+ import {
44
+ applyAttachmentEconomics,
45
+ purposeWarnings,
46
+ } from "./attachment-economics.ts";
44
47
  import { parseHeader } from "./header-source.ts";
45
48
  import {
46
49
  lineColAt,
@@ -79,9 +82,14 @@ export interface CompileOptions {
79
82
  standardLibrary?: StandardLibrary;
80
83
  adapterRegistry?: Readonly<Record<string, AdapterBindingTarget>>;
81
84
  }
85
+ export interface CompileWarning extends Omit<CompileDiagnostic, "severity"> {
86
+ severity: "warning";
87
+ }
82
88
  export interface CompileResult {
83
89
  verdict: "valid" | "invalid";
84
90
  diagnostics: CompileDiagnostic[];
91
+ /** Advice that does not block a freeze, such as a money move with no purpose. */
92
+ warnings: CompileWarning[];
85
93
  artifacts?: {
86
94
  document: UdlDocument;
87
95
  costManifest: UdlCostManifest;
@@ -392,8 +400,10 @@ function literal(expr: Expr, expectedType?: string): string | number | boolean {
392
400
  if (!Number.isSafeInteger(n) || n <= 0)
393
401
  fail(
394
402
  expr,
395
- "invalid duration",
396
- "write a positive fixed duration such as 48h or 3d",
403
+ expr.value.includes(" ")
404
+ ? `${expr.value} is not a fixed duration`
405
+ : "invalid duration",
406
+ "write a positive fixed duration such as 48h, 3d or 30d",
397
407
  );
398
408
  return n;
399
409
  }
@@ -463,6 +473,7 @@ export function compile(
463
473
  return {
464
474
  verdict: "invalid",
465
475
  diagnostics: parsed.diagnostics.map((d) => diagnostic(d, "parse")),
476
+ warnings: [],
466
477
  };
467
478
  const program = parsed.program;
468
479
  const bindingDiagnostics: Diagnostic[] = [];
@@ -489,6 +500,15 @@ export function compile(
489
500
  "write currency SAR or omit currency",
490
501
  );
491
502
  const templates = new Map<string, InstrumentDecl>();
503
+ // Attached instruments, for the warning on a money move with no purpose.
504
+ const attachedSelectors = new Map<
505
+ string,
506
+ { prefix: string; root: string }
507
+ >();
508
+ const attachEntries = new Map<
509
+ string,
510
+ { entry: Entry; name: string; template: InstrumentDecl }
511
+ >();
492
512
  const declarationSources = new Map<InstrumentDecl, string>();
493
513
  const standardOrigins = new Map<
494
514
  InstrumentDecl,
@@ -746,6 +766,11 @@ export function compile(
746
766
  attachments: UdlObjectAttachment[];
747
767
  economics: Entry[];
748
768
  child?: boolean;
769
+ /** How an economics selector names this instrument: "" or "occurrence.". */
770
+ selectorPrefix?: string;
771
+ rootInstrument?: string;
772
+ /** The template as the founder wrote it, such as money.schedule. */
773
+ template?: string;
749
774
  },
750
775
  familyDeclaration?: {
751
776
  module: string;
@@ -933,6 +958,7 @@ export function compile(
933
958
  enums.set(parameter.key, type.args.map(text));
934
959
  }
935
960
  const supplied = new Map<string, Expr>();
961
+ const suppliedEntries = new Map<string, Entry>();
936
962
  for (const entry of arguments_.entries) {
937
963
  if (supplied.has(entry.key))
938
964
  fail(
@@ -941,6 +967,7 @@ export function compile(
941
967
  "supply each parameter once",
942
968
  );
943
969
  supplied.set(entry.key, entry.value);
970
+ suppliedEntries.set(entry.key, entry);
944
971
  }
945
972
  const environment = new Map<string, Expr>(inherited);
946
973
  const parameterErrors: Diagnostic[] = [];
@@ -1082,10 +1109,11 @@ export function compile(
1082
1109
  `Remove \`${key}\`. To use your object's money field, add \`rename { ${field}: ${target} }\`.`,
1083
1110
  );
1084
1111
  }
1112
+ const tunables = decl.parameters.map((p) => p.key);
1085
1113
  fail(
1086
- suppliedValue,
1087
- `unknown tunable ${key}`,
1088
- `choose ${decl.parameters.map((p) => p.key).join(", ")}`,
1114
+ suppliedEntries.get(key) ?? suppliedValue,
1115
+ `${assignments.get(id)?.target ?? attachmentInfo?.template ?? decl.name} has no tunable "${key}".${didYouMean(key, tunables)} Tunables: ${tunables.join(", ")}`,
1116
+ `remove ${key}, or set one of ${tunables.join(", ")}`,
1089
1117
  );
1090
1118
  }
1091
1119
  const isParty = (name: string) =>
@@ -3205,14 +3233,39 @@ export function compile(
3205
3233
  );
3206
3234
  if (receipt) move.transfer = `self.${receipt}`;
3207
3235
  }
3208
- if (attachmentInfo && !attachmentInfo.child)
3236
+ // `economics occurrence.pay` classifies a record's move; an action of
3237
+ // the attached instrument wins over a record of the same name.
3238
+ const recordEconomics = new Map<string, Entry[]>();
3239
+ if (attachmentInfo) {
3240
+ const own: Entry[] = [];
3241
+ for (const declaration of attachmentInfo.economics) {
3242
+ const [first = "", ...rest] = declaration.key
3243
+ .slice("economics ".length)
3244
+ .split(".");
3245
+ if (
3246
+ rest.length &&
3247
+ !Object.hasOwn(inst.actions, first) &&
3248
+ records.has(first)
3249
+ )
3250
+ recordEconomics.set(first, [
3251
+ ...(recordEconomics.get(first) ?? []),
3252
+ { ...declaration, key: `economics ${rest.join(".")}` },
3253
+ ]);
3254
+ else own.push(declaration);
3255
+ }
3209
3256
  applyAttachmentEconomics(
3210
3257
  document,
3211
3258
  inst,
3212
3259
  attachmentInfo.parties,
3213
- attachmentInfo.economics,
3260
+ own,
3214
3261
  data,
3262
+ [...records.keys()],
3215
3263
  );
3264
+ attachedSelectors.set(inst.id, {
3265
+ prefix: attachmentInfo.selectorPrefix ?? "",
3266
+ root: attachmentInfo.rootInstrument ?? inst.id,
3267
+ });
3268
+ }
3216
3269
  for (const key of [
3217
3270
  "invariants",
3218
3271
  "reports",
@@ -3292,6 +3345,9 @@ export function compile(
3292
3345
  attachmentName: `${attachmentInfo.attachmentName}_${key}`,
3293
3346
  child: true,
3294
3347
  exposed: new Map<string, string>(),
3348
+ economics: recordEconomics.get(key) ?? [],
3349
+ selectorPrefix: `${attachmentInfo.selectorPrefix ?? ""}${key}.`,
3350
+ rootInstrument: attachmentInfo.rootInstrument ?? id,
3295
3351
  }
3296
3352
  : undefined;
3297
3353
  if (childAttachment)
@@ -3450,6 +3506,7 @@ export function compile(
3450
3506
 
3451
3507
  const parties: Record<string, AttachmentPartyBinding> = {};
3452
3508
  attachments.push({ name: attachmentName, instrument: instId, parties });
3509
+ attachEntries.set(instId, { entry, name: attachmentName, template });
3453
3510
 
3454
3511
  const tunableBlock: BlockExpr = {
3455
3512
  kind: "block",
@@ -3475,6 +3532,7 @@ export function compile(
3475
3532
  parties,
3476
3533
  attachments,
3477
3534
  economics,
3535
+ template: targetTemplate,
3478
3536
  },
3479
3537
  templateFamily ? { ...templateFamily } : undefined,
3480
3538
  standardOrigins.get(template),
@@ -3689,6 +3747,7 @@ export function compile(
3689
3747
  if (bindingDiagnostics.length)
3690
3748
  return {
3691
3749
  verdict: "invalid",
3750
+ warnings: [],
3692
3751
  diagnostics: distinct(bindingDiagnostics)
3693
3752
  .sort((a, b) => a.span.start - b.span.start)
3694
3753
  .map((d) => diagnostic(d, "check")),
@@ -4500,6 +4559,7 @@ export function compile(
4500
4559
  if (collisions.length)
4501
4560
  return {
4502
4561
  verdict: "invalid",
4562
+ warnings: [],
4503
4563
  diagnostics: collisions
4504
4564
  .sort((a, b) => a.span.start - b.span.start)
4505
4565
  .map((d) => diagnostic(d, "check")),
@@ -4577,6 +4637,7 @@ export function compile(
4577
4637
  if (!validated.ok)
4578
4638
  return {
4579
4639
  verdict: "invalid",
4640
+ warnings: [],
4580
4641
  diagnostics: issues.map((i) => {
4581
4642
  const origin = [...origins]
4582
4643
  .reverse()
@@ -4652,6 +4713,9 @@ export function compile(
4652
4713
  return {
4653
4714
  verdict: "valid",
4654
4715
  diagnostics: [],
4716
+ warnings: purposeWarnings(document, attachedSelectors, attachEntries).map(
4717
+ (d) => ({ ...diagnostic(d, "check"), severity: "warning" }),
4718
+ ),
4655
4719
  artifacts: {
4656
4720
  document: validated.value,
4657
4721
  costManifest: buildUdlCostManifest(validated.value),
@@ -4662,6 +4726,7 @@ export function compile(
4662
4726
  if (error instanceof CompileFailure)
4663
4727
  return {
4664
4728
  verdict: "invalid",
4729
+ warnings: [],
4665
4730
  diagnostics: distinct([...bindingDiagnostics, error.diagnostic]).map(
4666
4731
  (d) => diagnostic(d, "check"),
4667
4732
  ),
@@ -1,6 +1,7 @@
1
1
  // Generated by open/hsx/scripts/bundle-examples.ts. Do not edit by hand.
2
2
  import type { HsxExample } from "./examples.ts";
3
3
  export const examples: HsxExample[] = [
4
+ {"id":"camera-rental","title":"Camera rentals","summary":"Riyadh camera shop holds a 1,000 SAR deposit on each rental and charges 50 SAR for each day a camera comes back late, after one day of grace, up to 300 SAR.","headers":["money"],"source":"// Riyadh camera shop holds a 1,000 SAR deposit on each rental and charges 50 SAR\n// for each day a camera comes back late, after one day of grace, up to 300 SAR.\nprogram camera_rentals \"Camera rentals\"\nuse money\n\n// The customer owns the rental and pays both the deposit and any late fee.\n// The shop is payee, so its own session keeps a damaged camera's deposit with\n// keep_deposit; released_by stays on the default payee. Late fees are earnings.\nobject rental \"Camera rental\" {\n fields { camera: text, returnBy: date }\n columns: [camera, returnBy]\n attach deposit = money.hold {\n payer: owner, payee: operator, amount: 1000 SAR\n expose create as agree_deposit\n expose fund as pay_deposit\n expose release as keep_deposit\n expose refund as return_deposit\n }\n attach late = money.late_fee {\n payer: owner, payee: operator, amount: 50 SAR, cap: 300 SAR, period: 1d, grace: 1d\n rename { dueAt: returnBy }\n expose create as agree_late_fees\n expose stop as record_return\n }\n}\n"},
4
5
  {"id":"cards","title":"Boston catering cards","summary":"Boston catering cards lets staff reserve supplier purchases up to 5,000 SAR each. Supplier cancellation releases the reservation; captured purchases support a full refund or a reviewed dispute.","headers":["cards"],"source":"// Boston catering cards lets staff reserve supplier purchases up to 5,000 SAR each.\n// Supplier cancellation releases the reservation; captured purchases support a full refund or a reviewed dispute.\nprogram team_expense_cards \"Boston catering cards\"\nuse cards\nparty supplier: business\n\n// Bind supplier to the food wholesaler. Staff customers own their card records.\n// Card fee: 0%, tax: 0% on card access because the caterer charges staff nothing.\n// A supplier invoice might be 2,000 SAR plus 15% VAT, paid as one 2,300 SAR\n// authorization. cards does not calculate or remit the supplier's invoice VAT.\nobject employee \"Catering expense card\" {\n fields { employeeName: text, department: text }\n columns: [employeeName, department]\n attach holder = cards.cardholder {\n holder: owner\n expose create as enrol_employee\n expose activate as activate_employee\n expose suspend as suspend_employee\n expose resume as restore_employee\n expose close as close_employee_access\n expose operator_close as remove_employee_access\n }\n attach card = cards.card {\n holder: holder, person: owner, issuer: operator, spend_limit: 5000 SAR\n expose create as issue_card\n expose activate as activate_card\n expose freeze as freeze_lost_card\n expose unfreeze as restore_found_card\n expose issuer_freeze as block_card\n expose issuer_unfreeze as unblock_card\n expose cancel as cancel_card\n expose issuer_cancel as retire_employee_card\n }\n // Supply the gross invoice amount and expiresAt, such as 24 hours after\n // ordering. Pending and approved authorizations expire without capture.\n attach payment = cards.authorization {\n card: card, merchant: supplier, issuer: operator\n expose create as request_supplier_payment\n expose approve as approve_payment\n expose decline as decline_payment\n expose capture as capture_delivered_order\n expose reverse as cancel_supplier_reservation\n }\n attach receipt = cards.transaction {\n authorization: payment\n expose create as record_captured_purchase\n expose refund as refund_supplier_purchase\n }\n attach complaint = cards.dispute {\n transaction: receipt, issuer: operator, within: 30d\n expose create as dispute_supplier_purchase\n expose review as review_purchase_dispute\n expose win as uphold_dispute_and_refund\n }\n // A dispute unresolved after 30 days becomes lost. A refund always needs\n // the supplier's funds; the card header does not prove card-network settlement.\n}\n"},
5
6
  {"id":"collections","title":"Portland cafe equipment finance","summary":"Portland cafe equipment finance lends 24,000 SAR over 12 months and refers debt seven days overdue. Its agency earns 10% of each recovery from the lender, with at most four contacts per 30 days.","headers":["collections","financing"],"source":"// Portland cafe equipment finance lends 24,000 SAR over 12 months and refers debt seven days overdue.\n// Its agency earns 10% of each recovery from the lender, with at most four contacts per 30 days.\nprogram business_collections \"Portland cafe equipment finance\"\nuse collections\nuse financing\nparty agency: business\n\n// Bind agency to the collector. The cafe owns the facility. Set price to\n// 24,000 SAR: 6% flat profit is 1,440 SAR, repaid in twelve 2,120 SAR payments.\n// Loan fee: 0%, tax: 0% on principal. The agency invoices 15% VAT on its\n// recovery service outside collections.case, which posts only the 10% fee.\nobject facility \"Cafe equipment facility\" {\n fields { businessName: text, purpose: text, price: money }\n columns: [businessName, purpose, price]\n attach allowance = financing.limits {\n borrower: owner, per_borrower: 100000 SAR\n expose create as request_borrower_limit\n expose approve as approve_borrower\n }\n attach budget = financing.portfolio_limit {\n limit: 2000000 SAR\n expose create as create_lending_budget\n expose approve as approve_budget\n }\n // Approve both limits and provide twelve future due dates before disbursement.\n attach loan = financing.installments {\n borrower: owner, capital: operator, share: 0%\n months: 12, pricing: flat_total, profit_rate: 6%, disburse_to: borrower\n payoff_rebate: 100%, refund_within: 30d, write_off_after: 90d\n limits: allowance, portfolio: budget\n expose create as quote_equipment_loan\n expose sign as sign_loan\n expose disburse as release_loan\n expose cancel as decline_loan_quote\n expose void as cancel_unfunded_loan\n expose payoff as repay_early\n expose complete as close_repaid_loan\n }\n // Select matching plan, overdue slice and pending payment when opening.\n // Recovery pays that payment, not necessarily the whole remaining debt.\n attach recovery = collections.case {\n on: loan, agency: agency, capital: operator, overdue: 7d, fee: 10%\n expose create as refer_overdue_instalment\n expose assign as assign_agency\n expose recover as collect_referred_payment\n expose recall as recall_collection_case\n expose return_uncollected as return_uncollected_case\n }\n attach contact = collections.contact {\n case: recovery, agency: agency\n max_contacts: 4, window: 30d, contact_from: 9, contact_until: 18\n timezone: \"Asia/Riyadh\"\n expose create as record_collection_contact\n }\n}\n\nexpose facility.contact.outcome.create as record_contact_outcome\nexpose facility.loan.payment.create as record_repayment\nexpose facility.loan.payment.pay as pay_instalment\nexpose facility.loan.payment.refund as refund_mistaken_repayment\nexpose facility.loan.payoff_quote.create as request_payoff_quote\n// A disputed contact can be recorded, then capital can recall the case.\n// Uncollectible principal needs a separate capital decision after 90 days.\nexpose facility.loan.write_off_request.create as request_overdue_write_off\nexpose facility.loan.write_off_request.apply as approve_overdue_write_off\nexpose facility.loan.write_off_request.cancel as cancel_write_off_request\n"},
6
7
  {"id":"escrow","title":"Seattle used laptops","summary":"Seattle used laptops holds a buyer's 2,300 SAR until the laptop arrives and passes inspection. Delivery has seven days; the buyer has 48 hours to accept or dispute before the operator reviews the sale.","headers":["escrow"],"source":"// Seattle used laptops holds a buyer's 2,300 SAR until the laptop arrives and passes inspection.\n// Delivery has seven days; the buyer has 48 hours to accept or dispute before the operator reviews the sale.\nprogram used_device_sales \"Seattle used laptops\"\nuse escrow\nparty seller: business\n\n// Bind seller to the refurbisher. Set price to the agreed gross invoice, for\n// example 2,300 SAR. The company keeps 5%, 115 SAR, plus 15% VAT on that fee.\n// Seller receives 2,167.75 SAR. Any VAT on the laptop is on the seller's invoice.\nobject device \"Laptop purchase\" {\n fields { model: text, condition: text, serialNumber: text, price: money }\n columns: [model, condition, serialNumber, price]\n attach sale = escrow.hold {\n payer: owner, payee: seller\n fee: { seller: 5%, tax: 15% }\n deliver_within: 7d, accept_within: 48h\n dispute: { refund_after: return_verified }\n expose create as agree_purchase\n expose fund as pay_for_laptop\n expose deliver as confirm_delivery\n expose accept as accept_laptop\n expose dispute as report_problem\n expose verify_return as verify_returned_laptop\n expose refund as refund_buyer\n expose refund_undelivered as refund_missing_delivery\n expose settle_release as resolve_in_seller_favour\n expose settle_return as resolve_in_buyer_favour\n expose settle_refund as finish_buyer_refund\n expose cancel as cancel_unpaid_purchase\n }\n}\n"},
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  type CompileDiagnostic,
4
4
  type CompileOptions,
5
5
  type CompileResult,
6
+ type CompileWarning,
6
7
  type CompileOriginMapEntry,
7
8
  type AdapterBindingTarget,
8
9
  } from "./compile.ts";
package/src/parse.ts CHANGED
@@ -514,6 +514,21 @@ class Parser {
514
514
  };
515
515
  if (/[a-z]$/.test(token.text))
516
516
  return { kind: "duration", value: token.text, span: token.span };
517
+ // `1 month` reads as one value, so the checker names the tunable it
518
+ // was given to instead of failing on the next line. A unit followed by
519
+ // `:` is the next entry's key, as in `count: 2` then `months: 2`.
520
+ if (
521
+ /^(?:minute|hour|day|week|month|year)s?$/.test(this.peek().text) &&
522
+ this.peek().kind === "name" &&
523
+ this.tokens[this.index + 1]?.text !== ":"
524
+ ) {
525
+ const unit = this.take().text;
526
+ return {
527
+ kind: "duration",
528
+ value: `${token.text} ${unit}`,
529
+ span: this.span(start),
530
+ };
531
+ }
517
532
  if (/^[A-Z]{3}$/.test(this.peek().text)) {
518
533
  const currency = this.take().text;
519
534
  return {