@hyperscale0/hsx 1.0.0-beta.1 → 1.0.0-rc.1
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 +41 -9
- package/LICENSING.md +3 -3
- package/README.md +37 -149
- package/TRADEMARKS.md +4 -4
- package/dist/src/ast.d.ts +129 -10
- package/dist/src/ast.d.ts.map +1 -1
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +148 -12
- package/dist/src/cli.js.map +1 -1
- package/dist/src/compile.d.ts +41 -4
- package/dist/src/compile.d.ts.map +1 -1
- package/dist/src/compile.js +82 -15
- package/dist/src/compile.js.map +1 -1
- package/dist/src/cost.d.ts +78 -0
- package/dist/src/cost.d.ts.map +1 -0
- package/dist/src/cost.js +369 -0
- package/dist/src/cost.js.map +1 -0
- package/dist/src/diagnostics.d.ts +11 -0
- package/dist/src/diagnostics.d.ts.map +1 -0
- package/dist/src/diagnostics.js +420 -0
- package/dist/src/diagnostics.js.map +1 -0
- package/dist/src/emit.d.ts +35 -0
- package/dist/src/emit.d.ts.map +1 -0
- package/dist/src/emit.js +359 -0
- package/dist/src/emit.js.map +1 -0
- package/dist/src/entry-overrides.d.ts +17 -3
- package/dist/src/entry-overrides.d.ts.map +1 -1
- package/dist/src/entry-overrides.js +180 -35
- package/dist/src/entry-overrides.js.map +1 -1
- package/dist/src/format.d.ts +10 -0
- package/dist/src/format.d.ts.map +1 -0
- package/dist/src/format.js +183 -0
- package/dist/src/format.js.map +1 -0
- package/dist/src/index.d.ts +10 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/ir.d.ts +66 -0
- package/dist/src/ir.d.ts.map +1 -0
- package/dist/src/ir.js +15 -0
- package/dist/src/ir.js.map +1 -0
- package/dist/src/lex.d.ts +9 -1
- package/dist/src/lex.d.ts.map +1 -1
- package/dist/src/lex.js +50 -4
- package/dist/src/lex.js.map +1 -1
- package/dist/src/limits.d.ts +2 -3
- package/dist/src/limits.d.ts.map +1 -1
- package/dist/src/limits.js +2 -3
- package/dist/src/limits.js.map +1 -1
- package/dist/src/modules.d.ts +36 -0
- package/dist/src/modules.d.ts.map +1 -0
- package/dist/src/modules.js +372 -0
- package/dist/src/modules.js.map +1 -0
- package/dist/src/parse.d.ts.map +1 -1
- package/dist/src/parse.js +590 -17
- package/dist/src/parse.js.map +1 -1
- package/dist/src/typecheck.d.ts +8 -0
- package/dist/src/typecheck.d.ts.map +1 -0
- package/dist/src/typecheck.js +2958 -0
- package/dist/src/typecheck.js.map +1 -0
- package/dist/src/version.d.ts +4 -15
- package/dist/src/version.d.ts.map +1 -1
- package/dist/src/version.js +4 -15
- package/dist/src/version.js.map +1 -1
- package/docs/README.md +23 -0
- package/docs/guide/01-first-program.md +24 -0
- package/docs/guide/02-money.md +25 -0
- package/docs/guide/03-instruments.md +22 -0
- package/docs/guide/04-lifecycles.md +25 -0
- package/docs/guide/05-fees-and-splits.md +45 -0
- package/docs/guide/06-schedules.md +22 -0
- package/docs/guide/07-composition.md +46 -0
- package/docs/guide/08-writing-a-module.md +26 -0
- package/docs/guide/09-cost.md +24 -0
- package/docs/guide/10-diagnostics.md +27 -0
- package/docs/llms-full.txt +1738 -0
- package/docs/llms.txt +45 -0
- package/docs/reference/cli.md +33 -0
- package/docs/reference/diagnostics.md +619 -0
- package/docs/reference/grammar.md +39 -0
- package/docs/reference/std/advance.md +35 -0
- package/docs/reference/std/cancellable_booking.md +35 -0
- package/docs/reference/std/captured_payment.md +43 -0
- package/docs/reference/std/conditional_disbursement.md +33 -0
- package/docs/reference/std/credit_facility.md +35 -0
- package/docs/reference/std/held_payment.md +64 -0
- package/docs/reference/std/instant_transfer.md +30 -0
- package/docs/reference/std/metered.md +27 -0
- package/docs/reference/std/pooled_split.md +26 -0
- package/docs/reference/std/premium_forward.md +39 -0
- package/docs/reference/std/reconciled_payout.md +33 -0
- package/docs/reference/std/recurring_collection.md +25 -0
- package/docs/reference/std/rotating_pool.md +42 -0
- package/docs/reference/std/scheduled.md +51 -0
- package/docs/reference/std/security_deposit.md +46 -0
- package/docs/reference/std/settlement_batch.md +42 -0
- package/docs/reference/std/swap.md +46 -0
- package/docs/reference/std/threshold_pool.md +47 -0
- package/docs/reference/std/weighted_distribution.md +39 -0
- package/docs/reference/types.md +20 -0
- package/docs/reference/udl-output.md +14 -0
- package/docs/sessions/2026-09-02-two-instruments.hsx +21 -0
- package/docs/sessions/2026-09-02-two-instruments.md +41 -0
- package/examples/01-first-program/README.md +45 -0
- package/examples/01-first-program/tip-jar.hsx +17 -0
- package/examples/02-imports-and-archetypes/README.md +99 -0
- package/examples/02-imports-and-archetypes/photo-booth.hsx +37 -0
- package/examples/03-diagnostics/README.md +68 -0
- package/examples/03-diagnostics/corner-shop-fixed.hsx +22 -0
- package/examples/03-diagnostics/corner-shop.hsx +19 -0
- package/examples/04-complete-product/README.md +87 -0
- package/examples/04-complete-product/study-hall.hsx +67 -0
- package/examples/05-watch-club/README.md +9 -0
- package/examples/05-watch-club/watch-club.hsx +124 -0
- package/examples/README.md +18 -0
- package/examples/advance/README.md +3 -0
- package/examples/advance/advance.hsx +13 -0
- package/examples/advance/advance.udl +339 -0
- package/examples/cancellable_booking/README.md +3 -0
- package/examples/cancellable_booking/cancellable_booking.hsx +14 -0
- package/examples/cancellable_booking/cancellable_booking.udl +391 -0
- package/examples/captured_payment/README.md +3 -0
- package/examples/captured_payment/captured_payment.hsx +21 -0
- package/examples/captured_payment/captured_payment.udl +677 -0
- package/examples/conditional_disbursement/README.md +3 -0
- package/examples/conditional_disbursement/conditional_disbursement.hsx +17 -0
- package/examples/conditional_disbursement/conditional_disbursement.udl +412 -0
- package/examples/cost-table.json +386 -0
- package/examples/credit_facility/README.md +3 -0
- package/examples/credit_facility/credit_facility.hsx +27 -0
- package/examples/credit_facility/credit_facility.udl +1373 -0
- package/examples/held_payment/README.md +3 -0
- package/examples/held_payment/held_payment.hsx +13 -0
- package/examples/held_payment/held_payment.udl +507 -0
- package/examples/instant_transfer/README.md +3 -0
- package/examples/instant_transfer/instant_transfer.hsx +16 -0
- package/examples/instant_transfer/instant_transfer.udl +364 -0
- package/examples/metered/README.md +3 -0
- package/examples/metered/metered.hsx +13 -0
- package/examples/metered/metered.udl +247 -0
- package/examples/pooled_split/README.md +3 -0
- package/examples/pooled_split/pooled_split.hsx +15 -0
- package/examples/pooled_split/pooled_split.udl +425 -0
- package/examples/premium_forward/README.md +3 -0
- package/examples/premium_forward/premium_forward.hsx +19 -0
- package/examples/premium_forward/premium_forward.udl +594 -0
- package/examples/reconciled_payout/README.md +3 -0
- package/examples/reconciled_payout/reconciled_payout.hsx +13 -0
- package/examples/reconciled_payout/reconciled_payout.udl +303 -0
- package/examples/recurring_collection/README.md +3 -0
- package/examples/recurring_collection/recurring_collection.hsx +21 -0
- package/examples/recurring_collection/recurring_collection.udl +1130 -0
- package/examples/rotating_pool/README.md +3 -0
- package/examples/rotating_pool/rotating_pool.hsx +18 -0
- package/examples/rotating_pool/rotating_pool.udl +4352 -0
- package/examples/scheduled/README.md +3 -0
- package/examples/scheduled/scheduled.hsx +12 -0
- package/examples/scheduled/scheduled.udl +335 -0
- package/examples/security_deposit/README.md +3 -0
- package/examples/security_deposit/security_deposit.hsx +21 -0
- package/examples/security_deposit/security_deposit.udl +293 -0
- package/examples/settlement_batch/README.md +3 -0
- package/examples/settlement_batch/settlement_batch.hsx +19 -0
- package/examples/settlement_batch/settlement_batch.udl +753 -0
- package/examples/swap/README.md +3 -0
- package/examples/swap/swap.hsx +19 -0
- package/examples/swap/swap.udl +876 -0
- package/examples/threshold_pool/README.md +3 -0
- package/examples/threshold_pool/threshold_pool.hsx +16 -0
- package/examples/threshold_pool/threshold_pool.udl +667 -0
- package/examples/weighted_distribution/README.md +3 -0
- package/examples/weighted_distribution/weighted_distribution.hsx +20 -0
- package/examples/weighted_distribution/weighted_distribution.udl +337 -0
- package/package.json +19 -6
- package/skills/hsx/SKILL.md +483 -0
- package/src/ast.ts +163 -9
- package/src/cli.ts +164 -12
- package/src/compile.ts +160 -20
- package/src/cost.ts +579 -0
- package/src/diagnostics.ts +442 -0
- package/src/emit.ts +447 -0
- package/src/entry-overrides.ts +263 -48
- package/src/format.ts +261 -0
- package/src/index.ts +32 -4
- package/src/ir.ts +99 -0
- package/src/lex.ts +59 -4
- package/src/limits.ts +2 -3
- package/src/modules.ts +444 -0
- package/src/parse.ts +655 -21
- package/src/typecheck.ts +3876 -0
- package/src/version.ts +4 -16
- package/std/SEMANTICS.md +130 -0
- package/std/settlements/advance.hsx +177 -0
- package/std/settlements/cancellable_booking.hsx +87 -0
- package/std/settlements/captured_payment.hsx +144 -0
- package/std/settlements/conditional_disbursement.hsx +185 -0
- package/std/settlements/credit_facility.hsx +167 -0
- package/std/settlements/held_payment.hsx +578 -0
- package/std/settlements/index.hsx +3 -0
- package/std/settlements/instant_transfer.hsx +190 -0
- package/std/settlements/metered.hsx +67 -0
- package/std/settlements/pooled_split.hsx +68 -0
- package/std/settlements/premium_forward.hsx +257 -0
- package/std/settlements/reconciled_payout.hsx +77 -0
- package/std/settlements/recurring_collection.hsx +27 -0
- package/std/settlements/rotating_pool.hsx +252 -0
- package/std/settlements/scheduled.hsx +641 -0
- package/std/settlements/security_deposit.hsx +166 -0
- package/std/settlements/settlement_batch.hsx +122 -0
- package/std/settlements/swap.hsx +1114 -0
- package/std/settlements/threshold_pool.hsx +221 -0
- package/std/settlements/weighted_distribution.hsx +141 -0
- package/dist/src/archetypes.d.ts +0 -17
- package/dist/src/archetypes.d.ts.map +0 -1
- package/dist/src/archetypes.js +0 -327
- package/dist/src/archetypes.js.map +0 -1
- package/dist/src/check.d.ts +0 -12
- package/dist/src/check.d.ts.map +0 -1
- package/dist/src/check.js +0 -1910
- package/dist/src/check.js.map +0 -1
- package/dist/src/lower.d.ts +0 -146
- package/dist/src/lower.d.ts.map +0 -1
- package/dist/src/lower.js +0 -4570
- package/dist/src/lower.js.map +0 -1
- package/dist/src/model.d.ts +0 -467
- package/dist/src/model.d.ts.map +0 -1
- package/dist/src/model.js +0 -15
- package/dist/src/model.js.map +0 -1
- package/spec/hsx-ir.schema.json +0 -920
- package/src/archetypes.ts +0 -340
- package/src/check.ts +0 -2938
- package/src/lower.ts +0 -5416
- package/src/model.ts +0 -519
package/src/typecheck.ts
ADDED
|
@@ -0,0 +1,3876 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deriveUdlActionEffects,
|
|
3
|
+
udlClauseVocabulary,
|
|
4
|
+
type UdlDocument,
|
|
5
|
+
type UdlInstrument,
|
|
6
|
+
} from "@hyperscale0/udl";
|
|
7
|
+
import type {
|
|
8
|
+
ApplicationScopeDecl,
|
|
9
|
+
ApplyExpr,
|
|
10
|
+
BlockExpr,
|
|
11
|
+
ConstDecl,
|
|
12
|
+
Decl,
|
|
13
|
+
Entry,
|
|
14
|
+
Expr,
|
|
15
|
+
ExposeDecl,
|
|
16
|
+
IdentExpr,
|
|
17
|
+
InstrumentDecl,
|
|
18
|
+
PortDecl,
|
|
19
|
+
Program,
|
|
20
|
+
ProgramDecl,
|
|
21
|
+
Span,
|
|
22
|
+
SubjectDecl,
|
|
23
|
+
UseDecl,
|
|
24
|
+
} from "./ast.ts";
|
|
25
|
+
import { requiredFields } from "./emit.ts";
|
|
26
|
+
import type {
|
|
27
|
+
GeneralCheckResult,
|
|
28
|
+
GeneralDiagnostic,
|
|
29
|
+
HsxType,
|
|
30
|
+
JsonValue,
|
|
31
|
+
TypedAction,
|
|
32
|
+
TypedField,
|
|
33
|
+
TypedInstrument,
|
|
34
|
+
TypedProgram,
|
|
35
|
+
TypedSubject,
|
|
36
|
+
} from "./ir.ts";
|
|
37
|
+
|
|
38
|
+
const SNAKE_CASE = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
|
|
39
|
+
const CAMEL_CASE = /^[a-z][A-Za-z0-9]*$/;
|
|
40
|
+
const CURRENCY = /^[A-Z]{3}$/;
|
|
41
|
+
const MONEY_PATTERN = "^[1-9][0-9]{0,17}$";
|
|
42
|
+
const OPTIONAL_MONEY_PATTERN = "^(0|[1-9][0-9]{0,17})$";
|
|
43
|
+
const MAX_COMPREHENSION_EXPANSIONS = 256;
|
|
44
|
+
const NONE_SENTINEL = "__hsx_none__";
|
|
45
|
+
|
|
46
|
+
export interface GeneralCheckOptions {
|
|
47
|
+
readonly publishedCatalog?: UdlDocument;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ClauseDefinition = (typeof udlClauseVocabulary)[number];
|
|
51
|
+
|
|
52
|
+
interface ConcreteInstrument {
|
|
53
|
+
readonly aliases: ReadonlyMap<string, Expr>;
|
|
54
|
+
readonly body: BlockExpr;
|
|
55
|
+
readonly generatedPrefix: boolean;
|
|
56
|
+
readonly name: IdentExpr;
|
|
57
|
+
readonly parties: ReadonlySet<string>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const actionClauseBySpelling: ReadonlyMap<string, ClauseDefinition> = new Map(
|
|
61
|
+
udlClauseVocabulary
|
|
62
|
+
.filter((definition) => definition.scope === "action")
|
|
63
|
+
.map((definition) => [definition.spelling, definition]),
|
|
64
|
+
);
|
|
65
|
+
const instrumentClauseBySpelling: ReadonlyMap<string, ClauseDefinition> =
|
|
66
|
+
new Map(
|
|
67
|
+
udlClauseVocabulary
|
|
68
|
+
.filter((definition) => definition.scope === "instrument")
|
|
69
|
+
.map((definition) => [definition.spelling, definition]),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const minorUnits: Readonly<Record<string, number>> = {
|
|
73
|
+
BHD: 3,
|
|
74
|
+
JPY: 0,
|
|
75
|
+
KWD: 3,
|
|
76
|
+
SAR: 2,
|
|
77
|
+
USD: 2,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function checkGeneralProgram(
|
|
81
|
+
program: Program,
|
|
82
|
+
options: GeneralCheckOptions = {},
|
|
83
|
+
): GeneralCheckResult {
|
|
84
|
+
const diagnostics: GeneralDiagnostic[] = [];
|
|
85
|
+
const report = (
|
|
86
|
+
code: string,
|
|
87
|
+
span: Span,
|
|
88
|
+
message: string,
|
|
89
|
+
fix: string,
|
|
90
|
+
severity: "error" | "warning" = "error",
|
|
91
|
+
): void => {
|
|
92
|
+
diagnostics.push({ code, fix, message, severity, span });
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const headers = program.decls.filter((decl) => decl.kind === "program");
|
|
96
|
+
const header = headers[0];
|
|
97
|
+
const module = program.decls.find((decl) => decl.kind === "module");
|
|
98
|
+
if (!header && !module) {
|
|
99
|
+
report(
|
|
100
|
+
"HSX1000",
|
|
101
|
+
program.span,
|
|
102
|
+
"this file needs one program header or one module declaration",
|
|
103
|
+
'add `program product_name "Product title"`',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
for (const extra of headers.slice(1)) {
|
|
107
|
+
report(
|
|
108
|
+
"HSX1002",
|
|
109
|
+
extra.span,
|
|
110
|
+
"a file declares exactly one program",
|
|
111
|
+
"delete the extra program declaration",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (header && !SNAKE_CASE.test(header.name.name)) {
|
|
115
|
+
report(
|
|
116
|
+
"HSX1003",
|
|
117
|
+
header.name.span,
|
|
118
|
+
`program ${header.name.name} is not snake_case`,
|
|
119
|
+
"rename the program with lowercase words joined by underscores",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const parties = new Set(
|
|
124
|
+
program.decls
|
|
125
|
+
.filter((decl) => decl.kind === "party")
|
|
126
|
+
.map((decl) => decl.name.name),
|
|
127
|
+
);
|
|
128
|
+
const ports = new Map(
|
|
129
|
+
program.decls
|
|
130
|
+
.filter((decl): decl is PortDecl => decl.kind === "port")
|
|
131
|
+
.map((decl) => [decl.name.name, decl]),
|
|
132
|
+
);
|
|
133
|
+
const assetSubjects: TypedSubject[] = program.decls
|
|
134
|
+
.filter((decl) => decl.kind === "asset")
|
|
135
|
+
.map((decl) => ({
|
|
136
|
+
declaredValue: "optional" as const,
|
|
137
|
+
kind: decl.name.name,
|
|
138
|
+
origin: decl.span,
|
|
139
|
+
schema: { additionalProperties: false, properties: {}, type: "object" },
|
|
140
|
+
title: sentenceCase(decl.name.name),
|
|
141
|
+
version: 1,
|
|
142
|
+
}));
|
|
143
|
+
const subjects: TypedSubject[] = [...assetSubjects];
|
|
144
|
+
const subjectKinds = new Set(assetSubjects.map((subject) => subject.kind));
|
|
145
|
+
for (const decl of program.decls.filter(
|
|
146
|
+
(candidate): candidate is SubjectDecl => candidate.kind === "subject",
|
|
147
|
+
)) {
|
|
148
|
+
if (subjectKinds.has(decl.name.name)) {
|
|
149
|
+
report(
|
|
150
|
+
"HSX1008",
|
|
151
|
+
decl.name.span,
|
|
152
|
+
`subject ${decl.name.name} is declared twice`,
|
|
153
|
+
"keep one subject declaration or rename one kind",
|
|
154
|
+
);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const title = stringSlot(decl.body, "title");
|
|
158
|
+
const declaredValue = stringSlot(
|
|
159
|
+
decl.body,
|
|
160
|
+
"declaredValue",
|
|
161
|
+
"declared_value",
|
|
162
|
+
);
|
|
163
|
+
const versionRow = entry(decl.body, "version");
|
|
164
|
+
const version =
|
|
165
|
+
versionRow?.value.kind === "number" && !versionRow.value.raw.includes(".")
|
|
166
|
+
? Number(versionRow.value.raw)
|
|
167
|
+
: Number.NaN;
|
|
168
|
+
const schemaRow = entry(decl.body, "schema");
|
|
169
|
+
const schema =
|
|
170
|
+
schemaRow?.value.kind === "block"
|
|
171
|
+
? blockToObject(schemaRow.value, new Map(), diagnostics)
|
|
172
|
+
: undefined;
|
|
173
|
+
if (
|
|
174
|
+
!title ||
|
|
175
|
+
!["none", "optional", "required"].includes(declaredValue ?? "") ||
|
|
176
|
+
!Number.isSafeInteger(version) ||
|
|
177
|
+
version < 1 ||
|
|
178
|
+
!schema
|
|
179
|
+
) {
|
|
180
|
+
report(
|
|
181
|
+
"HSX1008",
|
|
182
|
+
decl.span,
|
|
183
|
+
`subject ${decl.name.name} needs title, positive integer version, declared_value, and schema`,
|
|
184
|
+
"declare the complete UDL subject-kind document",
|
|
185
|
+
);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
subjectKinds.add(decl.name.name);
|
|
189
|
+
subjects.push({
|
|
190
|
+
declaredValue: declaredValue as TypedSubject["declaredValue"],
|
|
191
|
+
kind: decl.name.name,
|
|
192
|
+
origin: decl.span,
|
|
193
|
+
schema,
|
|
194
|
+
title,
|
|
195
|
+
version,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const compositionDeclarations = program.decls.filter(
|
|
200
|
+
(declaration) =>
|
|
201
|
+
declaration.kind === "use" || declaration.kind === "expose",
|
|
202
|
+
);
|
|
203
|
+
const authoredInstrumentDeclarations = program.decls.filter(
|
|
204
|
+
(declaration) =>
|
|
205
|
+
declaration.kind === "instrument" ||
|
|
206
|
+
declaration.kind === "instrument_apply",
|
|
207
|
+
);
|
|
208
|
+
if (
|
|
209
|
+
options.publishedCatalog &&
|
|
210
|
+
(compositionDeclarations.length > 0 ||
|
|
211
|
+
authoredInstrumentDeclarations.length === 0)
|
|
212
|
+
) {
|
|
213
|
+
return checkPublishedProgram(
|
|
214
|
+
program,
|
|
215
|
+
options.publishedCatalog,
|
|
216
|
+
header,
|
|
217
|
+
subjects,
|
|
218
|
+
diagnostics,
|
|
219
|
+
report,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
for (const declaration of compositionDeclarations) {
|
|
223
|
+
report(
|
|
224
|
+
"HSX1018",
|
|
225
|
+
declaration.span,
|
|
226
|
+
"this compiler host did not supply a published instrument catalog",
|
|
227
|
+
"pass the canonical UDL catalog as the publishedCatalog compile option",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const aliases = new Map(
|
|
232
|
+
program.decls
|
|
233
|
+
.filter((decl) => decl.kind === "type")
|
|
234
|
+
.map((decl) => [decl.name.name, decl.value]),
|
|
235
|
+
);
|
|
236
|
+
checkConstants(program.decls, aliases, diagnostics);
|
|
237
|
+
|
|
238
|
+
const templates = new Map<string, InstrumentDecl>();
|
|
239
|
+
for (const decl of program.decls) {
|
|
240
|
+
if (decl.kind !== "instrument") continue;
|
|
241
|
+
const prior = templates.get(decl.name.name);
|
|
242
|
+
if (prior) {
|
|
243
|
+
report(
|
|
244
|
+
"HSX1004",
|
|
245
|
+
decl.name.span,
|
|
246
|
+
`instrument ${decl.name.name} is declared twice`,
|
|
247
|
+
"keep one declaration or rename one instrument",
|
|
248
|
+
);
|
|
249
|
+
} else templates.set(decl.name.name, decl);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const concrete: ConcreteInstrument[] = [];
|
|
253
|
+
const scopedDeclarations = (
|
|
254
|
+
declarationScope: readonly ApplicationScopeDecl[],
|
|
255
|
+
) => {
|
|
256
|
+
const scopedAliases = new Map(aliases);
|
|
257
|
+
const scopedConstants = resolveConstants([
|
|
258
|
+
...program.decls,
|
|
259
|
+
...declarationScope,
|
|
260
|
+
]);
|
|
261
|
+
const scopedParties = new Set(parties);
|
|
262
|
+
const scopedPorts = new Map(ports);
|
|
263
|
+
const scopedTemplates = new Map(templates);
|
|
264
|
+
for (const local of declarationScope) {
|
|
265
|
+
if (local.kind === "type")
|
|
266
|
+
scopedAliases.set(local.name.name, local.value);
|
|
267
|
+
if (local.kind === "party") scopedParties.add(local.name.name);
|
|
268
|
+
if (local.kind === "port") scopedPorts.set(local.name.name, local);
|
|
269
|
+
if (local.kind === "instrument") {
|
|
270
|
+
scopedTemplates.set(local.name.name, local);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
checkConstants(declarationScope, scopedAliases, diagnostics);
|
|
274
|
+
return {
|
|
275
|
+
aliases: scopedAliases,
|
|
276
|
+
constants: scopedConstants,
|
|
277
|
+
parties: scopedParties,
|
|
278
|
+
ports: scopedPorts,
|
|
279
|
+
templates: scopedTemplates,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
for (const decl of program.decls) {
|
|
283
|
+
if (
|
|
284
|
+
decl.kind === "instrument" &&
|
|
285
|
+
!decl.hasParameterList &&
|
|
286
|
+
decl.typeParameters.length === 0
|
|
287
|
+
) {
|
|
288
|
+
const scoped = scopedDeclarations(decl.declarationScope ?? []);
|
|
289
|
+
concrete.push({
|
|
290
|
+
aliases: scoped.aliases,
|
|
291
|
+
body: substituteExpr(decl.body, scoped.constants) as BlockExpr,
|
|
292
|
+
generatedPrefix: false,
|
|
293
|
+
name: decl.name,
|
|
294
|
+
parties: scoped.parties,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (decl.kind !== "instrument_apply") continue;
|
|
298
|
+
const application = decl.application;
|
|
299
|
+
const callee = application.callee.parts.at(-1)?.name ?? "";
|
|
300
|
+
const globalTemplate = templates.get(callee);
|
|
301
|
+
const declarationScope = [
|
|
302
|
+
...new Set([
|
|
303
|
+
...(globalTemplate?.declarationScope ?? []),
|
|
304
|
+
...(decl.declarationScope ?? []),
|
|
305
|
+
]),
|
|
306
|
+
];
|
|
307
|
+
const scoped = scopedDeclarations(declarationScope);
|
|
308
|
+
const template = scoped.templates.get(callee);
|
|
309
|
+
if (!template) {
|
|
310
|
+
report(
|
|
311
|
+
"HSX1001",
|
|
312
|
+
application.callee.span,
|
|
313
|
+
`instrument function ${callee} is not bound by this module graph`,
|
|
314
|
+
`import ${callee} from its module or declare it before instantiation`,
|
|
315
|
+
);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const body = instantiate(
|
|
319
|
+
template,
|
|
320
|
+
application,
|
|
321
|
+
diagnostics,
|
|
322
|
+
decl.name,
|
|
323
|
+
scoped.ports,
|
|
324
|
+
scoped.parties,
|
|
325
|
+
scoped.aliases,
|
|
326
|
+
scoped.constants,
|
|
327
|
+
);
|
|
328
|
+
if (body) {
|
|
329
|
+
const merged = decl.metadata
|
|
330
|
+
? mergeApplicationMetadata(body, decl.metadata, diagnostics)
|
|
331
|
+
: body;
|
|
332
|
+
concrete.push(
|
|
333
|
+
...constructedInstruments(decl.name, merged, diagnostics).map(
|
|
334
|
+
(candidate) => ({
|
|
335
|
+
...candidate,
|
|
336
|
+
aliases: scoped.aliases,
|
|
337
|
+
parties: scoped.parties,
|
|
338
|
+
}),
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const allocated = allocateGeneratedPrefixes(concrete);
|
|
345
|
+
|
|
346
|
+
const instruments: TypedInstrument[] = [];
|
|
347
|
+
const ids = new Set<string>();
|
|
348
|
+
for (const candidate of allocated) {
|
|
349
|
+
if (ids.has(candidate.name.name)) {
|
|
350
|
+
report(
|
|
351
|
+
"HSX1004",
|
|
352
|
+
candidate.name.span,
|
|
353
|
+
`instrument ${candidate.name.name} is emitted twice`,
|
|
354
|
+
"rename one instrument",
|
|
355
|
+
);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
ids.add(candidate.name.name);
|
|
359
|
+
const expandedBody = expandComprehensions(candidate.body, diagnostics);
|
|
360
|
+
const checked = checkInstrument(
|
|
361
|
+
candidate.name,
|
|
362
|
+
expandedBody,
|
|
363
|
+
candidate.parties,
|
|
364
|
+
candidate.aliases,
|
|
365
|
+
diagnostics,
|
|
366
|
+
);
|
|
367
|
+
if (checked) instruments.push(checked);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
crossInstrumentReferenceDiagnostics(instruments, diagnostics);
|
|
371
|
+
|
|
372
|
+
if (
|
|
373
|
+
header &&
|
|
374
|
+
instruments.length === 0 &&
|
|
375
|
+
!diagnostics.some((diagnostic) => diagnostic.severity === "error")
|
|
376
|
+
) {
|
|
377
|
+
report(
|
|
378
|
+
"HSX1502",
|
|
379
|
+
header.span,
|
|
380
|
+
`program ${header.name.name} emits no instruments`,
|
|
381
|
+
"declare or instantiate at least one instrument",
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
386
|
+
return { diagnostics };
|
|
387
|
+
}
|
|
388
|
+
const name = header?.name.name ?? module?.name.parts.at(-1)?.name ?? "module";
|
|
389
|
+
const typed: TypedProgram = {
|
|
390
|
+
instruments,
|
|
391
|
+
kind: "typed_program",
|
|
392
|
+
name,
|
|
393
|
+
origin: program.span,
|
|
394
|
+
subjects,
|
|
395
|
+
title: header?.title?.value ?? sentenceCase(name),
|
|
396
|
+
};
|
|
397
|
+
return { diagnostics, program: typed };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function checkPublishedProgram(
|
|
401
|
+
program: Program,
|
|
402
|
+
catalog: UdlDocument,
|
|
403
|
+
header: ProgramDecl | undefined,
|
|
404
|
+
authoredSubjects: readonly TypedSubject[],
|
|
405
|
+
diagnostics: GeneralDiagnostic[],
|
|
406
|
+
report: (
|
|
407
|
+
code: string,
|
|
408
|
+
span: Span,
|
|
409
|
+
message: string,
|
|
410
|
+
fix: string,
|
|
411
|
+
severity?: "error" | "warning",
|
|
412
|
+
) => void,
|
|
413
|
+
): GeneralCheckResult {
|
|
414
|
+
if (!header) {
|
|
415
|
+
return { diagnostics };
|
|
416
|
+
}
|
|
417
|
+
if (!header.title || header.title.value.length === 0) {
|
|
418
|
+
report(
|
|
419
|
+
"HSX1015",
|
|
420
|
+
header.span,
|
|
421
|
+
`composed program ${header.name.name} needs a product title`,
|
|
422
|
+
'write the title after the product id, like `program product_name "Product title"`',
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
if (header.name.name.length > 60) {
|
|
426
|
+
report(
|
|
427
|
+
"HSX1015",
|
|
428
|
+
header.name.span,
|
|
429
|
+
"a composed program product id is at most 60 characters",
|
|
430
|
+
"shorten the product id without changing its snake_case spelling",
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if ((header.title?.value.length ?? 0) > 80) {
|
|
434
|
+
report(
|
|
435
|
+
"HSX1015",
|
|
436
|
+
header.title?.span ?? header.span,
|
|
437
|
+
"a composed program title is at most 80 characters",
|
|
438
|
+
"shorten the product title to 80 characters or fewer",
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const authoredInstruments = program.decls.filter(
|
|
443
|
+
(decl) => decl.kind === "instrument" || decl.kind === "instrument_apply",
|
|
444
|
+
);
|
|
445
|
+
for (const declaration of authoredInstruments) {
|
|
446
|
+
report(
|
|
447
|
+
"HSX1022",
|
|
448
|
+
declaration.span,
|
|
449
|
+
"a composed program cannot author an instrument",
|
|
450
|
+
"publish the instrument in the catalog, then select it with use",
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const uses = program.decls.filter(
|
|
455
|
+
(decl): decl is UseDecl => decl.kind === "use",
|
|
456
|
+
);
|
|
457
|
+
if (uses.length === 0) {
|
|
458
|
+
report(
|
|
459
|
+
"HSX1016",
|
|
460
|
+
header.span,
|
|
461
|
+
`composed program ${header.name.name} uses no published instruments`,
|
|
462
|
+
"add at least one `use published_instrument` declaration",
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
const catalogById = new Map(
|
|
466
|
+
catalog.instruments.map((instrument) => [instrument.id, instrument]),
|
|
467
|
+
);
|
|
468
|
+
const useById = new Map<string, UseDecl>();
|
|
469
|
+
for (const use of uses) {
|
|
470
|
+
const id = use.instrument.name;
|
|
471
|
+
if (!SNAKE_CASE.test(id)) {
|
|
472
|
+
report(
|
|
473
|
+
"HSX1018",
|
|
474
|
+
use.instrument.span,
|
|
475
|
+
`published instrument id ${id} is not snake_case`,
|
|
476
|
+
"write the published catalog id exactly",
|
|
477
|
+
);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (useById.has(id)) {
|
|
481
|
+
report(
|
|
482
|
+
"HSX1017",
|
|
483
|
+
use.instrument.span,
|
|
484
|
+
`published instrument ${id} is used twice`,
|
|
485
|
+
"delete the duplicate use declaration",
|
|
486
|
+
);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
useById.set(id, use);
|
|
490
|
+
if (!catalogById.has(id)) {
|
|
491
|
+
report(
|
|
492
|
+
"HSX1018",
|
|
493
|
+
use.instrument.span,
|
|
494
|
+
`published instrument ${id} does not exist in this catalog`,
|
|
495
|
+
"choose an instrument id from the supplied published catalog",
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const selected = publishedInstrumentClosure(uses, catalogById);
|
|
501
|
+
const selectedById = new Map(
|
|
502
|
+
selected.map((entry) => [entry.instrument.id, entry]),
|
|
503
|
+
);
|
|
504
|
+
const exposures = program.decls.filter(
|
|
505
|
+
(decl): decl is ExposeDecl => decl.kind === "expose",
|
|
506
|
+
);
|
|
507
|
+
const publicNames = new Map<string, ExposeDecl>();
|
|
508
|
+
const exposedTargets = new Map<string, ExposeDecl>();
|
|
509
|
+
for (const exposure of exposures) {
|
|
510
|
+
const target = `${exposure.instrument.name}.${exposure.action.name}`;
|
|
511
|
+
const explicitlyUsed = useById.has(exposure.instrument.name);
|
|
512
|
+
const instrument = selectedById.get(exposure.instrument.name)?.instrument;
|
|
513
|
+
const action = instrument?.actions[exposure.action.name];
|
|
514
|
+
if (!explicitlyUsed || !instrument || !action) {
|
|
515
|
+
report(
|
|
516
|
+
"HSX1021",
|
|
517
|
+
exposure.span,
|
|
518
|
+
`${target} is not an action on an explicitly used published instrument`,
|
|
519
|
+
"add the matching use declaration and choose one of that instrument's actions",
|
|
520
|
+
);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (!CAMEL_CASE.test(exposure.publicName.name)) {
|
|
524
|
+
report(
|
|
525
|
+
"HSX1020",
|
|
526
|
+
exposure.publicName.span,
|
|
527
|
+
`public action name ${exposure.publicName.name} is not camelCase`,
|
|
528
|
+
"write a lowercase camelCase public action name",
|
|
529
|
+
);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const priorName = publicNames.get(exposure.publicName.name);
|
|
533
|
+
const priorTarget = exposedTargets.get(target);
|
|
534
|
+
if (priorName || priorTarget) {
|
|
535
|
+
report(
|
|
536
|
+
"HSX1020",
|
|
537
|
+
exposure.span,
|
|
538
|
+
priorTarget
|
|
539
|
+
? `${target} is exposed more than once`
|
|
540
|
+
: `public action name ${exposure.publicName.name} is declared more than once`,
|
|
541
|
+
"keep one public name for one instrument action",
|
|
542
|
+
);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
publicNames.set(exposure.publicName.name, exposure);
|
|
546
|
+
exposedTargets.set(target, exposure);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const referencedSubjectKinds = new Set(
|
|
550
|
+
selected.flatMap(({ instrument }) => instrument.subject?.kinds ?? []),
|
|
551
|
+
);
|
|
552
|
+
const catalogSubjects = catalog.subjects
|
|
553
|
+
.filter((subject) => referencedSubjectKinds.has(subject.kind))
|
|
554
|
+
.map((subject) =>
|
|
555
|
+
typedPublishedSubject(subject, uses[0]?.span ?? header.span),
|
|
556
|
+
);
|
|
557
|
+
const catalogSubjectKinds = new Set(
|
|
558
|
+
catalogSubjects.map((subject) => subject.kind),
|
|
559
|
+
);
|
|
560
|
+
for (const subject of authoredSubjects) {
|
|
561
|
+
if (!catalogSubjectKinds.has(subject.kind)) continue;
|
|
562
|
+
report(
|
|
563
|
+
"HSX1008",
|
|
564
|
+
subject.origin,
|
|
565
|
+
`subject ${subject.kind} is already supplied by the published catalog`,
|
|
566
|
+
"delete the duplicate subject declaration or use a distinct subject kind",
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
571
|
+
return { diagnostics };
|
|
572
|
+
}
|
|
573
|
+
const typed: TypedProgram = {
|
|
574
|
+
instruments: selected.map(({ instrument, origin }) => {
|
|
575
|
+
const projected = projectedPublishedInstrument(instrument, exposures);
|
|
576
|
+
return typedPublishedInstrument(
|
|
577
|
+
projected,
|
|
578
|
+
origin,
|
|
579
|
+
exposures.filter(
|
|
580
|
+
(exposure) => exposure.instrument.name === instrument.id,
|
|
581
|
+
),
|
|
582
|
+
);
|
|
583
|
+
}),
|
|
584
|
+
kind: "typed_program",
|
|
585
|
+
name: header.name.name,
|
|
586
|
+
origin: program.span,
|
|
587
|
+
subjects: [
|
|
588
|
+
...catalogSubjects,
|
|
589
|
+
...authoredSubjects.filter(
|
|
590
|
+
(subject) => !catalogSubjectKinds.has(subject.kind),
|
|
591
|
+
),
|
|
592
|
+
],
|
|
593
|
+
title: header.title?.value ?? sentenceCase(header.name.name),
|
|
594
|
+
};
|
|
595
|
+
return { diagnostics, program: typed };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function projectedPublishedInstrument(
|
|
599
|
+
source: UdlInstrument,
|
|
600
|
+
exposures: readonly ExposeDecl[],
|
|
601
|
+
): UdlInstrument {
|
|
602
|
+
const publicNameByAction = new Map(
|
|
603
|
+
exposures
|
|
604
|
+
.filter((exposure) => exposure.instrument.name === source.id)
|
|
605
|
+
.map((exposure) => [exposure.action.name, exposure.publicName.name]),
|
|
606
|
+
);
|
|
607
|
+
return {
|
|
608
|
+
...structuredClone(source),
|
|
609
|
+
actions: Object.fromEntries(
|
|
610
|
+
source.actionOrder.map((name) => {
|
|
611
|
+
const { publicAction: _publicAction, ...privateAction } =
|
|
612
|
+
source.actions[name]!;
|
|
613
|
+
const publicName = publicNameByAction.get(name);
|
|
614
|
+
return [
|
|
615
|
+
name,
|
|
616
|
+
publicName
|
|
617
|
+
? { ...privateAction, publicAction: publicName }
|
|
618
|
+
: privateAction,
|
|
619
|
+
];
|
|
620
|
+
}),
|
|
621
|
+
),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function publishedInstrumentClosure(
|
|
626
|
+
uses: readonly UseDecl[],
|
|
627
|
+
catalogById: ReadonlyMap<string, UdlInstrument>,
|
|
628
|
+
): readonly { readonly instrument: UdlInstrument; readonly origin: Span }[] {
|
|
629
|
+
const selected: { instrument: UdlInstrument; origin: Span }[] = [];
|
|
630
|
+
const selectedIds = new Set<string>();
|
|
631
|
+
const queue: { id: string; origin: Span }[] = uses.map((use) => ({
|
|
632
|
+
id: use.instrument.name,
|
|
633
|
+
origin: use.span,
|
|
634
|
+
}));
|
|
635
|
+
const idByPrefix = new Map(
|
|
636
|
+
[...catalogById.values()].map((instrument) => [
|
|
637
|
+
instrument.idPrefix,
|
|
638
|
+
instrument.id,
|
|
639
|
+
]),
|
|
640
|
+
);
|
|
641
|
+
while (queue.length > 0) {
|
|
642
|
+
const next = queue.shift();
|
|
643
|
+
if (!next || selectedIds.has(next.id)) continue;
|
|
644
|
+
const instrument = catalogById.get(next.id);
|
|
645
|
+
if (!instrument) continue;
|
|
646
|
+
selectedIds.add(next.id);
|
|
647
|
+
selected.push({ instrument, origin: next.origin });
|
|
648
|
+
for (const dependency of publishedInstrumentDependencies(
|
|
649
|
+
instrument,
|
|
650
|
+
idByPrefix,
|
|
651
|
+
)) {
|
|
652
|
+
if (!selectedIds.has(dependency))
|
|
653
|
+
queue.push({ id: dependency, origin: next.origin });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return selected;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function publishedInstrumentDependencies(
|
|
660
|
+
instrument: UdlInstrument,
|
|
661
|
+
idByPrefix: ReadonlyMap<string, string>,
|
|
662
|
+
): readonly string[] {
|
|
663
|
+
const found = new Set<string>();
|
|
664
|
+
const visit = (value: unknown): void => {
|
|
665
|
+
if (Array.isArray(value)) {
|
|
666
|
+
value.forEach(visit);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (!value || typeof value !== "object") return;
|
|
670
|
+
for (const [key, child] of Object.entries(value)) {
|
|
671
|
+
if (
|
|
672
|
+
(key === "instrumentId" || key === "childInstrumentId") &&
|
|
673
|
+
typeof child === "string"
|
|
674
|
+
) {
|
|
675
|
+
found.add(child);
|
|
676
|
+
}
|
|
677
|
+
visit(child);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
visit(instrument);
|
|
681
|
+
for (const field of Object.values(instrument.fields)) {
|
|
682
|
+
const prefix =
|
|
683
|
+
typeof field.pattern === "string"
|
|
684
|
+
? /^\^([a-z]{2,8})_\(sandbox\|live\)_/.exec(field.pattern)?.[1]
|
|
685
|
+
: undefined;
|
|
686
|
+
const dependency = prefix ? idByPrefix.get(prefix) : undefined;
|
|
687
|
+
if (dependency) found.add(dependency);
|
|
688
|
+
}
|
|
689
|
+
found.delete(instrument.id);
|
|
690
|
+
return [...found];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function typedPublishedInstrument(
|
|
694
|
+
source: UdlInstrument,
|
|
695
|
+
origin: Span,
|
|
696
|
+
exposures: readonly ExposeDecl[],
|
|
697
|
+
): TypedInstrument {
|
|
698
|
+
const exposureByAction = new Map(
|
|
699
|
+
exposures.map((exposure) => [exposure.action.name, exposure]),
|
|
700
|
+
);
|
|
701
|
+
const {
|
|
702
|
+
actionOrder,
|
|
703
|
+
actions,
|
|
704
|
+
fields,
|
|
705
|
+
id: _id,
|
|
706
|
+
...slots
|
|
707
|
+
} = structuredClone(source);
|
|
708
|
+
const requiredFields = new Set(source.required);
|
|
709
|
+
return {
|
|
710
|
+
actions: actionOrder.map((name) => {
|
|
711
|
+
const exposure = exposureByAction.get(name);
|
|
712
|
+
const { publicAction: _publicAction, ...privateAction } = actions[name]!;
|
|
713
|
+
return {
|
|
714
|
+
effects: (privateAction.effects ?? {}) as TypedAction["effects"],
|
|
715
|
+
name,
|
|
716
|
+
origin: exposure?.span ?? origin,
|
|
717
|
+
slots: (exposure
|
|
718
|
+
? { ...privateAction, publicAction: exposure.publicName.name }
|
|
719
|
+
: privateAction) as unknown as Readonly<Record<string, JsonValue>>,
|
|
720
|
+
};
|
|
721
|
+
}),
|
|
722
|
+
fields: Object.entries(fields).map(([name, schema]) => ({
|
|
723
|
+
name,
|
|
724
|
+
origin,
|
|
725
|
+
required: requiredFields.has(name),
|
|
726
|
+
schema,
|
|
727
|
+
type: { kind: "unknown" },
|
|
728
|
+
})),
|
|
729
|
+
id: source.id,
|
|
730
|
+
origin,
|
|
731
|
+
slots: {
|
|
732
|
+
...(slots as unknown as Readonly<Record<string, JsonValue>>),
|
|
733
|
+
required: [...source.required],
|
|
734
|
+
},
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function typedPublishedSubject(
|
|
739
|
+
subject: UdlDocument["subjects"][number],
|
|
740
|
+
origin: Span,
|
|
741
|
+
): TypedSubject {
|
|
742
|
+
return {
|
|
743
|
+
declaredValue: subject.declaredValue,
|
|
744
|
+
kind: subject.kind,
|
|
745
|
+
origin,
|
|
746
|
+
schema: structuredClone(subject.schema),
|
|
747
|
+
title: subject.title,
|
|
748
|
+
version: subject.version,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Presentation only. An application may restate how a settlement reads to a
|
|
753
|
+
// human or an agent; it may never restate what the settlement does. Both sets
|
|
754
|
+
// carry agent_description for the same reason they carry description: it is
|
|
755
|
+
// prose about the shape, and the catalog's voice is more concrete than the
|
|
756
|
+
// parameterized template's interpolated one.
|
|
757
|
+
const applicationMetadataKeys = new Set([
|
|
758
|
+
"action",
|
|
759
|
+
"agent_description",
|
|
760
|
+
"description",
|
|
761
|
+
"nav",
|
|
762
|
+
"navigation",
|
|
763
|
+
"summary",
|
|
764
|
+
"surface_visibility",
|
|
765
|
+
"template_id",
|
|
766
|
+
"title",
|
|
767
|
+
"visibility",
|
|
768
|
+
]);
|
|
769
|
+
const applicationActionMetadataKeys = new Set([
|
|
770
|
+
"agent_description",
|
|
771
|
+
"description",
|
|
772
|
+
"public",
|
|
773
|
+
"public_action",
|
|
774
|
+
"summary",
|
|
775
|
+
]);
|
|
776
|
+
|
|
777
|
+
function applicationInstrumentMetadataKey(key: string): string {
|
|
778
|
+
if (key === "navigation") return "nav";
|
|
779
|
+
if (key === "visibility") return "surface_visibility";
|
|
780
|
+
return key;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function applicationActionMetadataKey(key: string): string {
|
|
784
|
+
return key === "public_action" ? "public" : key;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function mergeApplicationMetadata(
|
|
788
|
+
body: BlockExpr,
|
|
789
|
+
metadata: BlockExpr,
|
|
790
|
+
diagnostics: GeneralDiagnostic[],
|
|
791
|
+
): BlockExpr {
|
|
792
|
+
let entries = [...body.entries];
|
|
793
|
+
for (const row of metadata.entries) {
|
|
794
|
+
if (!applicationMetadataKeys.has(row.key.name)) {
|
|
795
|
+
diagnostics.push({
|
|
796
|
+
code: "HSX1507",
|
|
797
|
+
fix: "move contract mechanics into the parameterized instrument",
|
|
798
|
+
message: `application metadata cannot replace ${row.key.name}`,
|
|
799
|
+
severity: "error",
|
|
800
|
+
span: row.key.span,
|
|
801
|
+
});
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (row.key.name !== "action") {
|
|
805
|
+
const overlayKey = applicationInstrumentMetadataKey(row.key.name);
|
|
806
|
+
const normalizedRow =
|
|
807
|
+
overlayKey === row.key.name
|
|
808
|
+
? row
|
|
809
|
+
: { ...row, key: { ...row.key, name: overlayKey } };
|
|
810
|
+
entries = [
|
|
811
|
+
...entries.filter(
|
|
812
|
+
(candidate) =>
|
|
813
|
+
applicationInstrumentMetadataKey(candidate.key.name) !== overlayKey,
|
|
814
|
+
),
|
|
815
|
+
normalizedRow,
|
|
816
|
+
];
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
const actionName = row.qualifiers[0]?.name;
|
|
820
|
+
const targetIndex = entries.findIndex(
|
|
821
|
+
(candidate) =>
|
|
822
|
+
candidate.key.name === "action" &&
|
|
823
|
+
candidate.qualifiers[0]?.name === actionName,
|
|
824
|
+
);
|
|
825
|
+
const target = entries[targetIndex];
|
|
826
|
+
if (
|
|
827
|
+
!actionName ||
|
|
828
|
+
row.value.kind !== "block" ||
|
|
829
|
+
!target ||
|
|
830
|
+
target.value.kind !== "block"
|
|
831
|
+
) {
|
|
832
|
+
diagnostics.push({
|
|
833
|
+
code: "HSX1507",
|
|
834
|
+
fix: "name an action declared by the parameterized instrument",
|
|
835
|
+
message: `application metadata action ${actionName ?? "<missing>"} does not exist`,
|
|
836
|
+
severity: "error",
|
|
837
|
+
span: row.span,
|
|
838
|
+
});
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
const invalid = row.value.entries.find(
|
|
842
|
+
(candidate) => !applicationActionMetadataKeys.has(candidate.key.name),
|
|
843
|
+
);
|
|
844
|
+
if (invalid) {
|
|
845
|
+
diagnostics.push({
|
|
846
|
+
code: "HSX1507",
|
|
847
|
+
fix: "keep only summary, description, agent_description, public, or public_action here",
|
|
848
|
+
message: `application metadata cannot replace action clause ${invalid.key.name}`,
|
|
849
|
+
severity: "error",
|
|
850
|
+
span: invalid.span,
|
|
851
|
+
});
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
const overlayKeys = new Set(
|
|
855
|
+
row.value.entries.map((entry) =>
|
|
856
|
+
applicationActionMetadataKey(entry.key.name),
|
|
857
|
+
),
|
|
858
|
+
);
|
|
859
|
+
entries[targetIndex] = {
|
|
860
|
+
...target,
|
|
861
|
+
value: {
|
|
862
|
+
...target.value,
|
|
863
|
+
entries: [
|
|
864
|
+
...target.value.entries.filter(
|
|
865
|
+
(candidate) =>
|
|
866
|
+
!overlayKeys.has(
|
|
867
|
+
applicationActionMetadataKey(candidate.key.name),
|
|
868
|
+
),
|
|
869
|
+
),
|
|
870
|
+
...row.value.entries,
|
|
871
|
+
],
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
return { ...body, entries };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function constructedInstruments(
|
|
879
|
+
rootName: IdentExpr,
|
|
880
|
+
body: BlockExpr,
|
|
881
|
+
diagnostics: GeneralDiagnostic[],
|
|
882
|
+
): {
|
|
883
|
+
readonly body: BlockExpr;
|
|
884
|
+
readonly generatedPrefix: boolean;
|
|
885
|
+
readonly name: IdentExpr;
|
|
886
|
+
}[] {
|
|
887
|
+
const expanded = expandComprehensions(body, diagnostics);
|
|
888
|
+
const construction = entry(expanded, "instruments");
|
|
889
|
+
const root = {
|
|
890
|
+
body: {
|
|
891
|
+
...expanded,
|
|
892
|
+
entries: expanded.entries.filter((row) => row !== construction),
|
|
893
|
+
},
|
|
894
|
+
generatedPrefix: false,
|
|
895
|
+
name: rootName,
|
|
896
|
+
};
|
|
897
|
+
if (construction?.value.kind !== "block") return [root];
|
|
898
|
+
const companions = construction.value.entries.flatMap((row) => {
|
|
899
|
+
if (row.key.name !== "instrument" || row.value.kind !== "block") {
|
|
900
|
+
diagnostics.push({
|
|
901
|
+
code: "HSX1406",
|
|
902
|
+
fix: 'write `instrument { id: concat(instrument, "_suffix"); ... }` inside the instruments block',
|
|
903
|
+
message:
|
|
904
|
+
"an instruments block contains only fixed instrument constructors",
|
|
905
|
+
severity: "error" as const,
|
|
906
|
+
span: row.span,
|
|
907
|
+
});
|
|
908
|
+
return [];
|
|
909
|
+
}
|
|
910
|
+
const idRow = entry(row.value, "id");
|
|
911
|
+
const id = idRow ? nameText(idRow.value) : "";
|
|
912
|
+
if (!id || id === "invalid_compile_time_name") {
|
|
913
|
+
diagnostics.push({
|
|
914
|
+
code: "HSX1406",
|
|
915
|
+
fix: "give the companion instrument a compile-time id",
|
|
916
|
+
message: "a companion instrument id must resolve at compile time",
|
|
917
|
+
severity: "error" as const,
|
|
918
|
+
span: idRow?.span ?? row.span,
|
|
919
|
+
});
|
|
920
|
+
return [];
|
|
921
|
+
}
|
|
922
|
+
const generated = entry(row.value, "generatedPrefix");
|
|
923
|
+
return [
|
|
924
|
+
{
|
|
925
|
+
body: {
|
|
926
|
+
...row.value,
|
|
927
|
+
entries: row.value.entries.filter(
|
|
928
|
+
(child) => child !== idRow && child !== generated,
|
|
929
|
+
),
|
|
930
|
+
},
|
|
931
|
+
generatedPrefix:
|
|
932
|
+
generated?.value.kind === "boolean" && generated.value.value,
|
|
933
|
+
name: {
|
|
934
|
+
kind: "ident" as const,
|
|
935
|
+
name: id,
|
|
936
|
+
span: idRow?.span ?? row.span,
|
|
937
|
+
},
|
|
938
|
+
},
|
|
939
|
+
];
|
|
940
|
+
});
|
|
941
|
+
return [root, ...companions];
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function allocateGeneratedPrefixes(
|
|
945
|
+
candidates: readonly ConcreteInstrument[],
|
|
946
|
+
): ConcreteInstrument[] {
|
|
947
|
+
const used = new Set(
|
|
948
|
+
candidates
|
|
949
|
+
.filter((candidate) => !candidate.generatedPrefix)
|
|
950
|
+
.map(
|
|
951
|
+
(candidate) =>
|
|
952
|
+
stringSlot(candidate.body, "idPrefix", "id_prefix") ??
|
|
953
|
+
prefixFor(candidate.name.name),
|
|
954
|
+
),
|
|
955
|
+
);
|
|
956
|
+
let ordinal = 0;
|
|
957
|
+
const next = (): string => {
|
|
958
|
+
while (true) {
|
|
959
|
+
const high = String.fromCharCode(97 + Math.floor(ordinal / 26));
|
|
960
|
+
const low = String.fromCharCode(97 + (ordinal % 26));
|
|
961
|
+
ordinal += 1;
|
|
962
|
+
const candidate = `zz${high}${low}`;
|
|
963
|
+
if (!used.has(candidate)) {
|
|
964
|
+
used.add(candidate);
|
|
965
|
+
return candidate;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
return candidates.map((candidate) => {
|
|
970
|
+
if (!candidate.generatedPrefix) return candidate;
|
|
971
|
+
const value: Expr = {
|
|
972
|
+
kind: "string",
|
|
973
|
+
span: candidate.name.span,
|
|
974
|
+
value: next(),
|
|
975
|
+
};
|
|
976
|
+
return {
|
|
977
|
+
...candidate,
|
|
978
|
+
body: {
|
|
979
|
+
...candidate.body,
|
|
980
|
+
entries: [
|
|
981
|
+
...candidate.body.entries,
|
|
982
|
+
{
|
|
983
|
+
key: {
|
|
984
|
+
kind: "ident",
|
|
985
|
+
name: "id_prefix",
|
|
986
|
+
span: candidate.name.span,
|
|
987
|
+
},
|
|
988
|
+
qualifiers: [],
|
|
989
|
+
span: candidate.name.span,
|
|
990
|
+
value,
|
|
991
|
+
},
|
|
992
|
+
],
|
|
993
|
+
},
|
|
994
|
+
};
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function inferSettlementTypeArgument(
|
|
999
|
+
template: InstrumentDecl,
|
|
1000
|
+
rows: ReadonlyMap<string, Expr>,
|
|
1001
|
+
typeParameter: string,
|
|
1002
|
+
): Expr | undefined {
|
|
1003
|
+
for (const parameter of template.parameters) {
|
|
1004
|
+
const parameterType = parameter.type;
|
|
1005
|
+
if (
|
|
1006
|
+
parameterType.kind !== "type_apply" ||
|
|
1007
|
+
parameterType.callee.name !== "money" ||
|
|
1008
|
+
parameterType.args[0]?.kind !== "ident" ||
|
|
1009
|
+
parameterType.args[0].name !== typeParameter
|
|
1010
|
+
) {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
const value = rows.get(parameter.name.name);
|
|
1014
|
+
const valueType = value?.kind === "binding" ? value.type : value;
|
|
1015
|
+
if (
|
|
1016
|
+
(valueType?.kind === "call" || valueType?.kind === "type_apply") &&
|
|
1017
|
+
valueType.callee.name === "money"
|
|
1018
|
+
) {
|
|
1019
|
+
return valueType.args[0];
|
|
1020
|
+
}
|
|
1021
|
+
if (valueType?.kind === "money") return valueType.currency;
|
|
1022
|
+
}
|
|
1023
|
+
return undefined;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function checkConstants(
|
|
1027
|
+
decls: readonly Decl[],
|
|
1028
|
+
aliases: ReadonlyMap<string, Expr>,
|
|
1029
|
+
diagnostics: GeneralDiagnostic[],
|
|
1030
|
+
): void {
|
|
1031
|
+
for (const decl of decls) {
|
|
1032
|
+
if (decl.kind !== "const" || !decl.type) continue;
|
|
1033
|
+
const expected = typeOf(decl.type, aliases);
|
|
1034
|
+
const actual = typeOf(decl.value, aliases);
|
|
1035
|
+
if (
|
|
1036
|
+
expected.kind === "money" &&
|
|
1037
|
+
actual.kind === "money" &&
|
|
1038
|
+
expected.currency !== actual.currency
|
|
1039
|
+
) {
|
|
1040
|
+
diagnostics.push({
|
|
1041
|
+
code: "HSX1101",
|
|
1042
|
+
fix: `write a ${expected.currency} literal or change ${decl.name.name}'s declared type`,
|
|
1043
|
+
message: `${decl.name.name} is money<${expected.currency}> but receives money<${actual.currency}>; currencies never coerce`,
|
|
1044
|
+
severity: "error",
|
|
1045
|
+
span: decl.value.span,
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
if (decl.value.kind === "money") validateMoneyLiteral(decl, diagnostics);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function resolveConstants(decls: readonly Decl[]): ReadonlyMap<string, Expr> {
|
|
1053
|
+
const constants = new Map(
|
|
1054
|
+
decls
|
|
1055
|
+
.filter((decl): decl is ConstDecl => decl.kind === "const")
|
|
1056
|
+
.map((decl) => [decl.name.name, decl.value]),
|
|
1057
|
+
);
|
|
1058
|
+
for (let pass = 0; pass < constants.size; pass += 1) {
|
|
1059
|
+
for (const [name, value] of constants) {
|
|
1060
|
+
constants.set(name, substituteExpr(value, constants));
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return constants;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function validateMoneyLiteral(
|
|
1067
|
+
decl: ConstDecl,
|
|
1068
|
+
diagnostics: GeneralDiagnostic[],
|
|
1069
|
+
): void {
|
|
1070
|
+
if (decl.value.kind !== "money") return;
|
|
1071
|
+
const precision = minorUnits[decl.value.currency.name] ?? 2;
|
|
1072
|
+
const fraction = decl.value.raw.split(".")[1] ?? "";
|
|
1073
|
+
if (fraction.length <= precision) return;
|
|
1074
|
+
diagnostics.push({
|
|
1075
|
+
code: "HSX1102",
|
|
1076
|
+
fix: `round the literal to ${precision} minor-unit digit${precision === 1 ? "" : "s"}`,
|
|
1077
|
+
message: `${decl.value.currency.name} ${decl.value.raw} has ${fraction.length} decimal places; ${decl.value.currency.name} settles to ${precision}`,
|
|
1078
|
+
severity: "error",
|
|
1079
|
+
span: decl.value.span,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function instantiate(
|
|
1084
|
+
template: InstrumentDecl,
|
|
1085
|
+
application: ApplyExpr,
|
|
1086
|
+
diagnostics: GeneralDiagnostic[],
|
|
1087
|
+
instrumentName: IdentExpr,
|
|
1088
|
+
ports: ReadonlyMap<string, PortDecl>,
|
|
1089
|
+
parties: ReadonlySet<string>,
|
|
1090
|
+
aliases: ReadonlyMap<string, Expr>,
|
|
1091
|
+
constants: ReadonlyMap<string, Expr>,
|
|
1092
|
+
): BlockExpr | undefined {
|
|
1093
|
+
const partyKeyedParameters = partyKeyedBlockParameters(template, aliases);
|
|
1094
|
+
const named = new Map<string, Expr>();
|
|
1095
|
+
const positional: Expr[] = [];
|
|
1096
|
+
const parameterNames = new Set(
|
|
1097
|
+
template.parameters.map((parameter) => parameter.name.name),
|
|
1098
|
+
);
|
|
1099
|
+
for (const arg of application.args) {
|
|
1100
|
+
if (arg.kind !== "binding") {
|
|
1101
|
+
positional.push(arg);
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
if (!parameterNames.has(arg.name.name)) {
|
|
1105
|
+
diagnostics.push({
|
|
1106
|
+
code: "HSX1012",
|
|
1107
|
+
fix: `remove ${arg.name.name} or name one of ${[...parameterNames].join(", ")}`,
|
|
1108
|
+
message: `${template.name.name} has no argument named ${arg.name.name}`,
|
|
1109
|
+
severity: "error",
|
|
1110
|
+
span: arg.name.span,
|
|
1111
|
+
});
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
if (named.has(arg.name.name)) {
|
|
1115
|
+
diagnostics.push({
|
|
1116
|
+
code: "HSX1013",
|
|
1117
|
+
fix: `pass ${arg.name.name} once`,
|
|
1118
|
+
message: `${template.name.name} receives ${arg.name.name} more than once`,
|
|
1119
|
+
severity: "error",
|
|
1120
|
+
span: arg.name.span,
|
|
1121
|
+
});
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
named.set(arg.name.name, arg.type);
|
|
1125
|
+
}
|
|
1126
|
+
const namedParameterCount = template.parameters.filter((parameter) =>
|
|
1127
|
+
named.has(parameter.name.name),
|
|
1128
|
+
).length;
|
|
1129
|
+
const maximumPositional = template.parameters.length - namedParameterCount;
|
|
1130
|
+
if (positional.length > maximumPositional) {
|
|
1131
|
+
diagnostics.push({
|
|
1132
|
+
code: "HSX1012",
|
|
1133
|
+
fix: `pass at most ${template.parameters.length} arguments`,
|
|
1134
|
+
message: `${template.name.name} receives ${application.args.length} arguments but declares ${template.parameters.length}`,
|
|
1135
|
+
severity: "error",
|
|
1136
|
+
span: application.span,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
const typeArgs =
|
|
1140
|
+
application.typeArgs.length > 0
|
|
1141
|
+
? application.typeArgs
|
|
1142
|
+
: template.typeParameters.map(
|
|
1143
|
+
(parameter) =>
|
|
1144
|
+
inferSettlementTypeArgument(template, named, parameter.name.name) ??
|
|
1145
|
+
parameter.name,
|
|
1146
|
+
);
|
|
1147
|
+
if (template.typeParameters.length !== typeArgs.length) {
|
|
1148
|
+
diagnostics.push({
|
|
1149
|
+
code: "HSX1010",
|
|
1150
|
+
fix: `pass ${template.typeParameters.length} type argument${template.typeParameters.length === 1 ? "" : "s"}`,
|
|
1151
|
+
message: `${template.name.name} expects ${template.typeParameters.length} type arguments but receives ${application.typeArgs.length}`,
|
|
1152
|
+
severity: "error",
|
|
1153
|
+
span: application.span,
|
|
1154
|
+
});
|
|
1155
|
+
return undefined;
|
|
1156
|
+
}
|
|
1157
|
+
const values = new Map<string, Expr>(constants);
|
|
1158
|
+
const missingOptional = new Map<
|
|
1159
|
+
string,
|
|
1160
|
+
InstrumentDecl["parameters"][number]
|
|
1161
|
+
>();
|
|
1162
|
+
values.set("instrument", instrumentName);
|
|
1163
|
+
const fieldBindings = new Map<
|
|
1164
|
+
string,
|
|
1165
|
+
{ readonly name: IdentExpr; readonly type: Expr }
|
|
1166
|
+
>();
|
|
1167
|
+
template.typeParameters.forEach((parameter, index) => {
|
|
1168
|
+
const value = typeArgs[index];
|
|
1169
|
+
if (value) values.set(parameter.name.name, value);
|
|
1170
|
+
});
|
|
1171
|
+
for (const [name, value] of named) values.set(name, value);
|
|
1172
|
+
let position = 0;
|
|
1173
|
+
for (const parameter of template.parameters) {
|
|
1174
|
+
const value = named.get(parameter.name.name) ?? positional[position++];
|
|
1175
|
+
if (!value) {
|
|
1176
|
+
if (optionalInner(parameter.type)) {
|
|
1177
|
+
values.set(parameter.name.name, booleanExpr(false, parameter.span));
|
|
1178
|
+
missingOptional.set(parameter.name.name, parameter);
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
diagnostics.push({
|
|
1182
|
+
code: "HSX1011",
|
|
1183
|
+
fix: `pass ${parameter.name.name}: <${typeWords(typeOf(parameter.type, aliases))}>`,
|
|
1184
|
+
message: `${template.name.name} needs argument ${parameter.name.name}`,
|
|
1185
|
+
severity: "error",
|
|
1186
|
+
span: application.span,
|
|
1187
|
+
});
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
const expectedExpr = substituteExpr(
|
|
1191
|
+
optionalInner(parameter.type) ?? parameter.type,
|
|
1192
|
+
values,
|
|
1193
|
+
);
|
|
1194
|
+
const expected = typeOf(expectedExpr, aliases);
|
|
1195
|
+
const actual = typeOf(value, aliases);
|
|
1196
|
+
const nonPartyKeys = partyKeyedParameters.get(parameter.name.name);
|
|
1197
|
+
if (nonPartyKeys && value.kind === "block") {
|
|
1198
|
+
const declaredParties = [...parties].sort();
|
|
1199
|
+
for (const row of value.entries) {
|
|
1200
|
+
if (parties.has(row.key.name) || nonPartyKeys.has(row.key.name))
|
|
1201
|
+
continue;
|
|
1202
|
+
diagnostics.push({
|
|
1203
|
+
code: "HSX1001",
|
|
1204
|
+
fix:
|
|
1205
|
+
declaredParties.length > 0
|
|
1206
|
+
? `replace ${row.key.name} with one of ${declaredParties.join(", ")}`
|
|
1207
|
+
: `declare the party named by ${row.key.name}`,
|
|
1208
|
+
message: `${parameter.name.name} uses party key ${row.key.name}, which is not declared`,
|
|
1209
|
+
severity: "error",
|
|
1210
|
+
span: row.key.span,
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (
|
|
1215
|
+
expected.kind === "money" &&
|
|
1216
|
+
actual.kind === "money" &&
|
|
1217
|
+
expected.currency &&
|
|
1218
|
+
actual.currency &&
|
|
1219
|
+
expected.currency !== actual.currency
|
|
1220
|
+
) {
|
|
1221
|
+
diagnostics.push({
|
|
1222
|
+
code: "HSX1101",
|
|
1223
|
+
fix: `pass money<${expected.currency}> to ${parameter.name.name}`,
|
|
1224
|
+
message: `${parameter.name.name} needs money<${expected.currency}> but receives money<${actual.currency}>`,
|
|
1225
|
+
severity: "error",
|
|
1226
|
+
span: value.span,
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
if (!argumentMatches(expectedExpr, value, parties, ports, aliases)) {
|
|
1230
|
+
diagnostics.push({
|
|
1231
|
+
code: "HSX1104",
|
|
1232
|
+
fix: `pass ${typeWords(expected)} to ${parameter.name.name}`,
|
|
1233
|
+
message: `${parameter.name.name} needs ${typeWords(expected)} but receives ${argumentWords(value, aliases)}`,
|
|
1234
|
+
severity: "error",
|
|
1235
|
+
span: value.span,
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
if (value.kind === "binding") {
|
|
1239
|
+
fieldBindings.set(parameter.name.name, {
|
|
1240
|
+
name: value.name,
|
|
1241
|
+
type: value.type,
|
|
1242
|
+
});
|
|
1243
|
+
values.set(parameter.name.name, value.name);
|
|
1244
|
+
} else if (expected.kind !== "party" && value.kind === "ident") {
|
|
1245
|
+
fieldBindings.set(parameter.name.name, {
|
|
1246
|
+
name: value,
|
|
1247
|
+
type: substituteExpr(
|
|
1248
|
+
optionalInner(parameter.type) ?? parameter.type,
|
|
1249
|
+
values,
|
|
1250
|
+
),
|
|
1251
|
+
});
|
|
1252
|
+
values.set(parameter.name.name, value);
|
|
1253
|
+
} else {
|
|
1254
|
+
values.set(parameter.name.name, value);
|
|
1255
|
+
}
|
|
1256
|
+
if (value.kind === "port_ref") {
|
|
1257
|
+
bindPortCompileValues(
|
|
1258
|
+
values,
|
|
1259
|
+
parameter.name.name,
|
|
1260
|
+
value,
|
|
1261
|
+
ports,
|
|
1262
|
+
diagnostics,
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
const selected = selectCompileTimeRows(template.body, values, diagnostics);
|
|
1267
|
+
const dependencyDiagnosticStart = diagnostics.length;
|
|
1268
|
+
for (const [name, parameter] of missingOptional) {
|
|
1269
|
+
if (!referencesIdentifier(selected, name, values)) continue;
|
|
1270
|
+
const inner = optionalInner(parameter.type);
|
|
1271
|
+
if (!inner) continue;
|
|
1272
|
+
diagnostics.push({
|
|
1273
|
+
code: "HSX1011",
|
|
1274
|
+
fix: `pass ${name}: <${typeWords(typeOf(inner, aliases))}>`,
|
|
1275
|
+
message: `${template.name.name} needs argument ${name} for the selected options`,
|
|
1276
|
+
severity: "error",
|
|
1277
|
+
span: application.span,
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
if (diagnostics.length > dependencyDiagnosticStart) return undefined;
|
|
1281
|
+
const body = substituteExpr(selected, values) as BlockExpr;
|
|
1282
|
+
return {
|
|
1283
|
+
...body,
|
|
1284
|
+
entries: body.entries.map((row) => {
|
|
1285
|
+
if (row.key.name !== "fields" || row.value.kind !== "block") return row;
|
|
1286
|
+
return {
|
|
1287
|
+
...row,
|
|
1288
|
+
value: {
|
|
1289
|
+
...row.value,
|
|
1290
|
+
entries: row.value.entries.map((field) => {
|
|
1291
|
+
const binding = fieldBindings.get(field.key.name);
|
|
1292
|
+
return binding
|
|
1293
|
+
? {
|
|
1294
|
+
...field,
|
|
1295
|
+
key: binding.name,
|
|
1296
|
+
value:
|
|
1297
|
+
field.value.kind === "block"
|
|
1298
|
+
? {
|
|
1299
|
+
...field.value,
|
|
1300
|
+
entries: field.value.entries.map((row) =>
|
|
1301
|
+
row.key.name === "type"
|
|
1302
|
+
? { ...row, value: binding.type }
|
|
1303
|
+
: row,
|
|
1304
|
+
),
|
|
1305
|
+
}
|
|
1306
|
+
: binding.type,
|
|
1307
|
+
}
|
|
1308
|
+
: field;
|
|
1309
|
+
}),
|
|
1310
|
+
},
|
|
1311
|
+
};
|
|
1312
|
+
}),
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function referencesIdentifier(
|
|
1317
|
+
expr: Expr,
|
|
1318
|
+
name: string,
|
|
1319
|
+
values: ReadonlyMap<string, Expr>,
|
|
1320
|
+
): boolean {
|
|
1321
|
+
if (expr.kind === "ident") return expr.name === name;
|
|
1322
|
+
if (expr.kind === "block") {
|
|
1323
|
+
return expr.entries.some(
|
|
1324
|
+
(row) =>
|
|
1325
|
+
row.key.name !== "let" && referencesIdentifier(row.value, name, values),
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
if (expr.kind === "call") {
|
|
1329
|
+
if (expr.callee.name === "if_eq") {
|
|
1330
|
+
const [left, right, whenEqual, whenDifferent] = expr.args;
|
|
1331
|
+
const actualLeft = left ? substituteCompileArgument(left, values) : left;
|
|
1332
|
+
const actualRight = right
|
|
1333
|
+
? substituteCompileArgument(right, values)
|
|
1334
|
+
: right;
|
|
1335
|
+
const selectedBranch =
|
|
1336
|
+
actualLeft &&
|
|
1337
|
+
actualRight &&
|
|
1338
|
+
nameText(actualLeft) === nameText(actualRight)
|
|
1339
|
+
? whenEqual
|
|
1340
|
+
: whenDifferent;
|
|
1341
|
+
return selectedBranch
|
|
1342
|
+
? referencesIdentifier(selectedBranch, name, values)
|
|
1343
|
+
: false;
|
|
1344
|
+
}
|
|
1345
|
+
return expr.args.some((argument) =>
|
|
1346
|
+
referencesIdentifier(argument, name, values),
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
if (expr.kind === "type_apply") {
|
|
1350
|
+
return expr.args.some((argument) =>
|
|
1351
|
+
referencesIdentifier(argument, name, values),
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
if (expr.kind === "apply") {
|
|
1355
|
+
return (
|
|
1356
|
+
expr.args.some((argument) =>
|
|
1357
|
+
referencesIdentifier(argument, name, values),
|
|
1358
|
+
) ||
|
|
1359
|
+
expr.typeArgs.some((argument) =>
|
|
1360
|
+
referencesIdentifier(argument, name, values),
|
|
1361
|
+
)
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
if (expr.kind === "list") {
|
|
1365
|
+
return expr.items.some((item) => referencesIdentifier(item, name, values));
|
|
1366
|
+
}
|
|
1367
|
+
if (expr.kind === "binding")
|
|
1368
|
+
return referencesIdentifier(expr.type, name, values);
|
|
1369
|
+
if (expr.kind === "decided_amount") {
|
|
1370
|
+
return referencesIdentifier(expr.body, name, values);
|
|
1371
|
+
}
|
|
1372
|
+
return false;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function partyKeyedBlockParameters(
|
|
1376
|
+
template: InstrumentDecl,
|
|
1377
|
+
aliases: ReadonlyMap<string, Expr>,
|
|
1378
|
+
): ReadonlyMap<string, ReadonlySet<string>> {
|
|
1379
|
+
const partyParameters = new Set(
|
|
1380
|
+
template.parameters
|
|
1381
|
+
.filter(
|
|
1382
|
+
(parameter) =>
|
|
1383
|
+
typeOf(optionalInner(parameter.type) ?? parameter.type, aliases)
|
|
1384
|
+
.kind === "party",
|
|
1385
|
+
)
|
|
1386
|
+
.map((parameter) => parameter.name.name),
|
|
1387
|
+
);
|
|
1388
|
+
const partyListParameters = new Set(
|
|
1389
|
+
template.parameters
|
|
1390
|
+
.filter((parameter) => {
|
|
1391
|
+
const type = resolveAlias(
|
|
1392
|
+
optionalInner(parameter.type) ?? parameter.type,
|
|
1393
|
+
aliases,
|
|
1394
|
+
);
|
|
1395
|
+
const item = type.kind === "type_apply" ? type.args[0] : undefined;
|
|
1396
|
+
return (
|
|
1397
|
+
type.kind === "type_apply" &&
|
|
1398
|
+
type.callee.name === "list" &&
|
|
1399
|
+
item !== undefined &&
|
|
1400
|
+
typeOf(item, aliases).kind === "party"
|
|
1401
|
+
);
|
|
1402
|
+
})
|
|
1403
|
+
.map((parameter) => parameter.name.name),
|
|
1404
|
+
);
|
|
1405
|
+
const blockParameters = new Set(
|
|
1406
|
+
template.parameters
|
|
1407
|
+
.filter((parameter) => {
|
|
1408
|
+
const type = resolveAlias(
|
|
1409
|
+
optionalInner(parameter.type) ?? parameter.type,
|
|
1410
|
+
aliases,
|
|
1411
|
+
);
|
|
1412
|
+
return type.kind === "ident" && type.name === "block";
|
|
1413
|
+
})
|
|
1414
|
+
.map((parameter) => parameter.name.name),
|
|
1415
|
+
);
|
|
1416
|
+
const keyed = new Set<string>();
|
|
1417
|
+
const nonPartyKeys = new Map<string, Set<string>>();
|
|
1418
|
+
const partyBindings = new Set(partyParameters);
|
|
1419
|
+
const visit = (expr: Expr): void => {
|
|
1420
|
+
if (expr.kind === "call") {
|
|
1421
|
+
const [owner, key] = expr.args;
|
|
1422
|
+
if (
|
|
1423
|
+
expr.callee.name === "get" &&
|
|
1424
|
+
owner?.kind === "ident" &&
|
|
1425
|
+
key?.kind === "ident" &&
|
|
1426
|
+
blockParameters.has(owner.name) &&
|
|
1427
|
+
partyBindings.has(key.name)
|
|
1428
|
+
) {
|
|
1429
|
+
keyed.add(owner.name);
|
|
1430
|
+
}
|
|
1431
|
+
expr.args.forEach(visit);
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
if (expr.kind === "type_apply") {
|
|
1435
|
+
expr.args.forEach(visit);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
if (expr.kind === "apply") {
|
|
1439
|
+
expr.args.forEach(visit);
|
|
1440
|
+
expr.typeArgs.forEach(visit);
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
if (expr.kind === "list") {
|
|
1444
|
+
expr.items.forEach(visit);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (expr.kind === "block") {
|
|
1448
|
+
for (const row of expr.entries) {
|
|
1449
|
+
if (row.key.name === "let") {
|
|
1450
|
+
const binding = row.qualifiers[0];
|
|
1451
|
+
const value = row.value;
|
|
1452
|
+
const aliasesParty =
|
|
1453
|
+
value.kind === "ident" && partyBindings.has(value.name);
|
|
1454
|
+
const aliasesPartyList =
|
|
1455
|
+
value.kind === "ident" && partyListParameters.has(value.name);
|
|
1456
|
+
const derivesBlockKeys =
|
|
1457
|
+
value.kind === "call" &&
|
|
1458
|
+
value.callee.name === "keys_except" &&
|
|
1459
|
+
value.args[0]?.kind === "ident" &&
|
|
1460
|
+
blockParameters.has(value.args[0].name);
|
|
1461
|
+
const indexesPartyList =
|
|
1462
|
+
value.kind === "call" &&
|
|
1463
|
+
value.callee.name === "at" &&
|
|
1464
|
+
value.args[0]?.kind === "ident" &&
|
|
1465
|
+
partyListParameters.has(value.args[0].name);
|
|
1466
|
+
if (binding && (aliasesPartyList || derivesBlockKeys)) {
|
|
1467
|
+
partyListParameters.add(binding.name);
|
|
1468
|
+
}
|
|
1469
|
+
if (derivesBlockKeys) {
|
|
1470
|
+
const owner = value.args[0];
|
|
1471
|
+
if (owner?.kind === "ident") {
|
|
1472
|
+
const omitted = nonPartyKeys.get(owner.name) ?? new Set<string>();
|
|
1473
|
+
for (const argument of value.args.slice(1)) {
|
|
1474
|
+
omitted.add(nameText(argument));
|
|
1475
|
+
}
|
|
1476
|
+
nonPartyKeys.set(owner.name, omitted);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
if (binding && (aliasesParty || indexesPartyList)) {
|
|
1480
|
+
partyBindings.add(binding.name);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
visit(row.value);
|
|
1484
|
+
}
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (expr.kind === "decided_amount") {
|
|
1488
|
+
visit(expr.body);
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (expr.kind === "binding") visit(expr.type);
|
|
1492
|
+
};
|
|
1493
|
+
visit(template.body);
|
|
1494
|
+
return new Map(
|
|
1495
|
+
[...keyed].map((parameter) => [
|
|
1496
|
+
parameter,
|
|
1497
|
+
nonPartyKeys.get(parameter) ?? new Set<string>(),
|
|
1498
|
+
]),
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function bindPortCompileValues(
|
|
1503
|
+
values: Map<string, Expr>,
|
|
1504
|
+
binding: string,
|
|
1505
|
+
value: Extract<Expr, { readonly kind: "port_ref" }>,
|
|
1506
|
+
ports: ReadonlyMap<string, PortDecl>,
|
|
1507
|
+
diagnostics: GeneralDiagnostic[],
|
|
1508
|
+
): void {
|
|
1509
|
+
const port = ports.get(value.name.name);
|
|
1510
|
+
if (!port) {
|
|
1511
|
+
diagnostics.push({
|
|
1512
|
+
code: "HSX1008",
|
|
1513
|
+
fix: `declare port ${value.name.name} { allowed: [...]; } or correct the port name`,
|
|
1514
|
+
message: `decision port ${value.name.name} is not declared`,
|
|
1515
|
+
severity: "error",
|
|
1516
|
+
span: value.name.span,
|
|
1517
|
+
});
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const allowed = port ? entry(port.body, "allowed")?.value : undefined;
|
|
1521
|
+
const shape = entry(port.body, "shape")?.value ?? {
|
|
1522
|
+
entries: [],
|
|
1523
|
+
kind: "block",
|
|
1524
|
+
span: port.body.span,
|
|
1525
|
+
};
|
|
1526
|
+
if (allowed) values.set(`${binding}_allowed`, allowed);
|
|
1527
|
+
values.set(`${binding}_fields`, shape);
|
|
1528
|
+
if (value.within) values.set(`${binding}_within`, value.within);
|
|
1529
|
+
if (value.deadline) values.set(`${binding}_deadline`, value.deadline);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function substituteExpr(expr: Expr, values: ReadonlyMap<string, Expr>): Expr {
|
|
1533
|
+
if (expr.kind === "ident") {
|
|
1534
|
+
return values.get(expr.name) ?? substituteName(expr, values);
|
|
1535
|
+
}
|
|
1536
|
+
if (expr.kind === "string") {
|
|
1537
|
+
return {
|
|
1538
|
+
...expr,
|
|
1539
|
+
value: expr.value.replaceAll(
|
|
1540
|
+
/\{([A-Za-z][A-Za-z0-9_]*)\}/g,
|
|
1541
|
+
(_, name) => {
|
|
1542
|
+
const value = values.get(String(name));
|
|
1543
|
+
if (value?.kind === "ident") return value.name;
|
|
1544
|
+
if (value?.kind === "number") return value.raw;
|
|
1545
|
+
if (value?.kind === "string") return value.value;
|
|
1546
|
+
if (value?.kind === "percent") return `${value.raw}%`;
|
|
1547
|
+
if (value?.kind === "binding") return value.name.name;
|
|
1548
|
+
if (value?.kind === "port_ref") return value.name.name;
|
|
1549
|
+
return `{${String(name)}}`;
|
|
1550
|
+
},
|
|
1551
|
+
),
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
if (expr.kind === "block") {
|
|
1555
|
+
return {
|
|
1556
|
+
...expr,
|
|
1557
|
+
entries: expr.entries.map((entry) => {
|
|
1558
|
+
const nestedValues = entry.iteration
|
|
1559
|
+
? new Map(
|
|
1560
|
+
[...values].filter(
|
|
1561
|
+
([name]) => name !== entry.iteration?.binding.name,
|
|
1562
|
+
),
|
|
1563
|
+
)
|
|
1564
|
+
: values;
|
|
1565
|
+
return {
|
|
1566
|
+
...entry,
|
|
1567
|
+
...(entry.iteration
|
|
1568
|
+
? {
|
|
1569
|
+
iteration: {
|
|
1570
|
+
...entry.iteration,
|
|
1571
|
+
bound: substituteExpr(entry.iteration.bound, values),
|
|
1572
|
+
},
|
|
1573
|
+
}
|
|
1574
|
+
: {}),
|
|
1575
|
+
key: substituteName(entry.key, nestedValues),
|
|
1576
|
+
qualifiers: entry.qualifiers.map((qualifier, index) => {
|
|
1577
|
+
const value = nestedValues.get(qualifier.name);
|
|
1578
|
+
const fixed = fixedQualifier(entry.key.name, qualifier.name, index);
|
|
1579
|
+
return !fixed && value
|
|
1580
|
+
? nameFromValue(qualifier, value)
|
|
1581
|
+
: substituteName(qualifier, nestedValues);
|
|
1582
|
+
}),
|
|
1583
|
+
value: substituteExpr(entry.value, nestedValues),
|
|
1584
|
+
};
|
|
1585
|
+
}),
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
if (expr.kind === "list") {
|
|
1589
|
+
return {
|
|
1590
|
+
...expr,
|
|
1591
|
+
items: expr.items.map((item) => substituteExpr(item, values)),
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
if (expr.kind === "type_apply") {
|
|
1595
|
+
return {
|
|
1596
|
+
...expr,
|
|
1597
|
+
args: expr.args.map((arg) => substituteExpr(arg, values)),
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
if (expr.kind === "call") {
|
|
1601
|
+
const hasUnbound = expr.args.some((arg) =>
|
|
1602
|
+
hasUnboundCompileIdentifier(arg, values),
|
|
1603
|
+
);
|
|
1604
|
+
const substituted = {
|
|
1605
|
+
...expr,
|
|
1606
|
+
args: expr.args.map((arg) => substituteCompileArgument(arg, values)),
|
|
1607
|
+
};
|
|
1608
|
+
const structural = new Set([
|
|
1609
|
+
"at",
|
|
1610
|
+
"get",
|
|
1611
|
+
"keys",
|
|
1612
|
+
"keys_except",
|
|
1613
|
+
"kind",
|
|
1614
|
+
"len",
|
|
1615
|
+
"values",
|
|
1616
|
+
]).has(expr.callee.name);
|
|
1617
|
+
return hasUnbound && !structural
|
|
1618
|
+
? substituted
|
|
1619
|
+
: (evaluateCompileTimeCall(substituted, !hasUnbound) ?? substituted);
|
|
1620
|
+
}
|
|
1621
|
+
if (expr.kind === "apply") {
|
|
1622
|
+
return {
|
|
1623
|
+
...expr,
|
|
1624
|
+
args: expr.args.map((arg) => substituteExpr(arg, values)),
|
|
1625
|
+
typeArgs: expr.typeArgs.map((arg) => substituteExpr(arg, values)),
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
if (expr.kind === "binding") {
|
|
1629
|
+
return { ...expr, type: substituteExpr(expr.type, values) };
|
|
1630
|
+
}
|
|
1631
|
+
if (expr.kind === "decided_amount") {
|
|
1632
|
+
return { ...expr, body: substituteExpr(expr.body, values) as BlockExpr };
|
|
1633
|
+
}
|
|
1634
|
+
return expr;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function substituteCompileArgument(
|
|
1638
|
+
expr: Expr,
|
|
1639
|
+
values: ReadonlyMap<string, Expr>,
|
|
1640
|
+
): Expr {
|
|
1641
|
+
if (expr.kind !== "ident") return substituteExpr(expr, values);
|
|
1642
|
+
const value = values.get(expr.name);
|
|
1643
|
+
if (!value) return expr;
|
|
1644
|
+
if (value.kind === "ident" || value.kind === "port_ref") {
|
|
1645
|
+
return { kind: "string", span: value.span, value: nameText(value) };
|
|
1646
|
+
}
|
|
1647
|
+
return value;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function hasUnboundCompileIdentifier(
|
|
1651
|
+
expr: Expr,
|
|
1652
|
+
values: ReadonlyMap<string, Expr>,
|
|
1653
|
+
): boolean {
|
|
1654
|
+
if (expr.kind === "ident") return !values.has(expr.name);
|
|
1655
|
+
if (expr.kind === "call" || expr.kind === "type_apply") {
|
|
1656
|
+
return expr.args.some((arg) => hasUnboundCompileIdentifier(arg, values));
|
|
1657
|
+
}
|
|
1658
|
+
if (expr.kind === "list") {
|
|
1659
|
+
return expr.items.some((item) => hasUnboundCompileIdentifier(item, values));
|
|
1660
|
+
}
|
|
1661
|
+
return false;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function optionalInner(type: Expr): Expr | undefined {
|
|
1665
|
+
return type.kind === "type_apply" &&
|
|
1666
|
+
type.callee.name === "optional" &&
|
|
1667
|
+
type.args.length === 1
|
|
1668
|
+
? type.args[0]
|
|
1669
|
+
: undefined;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function resolveAlias(
|
|
1673
|
+
expr: Expr,
|
|
1674
|
+
aliases: ReadonlyMap<string, Expr>,
|
|
1675
|
+
seen: ReadonlySet<string> = new Set(),
|
|
1676
|
+
): Expr {
|
|
1677
|
+
if (expr.kind !== "ident") return expr;
|
|
1678
|
+
const alias = aliases.get(expr.name);
|
|
1679
|
+
if (!alias || seen.has(expr.name)) return expr;
|
|
1680
|
+
return resolveAlias(alias, aliases, new Set([...seen, expr.name]));
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
function argumentMatches(
|
|
1684
|
+
expectedExpression: Expr,
|
|
1685
|
+
actualExpression: Expr,
|
|
1686
|
+
parties: ReadonlySet<string>,
|
|
1687
|
+
ports: ReadonlyMap<string, PortDecl>,
|
|
1688
|
+
aliases: ReadonlyMap<string, Expr>,
|
|
1689
|
+
): boolean {
|
|
1690
|
+
const expected = resolveAlias(expectedExpression, aliases);
|
|
1691
|
+
const actual =
|
|
1692
|
+
actualExpression.kind === "binding"
|
|
1693
|
+
? resolveAlias(actualExpression.type, aliases)
|
|
1694
|
+
: actualExpression;
|
|
1695
|
+
if (expected.kind === "type_apply") {
|
|
1696
|
+
if (expected.callee.name === "list") {
|
|
1697
|
+
const itemType = expected.args[0];
|
|
1698
|
+
return Boolean(
|
|
1699
|
+
itemType &&
|
|
1700
|
+
actual.kind === "list" &&
|
|
1701
|
+
actual.items.every((item) =>
|
|
1702
|
+
argumentMatches(itemType, item, parties, ports, aliases),
|
|
1703
|
+
),
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
if (expected.callee.name === "optional") {
|
|
1707
|
+
const inner = expected.args[0];
|
|
1708
|
+
return Boolean(
|
|
1709
|
+
inner && argumentMatches(inner, actual, parties, ports, aliases),
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
const expectedType = typeOf(expected, aliases);
|
|
1714
|
+
const actualType = typeOf(actual, aliases);
|
|
1715
|
+
switch (expectedType.kind) {
|
|
1716
|
+
case "money":
|
|
1717
|
+
return actualType.kind === "money";
|
|
1718
|
+
case "party":
|
|
1719
|
+
return actual.kind === "ident";
|
|
1720
|
+
case "condition":
|
|
1721
|
+
return actual.kind === "port_ref";
|
|
1722
|
+
case "text":
|
|
1723
|
+
return actual.kind === "string" || actual.kind === "ident";
|
|
1724
|
+
case "date":
|
|
1725
|
+
return actualType.kind === "date" || actual.kind === "ident";
|
|
1726
|
+
case "bps":
|
|
1727
|
+
return actualType.kind === "bps" || actual.kind === "percent";
|
|
1728
|
+
case "percent":
|
|
1729
|
+
return actual.kind === "percent";
|
|
1730
|
+
case "integer":
|
|
1731
|
+
case "boolean":
|
|
1732
|
+
case "account":
|
|
1733
|
+
return actualType.kind === expectedType.kind;
|
|
1734
|
+
case "ref":
|
|
1735
|
+
return (
|
|
1736
|
+
actualType.kind === "ref" || actualExpression.kind === "settlement_ref"
|
|
1737
|
+
);
|
|
1738
|
+
case "unknown":
|
|
1739
|
+
if (expected.kind !== "ident") return false;
|
|
1740
|
+
if (expected.name === "unknown") return true;
|
|
1741
|
+
return (
|
|
1742
|
+
expected.name === "block" &&
|
|
1743
|
+
(actual.kind === "block" || actual.kind === "decided_amount")
|
|
1744
|
+
);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function argumentWords(
|
|
1749
|
+
expression: Expr,
|
|
1750
|
+
aliases: ReadonlyMap<string, Expr>,
|
|
1751
|
+
): string {
|
|
1752
|
+
if (expression.kind === "list") return "list";
|
|
1753
|
+
if (expression.kind === "block") return "block";
|
|
1754
|
+
if (expression.kind === "port_ref") return "decision port";
|
|
1755
|
+
return typeWords(typeOf(expression, aliases));
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function booleanExpr(value: boolean, span: Span): Expr {
|
|
1759
|
+
return { kind: "boolean", span, value };
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
function selectCompileTimeRows(
|
|
1763
|
+
block: BlockExpr,
|
|
1764
|
+
values: ReadonlyMap<string, Expr>,
|
|
1765
|
+
diagnostics: GeneralDiagnostic[],
|
|
1766
|
+
): BlockExpr {
|
|
1767
|
+
const entries: Entry[] = [];
|
|
1768
|
+
const locals = new Map(values);
|
|
1769
|
+
for (const row of block.entries) {
|
|
1770
|
+
if (row.key.name === "let") {
|
|
1771
|
+
const binding = row.qualifiers[0];
|
|
1772
|
+
if (binding) locals.set(binding.name, substituteExpr(row.value, locals));
|
|
1773
|
+
entries.push(row);
|
|
1774
|
+
continue;
|
|
1775
|
+
}
|
|
1776
|
+
if (row.key.name === "unsupported") {
|
|
1777
|
+
const value = substituteExpr(row.value, locals);
|
|
1778
|
+
if (value.kind !== "block") {
|
|
1779
|
+
diagnostics.push({
|
|
1780
|
+
code: "HSX1405",
|
|
1781
|
+
fix: "give unsupported a block with code, message, and fix rows",
|
|
1782
|
+
message: "unsupported needs a fixed diagnostic block",
|
|
1783
|
+
severity: "error",
|
|
1784
|
+
span: row.span,
|
|
1785
|
+
});
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
const diagnosticValue = (name: string): string | undefined => {
|
|
1789
|
+
const entry = value.entries.find(
|
|
1790
|
+
(candidate) => candidate.key.name === name,
|
|
1791
|
+
);
|
|
1792
|
+
return entry ? nameText(entry.value) : undefined;
|
|
1793
|
+
};
|
|
1794
|
+
diagnostics.push({
|
|
1795
|
+
code: diagnosticValue("code") ?? "HSX1110",
|
|
1796
|
+
fix:
|
|
1797
|
+
diagnosticValue("fix") ??
|
|
1798
|
+
"choose a supported compile-time parameter combination",
|
|
1799
|
+
message:
|
|
1800
|
+
diagnosticValue("message") ??
|
|
1801
|
+
"this compile-time parameter combination is not supported",
|
|
1802
|
+
severity: "error",
|
|
1803
|
+
span: row.span,
|
|
1804
|
+
});
|
|
1805
|
+
continue;
|
|
1806
|
+
}
|
|
1807
|
+
if (
|
|
1808
|
+
row.key.name !== "when" &&
|
|
1809
|
+
row.key.name !== "when_not" &&
|
|
1810
|
+
row.key.name !== "when_eq"
|
|
1811
|
+
) {
|
|
1812
|
+
entries.push({
|
|
1813
|
+
...row,
|
|
1814
|
+
value:
|
|
1815
|
+
row.value.kind === "block" && !row.iteration
|
|
1816
|
+
? selectCompileTimeRows(row.value, locals, diagnostics)
|
|
1817
|
+
: row.value,
|
|
1818
|
+
});
|
|
1819
|
+
continue;
|
|
1820
|
+
}
|
|
1821
|
+
const subject = row.qualifiers[0];
|
|
1822
|
+
const actual = subject ? locals.get(subject.name) : undefined;
|
|
1823
|
+
let include = false;
|
|
1824
|
+
if (row.key.name === "when_eq") {
|
|
1825
|
+
const expected = row.qualifiers[1];
|
|
1826
|
+
include = Boolean(
|
|
1827
|
+
actual && expected && nameText(actual) === expected.name,
|
|
1828
|
+
);
|
|
1829
|
+
} else {
|
|
1830
|
+
include = compileTimeTruthy(actual);
|
|
1831
|
+
if (row.key.name === "when_not") include = !include;
|
|
1832
|
+
}
|
|
1833
|
+
if (row.value.kind !== "block") {
|
|
1834
|
+
diagnostics.push({
|
|
1835
|
+
code: "HSX1405",
|
|
1836
|
+
fix: "give the compile-time condition a body in braces",
|
|
1837
|
+
message: `${row.key.name} needs a fixed block to select`,
|
|
1838
|
+
severity: "error",
|
|
1839
|
+
span: row.span,
|
|
1840
|
+
});
|
|
1841
|
+
continue;
|
|
1842
|
+
}
|
|
1843
|
+
if (include) {
|
|
1844
|
+
entries.push(
|
|
1845
|
+
...selectCompileTimeRows(row.value, locals, diagnostics).entries,
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
return { ...block, entries };
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function compileTimeTruthy(value: Expr | undefined): boolean {
|
|
1853
|
+
if (!value) return false;
|
|
1854
|
+
if (value.kind === "string" && value.value === NONE_SENTINEL) return false;
|
|
1855
|
+
if (value.kind === "boolean") return value.value;
|
|
1856
|
+
if (value.kind === "number") return Number(value.raw) !== 0;
|
|
1857
|
+
if (value.kind === "list") return value.items.length > 0;
|
|
1858
|
+
if (value.kind === "block") return value.entries.length > 0;
|
|
1859
|
+
return true;
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
function evaluateCompileTimeCall(
|
|
1863
|
+
expr: Extract<Expr, { readonly kind: "call" }>,
|
|
1864
|
+
resolveMissing = true,
|
|
1865
|
+
): Expr | undefined {
|
|
1866
|
+
const [first, second, third, fourth] = expr.args;
|
|
1867
|
+
const number = (value: Expr | undefined): number | undefined => {
|
|
1868
|
+
if (value?.kind === "percent") return value.bps;
|
|
1869
|
+
if (value?.kind !== "number" || value.raw.includes(".")) return undefined;
|
|
1870
|
+
const parsed = Number(value.raw);
|
|
1871
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
1872
|
+
};
|
|
1873
|
+
const numeric = (value: number): Expr => ({
|
|
1874
|
+
kind: "number",
|
|
1875
|
+
raw: String(value),
|
|
1876
|
+
span: expr.span,
|
|
1877
|
+
});
|
|
1878
|
+
const text = (value: string): Expr => ({
|
|
1879
|
+
kind: "string",
|
|
1880
|
+
span: expr.span,
|
|
1881
|
+
value,
|
|
1882
|
+
});
|
|
1883
|
+
const a = number(first);
|
|
1884
|
+
const b = number(second);
|
|
1885
|
+
switch (expr.callee.name) {
|
|
1886
|
+
case "add":
|
|
1887
|
+
return a === undefined || b === undefined ? undefined : numeric(a + b);
|
|
1888
|
+
case "sub":
|
|
1889
|
+
return a === undefined || b === undefined ? undefined : numeric(a - b);
|
|
1890
|
+
case "mul":
|
|
1891
|
+
return a === undefined || b === undefined ? undefined : numeric(a * b);
|
|
1892
|
+
case "div_floor":
|
|
1893
|
+
return a === undefined || b === undefined || b === 0
|
|
1894
|
+
? undefined
|
|
1895
|
+
: numeric(Math.floor(a / b));
|
|
1896
|
+
case "mod":
|
|
1897
|
+
return a === undefined || b === undefined || b === 0
|
|
1898
|
+
? undefined
|
|
1899
|
+
: numeric(a % b);
|
|
1900
|
+
case "min":
|
|
1901
|
+
return a === undefined || b === undefined
|
|
1902
|
+
? undefined
|
|
1903
|
+
: numeric(Math.min(a, b));
|
|
1904
|
+
case "lt":
|
|
1905
|
+
return a === undefined || b === undefined
|
|
1906
|
+
? undefined
|
|
1907
|
+
: numeric(a < b ? 1 : 0);
|
|
1908
|
+
case "len":
|
|
1909
|
+
return first?.kind === "list"
|
|
1910
|
+
? numeric(first.items.length)
|
|
1911
|
+
: first?.kind === "block"
|
|
1912
|
+
? numeric(first.entries.length)
|
|
1913
|
+
: first?.kind === "boolean" && !first.value
|
|
1914
|
+
? numeric(0)
|
|
1915
|
+
: undefined;
|
|
1916
|
+
case "at": {
|
|
1917
|
+
const index = number(second);
|
|
1918
|
+
if (index === undefined) return undefined;
|
|
1919
|
+
if (first?.kind === "list") return first.items[index - 1];
|
|
1920
|
+
if (first?.kind === "block") return first.entries[index - 1]?.value;
|
|
1921
|
+
return undefined;
|
|
1922
|
+
}
|
|
1923
|
+
case "get": {
|
|
1924
|
+
const key = second ? nameText(second) : "";
|
|
1925
|
+
if (!key || key === "invalid_compile_time_name") return undefined;
|
|
1926
|
+
const body = first?.kind === "decided_amount" ? first.body : first;
|
|
1927
|
+
if (body?.kind !== "block") {
|
|
1928
|
+
return resolveMissing
|
|
1929
|
+
? { kind: "string", span: expr.span, value: NONE_SENTINEL }
|
|
1930
|
+
: undefined;
|
|
1931
|
+
}
|
|
1932
|
+
const value = body.entries.find((row) => row.key.name === key)?.value;
|
|
1933
|
+
if (value || !resolveMissing) return value;
|
|
1934
|
+
return { kind: "string", span: expr.span, value: NONE_SENTINEL };
|
|
1935
|
+
}
|
|
1936
|
+
case "keys":
|
|
1937
|
+
return first?.kind === "block" || first?.kind === "decided_amount"
|
|
1938
|
+
? {
|
|
1939
|
+
items: (first.kind === "decided_amount"
|
|
1940
|
+
? first.body
|
|
1941
|
+
: first
|
|
1942
|
+
).entries.map((row) => ({ ...row.key })),
|
|
1943
|
+
kind: "list",
|
|
1944
|
+
span: expr.span,
|
|
1945
|
+
}
|
|
1946
|
+
: undefined;
|
|
1947
|
+
case "values":
|
|
1948
|
+
return first?.kind === "block"
|
|
1949
|
+
? {
|
|
1950
|
+
items: first.entries.map((row) => row.value),
|
|
1951
|
+
kind: "list",
|
|
1952
|
+
span: expr.span,
|
|
1953
|
+
}
|
|
1954
|
+
: undefined;
|
|
1955
|
+
case "keys_except": {
|
|
1956
|
+
if (first?.kind !== "block") return undefined;
|
|
1957
|
+
const omitted = new Set(expr.args.slice(1).map(nameText));
|
|
1958
|
+
return {
|
|
1959
|
+
items: first.entries
|
|
1960
|
+
.filter((row) => !omitted.has(row.key.name))
|
|
1961
|
+
.map((row) => ({ ...row.key })),
|
|
1962
|
+
kind: "list",
|
|
1963
|
+
span: expr.span,
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
case "kind":
|
|
1967
|
+
return first
|
|
1968
|
+
? text(
|
|
1969
|
+
first.kind === "string" && first.value === NONE_SENTINEL
|
|
1970
|
+
? "none"
|
|
1971
|
+
: first.kind,
|
|
1972
|
+
)
|
|
1973
|
+
: undefined;
|
|
1974
|
+
case "basis_points":
|
|
1975
|
+
return first?.kind === "percent" ? numeric(first.bps) : undefined;
|
|
1976
|
+
case "names": {
|
|
1977
|
+
const count = number(second);
|
|
1978
|
+
if (!first || count === undefined || count < 0 || !third)
|
|
1979
|
+
return undefined;
|
|
1980
|
+
return {
|
|
1981
|
+
items: Array.from({ length: count }, (_, index) =>
|
|
1982
|
+
text(`${nameText(first)}${index + 1}${nameText(third)}`),
|
|
1983
|
+
),
|
|
1984
|
+
kind: "list",
|
|
1985
|
+
span: expr.span,
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
case "suffix_each":
|
|
1989
|
+
return first?.kind === "list" && second
|
|
1990
|
+
? {
|
|
1991
|
+
items: first.items.map((item) =>
|
|
1992
|
+
text(`${camel(nameText(item))}${nameText(second)}`),
|
|
1993
|
+
),
|
|
1994
|
+
kind: "list",
|
|
1995
|
+
span: expr.span,
|
|
1996
|
+
}
|
|
1997
|
+
: undefined;
|
|
1998
|
+
case "concat_lists":
|
|
1999
|
+
return expr.args.every((arg) => arg.kind === "list")
|
|
2000
|
+
? {
|
|
2001
|
+
items: expr.args.flatMap((arg) =>
|
|
2002
|
+
arg.kind === "list" ? arg.items : [],
|
|
2003
|
+
),
|
|
2004
|
+
kind: "list",
|
|
2005
|
+
span: expr.span,
|
|
2006
|
+
}
|
|
2007
|
+
: undefined;
|
|
2008
|
+
case "concat":
|
|
2009
|
+
return text(expr.args.map(nameText).join(""));
|
|
2010
|
+
case "camel":
|
|
2011
|
+
return first ? text(camel(nameText(first))) : undefined;
|
|
2012
|
+
case "title":
|
|
2013
|
+
return first ? text(titleize(nameText(first))) : undefined;
|
|
2014
|
+
case "sentence":
|
|
2015
|
+
return first ? text(sentenceCase(nameText(first))) : undefined;
|
|
2016
|
+
case "percent_text": {
|
|
2017
|
+
const basisPoints = number(first);
|
|
2018
|
+
if (basisPoints === undefined) return undefined;
|
|
2019
|
+
const whole = Math.trunc(basisPoints / 100);
|
|
2020
|
+
const fraction = basisPoints % 100;
|
|
2021
|
+
const decimal =
|
|
2022
|
+
fraction === 0
|
|
2023
|
+
? ""
|
|
2024
|
+
: fraction % 10 === 0
|
|
2025
|
+
? `.${fraction / 10}`
|
|
2026
|
+
: `.${String(fraction).padStart(2, "0")}`;
|
|
2027
|
+
return text(`${whole}${decimal}%`);
|
|
2028
|
+
}
|
|
2029
|
+
case "words":
|
|
2030
|
+
return first ? text(nameText(first).replaceAll("_", " ")) : undefined;
|
|
2031
|
+
case "prefix":
|
|
2032
|
+
return first ? text(prefixFor(nameText(first))) : undefined;
|
|
2033
|
+
case "owner":
|
|
2034
|
+
return first?.kind === "settlement_ref"
|
|
2035
|
+
? text(first.owner.name)
|
|
2036
|
+
: undefined;
|
|
2037
|
+
case "member":
|
|
2038
|
+
return first?.kind === "settlement_ref"
|
|
2039
|
+
? text(first.member.name)
|
|
2040
|
+
: undefined;
|
|
2041
|
+
case "scale_duration": {
|
|
2042
|
+
if (!first || b === undefined) return undefined;
|
|
2043
|
+
const match = /^P([0-9]+)([DWMY])$/.exec(nameText(first));
|
|
2044
|
+
if (!match) return undefined;
|
|
2045
|
+
const amount = Number(match[1]) * b;
|
|
2046
|
+
return match[2] === "W"
|
|
2047
|
+
? text(`P${amount * 7}D`)
|
|
2048
|
+
: text(`P${amount}${match[2]}`);
|
|
2049
|
+
}
|
|
2050
|
+
case "if_eq":
|
|
2051
|
+
return first && second && nameText(first) === nameText(second)
|
|
2052
|
+
? third
|
|
2053
|
+
: fourth;
|
|
2054
|
+
default:
|
|
2055
|
+
return undefined;
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function substituteName(
|
|
2060
|
+
name: IdentExpr,
|
|
2061
|
+
values: ReadonlyMap<string, Expr>,
|
|
2062
|
+
): IdentExpr {
|
|
2063
|
+
return {
|
|
2064
|
+
...name,
|
|
2065
|
+
name: name.name.replaceAll(/\[([A-Za-z][A-Za-z0-9_]*)\]/g, (whole, key) => {
|
|
2066
|
+
const value = values.get(String(key));
|
|
2067
|
+
return value ? nameText(value) : String(whole);
|
|
2068
|
+
}),
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
function fixedQualifier(
|
|
2073
|
+
key: string,
|
|
2074
|
+
qualifier: string,
|
|
2075
|
+
index: number,
|
|
2076
|
+
): boolean {
|
|
2077
|
+
if (["action", "when", "when_not", "when_eq"].includes(key)) return true;
|
|
2078
|
+
if (key === "on" && index === 0) return true;
|
|
2079
|
+
const normalizedKey = key.replaceAll("_", " ");
|
|
2080
|
+
return udlClauseVocabulary.some(
|
|
2081
|
+
(definition) =>
|
|
2082
|
+
definition.spelling === `${normalizedKey} ${qualifier}` ||
|
|
2083
|
+
definition.spelling ===
|
|
2084
|
+
`${normalizedKey} ${qualifier.replaceAll("_", " ")}`,
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
function nameFromValue(origin: IdentExpr, value: Expr): IdentExpr {
|
|
2089
|
+
return { ...origin, name: nameText(value) };
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
function nameText(value: Expr): string {
|
|
2093
|
+
if (value.kind === "binding") return value.name.name;
|
|
2094
|
+
if (value.kind === "ident") return value.name;
|
|
2095
|
+
if (value.kind === "number") return value.raw;
|
|
2096
|
+
if (value.kind === "string") return value.value;
|
|
2097
|
+
if (value.kind === "percent") return `${value.raw}%`;
|
|
2098
|
+
if (value.kind === "port_ref") return value.name.name;
|
|
2099
|
+
if (value.kind === "settlement_ref")
|
|
2100
|
+
return `${value.owner.name}.${value.member.name}`;
|
|
2101
|
+
return "invalid_compile_time_name";
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function expandComprehensions(
|
|
2105
|
+
block: BlockExpr,
|
|
2106
|
+
diagnostics: GeneralDiagnostic[],
|
|
2107
|
+
): BlockExpr {
|
|
2108
|
+
const budget = { used: 0 };
|
|
2109
|
+
const expand = (
|
|
2110
|
+
current: BlockExpr,
|
|
2111
|
+
inherited: ReadonlyMap<string, Expr> = new Map(),
|
|
2112
|
+
): BlockExpr => {
|
|
2113
|
+
const entries: Entry[] = [];
|
|
2114
|
+
const locals = new Map(inherited);
|
|
2115
|
+
for (const row of current.entries) {
|
|
2116
|
+
if (row.key.name === "let") {
|
|
2117
|
+
const binding = row.qualifiers[0];
|
|
2118
|
+
if (!binding) {
|
|
2119
|
+
diagnostics.push({
|
|
2120
|
+
code: "HSX1405",
|
|
2121
|
+
fix: "write `let(name): <compile-time value>`",
|
|
2122
|
+
message: "a compile-time binding needs one name",
|
|
2123
|
+
severity: "error",
|
|
2124
|
+
span: row.span,
|
|
2125
|
+
});
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
locals.set(binding.name, substituteExpr(row.value, locals));
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
2131
|
+
if (
|
|
2132
|
+
row.key.name === "when" ||
|
|
2133
|
+
row.key.name === "when_not" ||
|
|
2134
|
+
row.key.name === "when_eq"
|
|
2135
|
+
) {
|
|
2136
|
+
const subject = row.qualifiers[0];
|
|
2137
|
+
const actual = subject ? locals.get(subject.name) : undefined;
|
|
2138
|
+
const expected = row.qualifiers[1];
|
|
2139
|
+
let include =
|
|
2140
|
+
row.key.name === "when_eq"
|
|
2141
|
+
? Boolean(actual && expected && nameText(actual) === expected.name)
|
|
2142
|
+
: compileTimeTruthy(actual);
|
|
2143
|
+
if (row.key.name === "when_not") include = !include;
|
|
2144
|
+
if (include && row.value.kind === "block") {
|
|
2145
|
+
entries.push(...expand(row.value, locals).entries);
|
|
2146
|
+
}
|
|
2147
|
+
continue;
|
|
2148
|
+
}
|
|
2149
|
+
if (!row.iteration) {
|
|
2150
|
+
const substituted: Entry = {
|
|
2151
|
+
...row,
|
|
2152
|
+
key: substituteName(row.key, locals),
|
|
2153
|
+
qualifiers: row.qualifiers.map((qualifier, index) => {
|
|
2154
|
+
const value = locals.get(qualifier.name);
|
|
2155
|
+
const fixed = fixedQualifier(row.key.name, qualifier.name, index);
|
|
2156
|
+
return !fixed && value
|
|
2157
|
+
? nameFromValue(qualifier, value)
|
|
2158
|
+
: substituteName(qualifier, locals);
|
|
2159
|
+
}),
|
|
2160
|
+
value: substituteExpr(row.value, locals),
|
|
2161
|
+
};
|
|
2162
|
+
entries.push({
|
|
2163
|
+
...substituted,
|
|
2164
|
+
value:
|
|
2165
|
+
substituted.value.kind === "block"
|
|
2166
|
+
? expand(substituted.value, locals)
|
|
2167
|
+
: substituted.value,
|
|
2168
|
+
});
|
|
2169
|
+
continue;
|
|
2170
|
+
}
|
|
2171
|
+
if (row.value.kind !== "block") continue;
|
|
2172
|
+
const bound = substituteExpr(row.iteration.bound, locals);
|
|
2173
|
+
const values = finiteIterationValues(bound);
|
|
2174
|
+
if (!values) {
|
|
2175
|
+
diagnostics.push({
|
|
2176
|
+
code: "HSX1403",
|
|
2177
|
+
fix: "pass an integer literal or a literal finite list as the comprehension bound",
|
|
2178
|
+
message: `comprehension bound for ${row.iteration.binding.name} is not known at compile time`,
|
|
2179
|
+
severity: "error",
|
|
2180
|
+
span: bound.span,
|
|
2181
|
+
});
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2184
|
+
if (budget.used + values.length > MAX_COMPREHENSION_EXPANSIONS) {
|
|
2185
|
+
diagnostics.push({
|
|
2186
|
+
code: "HSX1404",
|
|
2187
|
+
fix: `reduce the fixed expansion to at most ${MAX_COMPREHENSION_EXPANSIONS} rows`,
|
|
2188
|
+
message: `bounded comprehensions expand past the ${MAX_COMPREHENSION_EXPANSIONS}-row compiler limit`,
|
|
2189
|
+
severity: "error",
|
|
2190
|
+
span: bound.span,
|
|
2191
|
+
});
|
|
2192
|
+
continue;
|
|
2193
|
+
}
|
|
2194
|
+
budget.used += values.length;
|
|
2195
|
+
for (const value of values) {
|
|
2196
|
+
const substitutions = new Map<string, Expr>([
|
|
2197
|
+
...locals,
|
|
2198
|
+
[row.iteration.binding.name, value],
|
|
2199
|
+
]);
|
|
2200
|
+
const body = substituteExpr(row.value, substitutions);
|
|
2201
|
+
if (body.kind === "block")
|
|
2202
|
+
entries.push(...expand(body, substitutions).entries);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
return { ...current, entries };
|
|
2206
|
+
};
|
|
2207
|
+
return expand(block);
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
function finiteIterationValues(bound: Expr): readonly Expr[] | undefined {
|
|
2211
|
+
if (bound.kind === "call") {
|
|
2212
|
+
const evaluated = evaluateCompileTimeCall(bound);
|
|
2213
|
+
return evaluated ? finiteIterationValues(evaluated) : undefined;
|
|
2214
|
+
}
|
|
2215
|
+
if (bound.kind === "list") return bound.items;
|
|
2216
|
+
if (bound.kind !== "number" || bound.raw.includes(".")) return undefined;
|
|
2217
|
+
const count = Number(bound.raw);
|
|
2218
|
+
if (!Number.isSafeInteger(count) || count < 0) return undefined;
|
|
2219
|
+
return Array.from(
|
|
2220
|
+
{ length: count },
|
|
2221
|
+
(_, index): Expr => ({
|
|
2222
|
+
kind: "number",
|
|
2223
|
+
raw: String(index + 1),
|
|
2224
|
+
span: bound.span,
|
|
2225
|
+
}),
|
|
2226
|
+
);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function checkInstrument(
|
|
2230
|
+
name: IdentExpr,
|
|
2231
|
+
body: BlockExpr,
|
|
2232
|
+
parties: ReadonlySet<string>,
|
|
2233
|
+
aliases: ReadonlyMap<string, Expr>,
|
|
2234
|
+
diagnostics: GeneralDiagnostic[],
|
|
2235
|
+
): TypedInstrument | undefined {
|
|
2236
|
+
if (!SNAKE_CASE.test(name.name)) {
|
|
2237
|
+
diagnostics.push({
|
|
2238
|
+
code: "HSX1003",
|
|
2239
|
+
fix: "rename the instrument with lowercase words joined by underscores",
|
|
2240
|
+
message: `instrument ${name.name} is not snake_case`,
|
|
2241
|
+
severity: "error",
|
|
2242
|
+
span: name.span,
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
const fieldsEntry = entry(body, "fields");
|
|
2246
|
+
const fields =
|
|
2247
|
+
fieldsEntry?.value.kind === "block"
|
|
2248
|
+
? checkFields(fieldsEntry.value, aliases, diagnostics)
|
|
2249
|
+
: [];
|
|
2250
|
+
if (!fieldsEntry) {
|
|
2251
|
+
diagnostics.push({
|
|
2252
|
+
code: "HSX1503",
|
|
2253
|
+
fix: "add a fields { ... } block, even when it is empty",
|
|
2254
|
+
message: `instrument ${name.name} has no fields block`,
|
|
2255
|
+
severity: "error",
|
|
2256
|
+
span: name.span,
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
const fieldNames = new Set(fields.map((field) => field.name));
|
|
2260
|
+
if (
|
|
2261
|
+
fields.some((field) => field.type.kind === "money") &&
|
|
2262
|
+
!fieldNames.has("currency")
|
|
2263
|
+
) {
|
|
2264
|
+
fields.push({
|
|
2265
|
+
name: "currency",
|
|
2266
|
+
origin: fieldsEntry?.span ?? name.span,
|
|
2267
|
+
required: true,
|
|
2268
|
+
schema: {
|
|
2269
|
+
description: "ISO 4217 currency code",
|
|
2270
|
+
maxLength: 3,
|
|
2271
|
+
minLength: 3,
|
|
2272
|
+
pattern: "^[A-Z]{3}$",
|
|
2273
|
+
type: "string",
|
|
2274
|
+
},
|
|
2275
|
+
type: { kind: "text" },
|
|
2276
|
+
});
|
|
2277
|
+
fieldNames.add("currency");
|
|
2278
|
+
}
|
|
2279
|
+
const endpointFields = new Map<string, string>();
|
|
2280
|
+
for (const field of fields) {
|
|
2281
|
+
if (field.type.kind === "account")
|
|
2282
|
+
endpointFields.set(field.name, field.name);
|
|
2283
|
+
}
|
|
2284
|
+
const slots: Record<string, JsonValue> = {};
|
|
2285
|
+
const partiesEntry = entry(body, "parties");
|
|
2286
|
+
if (partiesEntry?.value.kind === "block") {
|
|
2287
|
+
const roleObject = blockToObject(
|
|
2288
|
+
partiesEntry.value,
|
|
2289
|
+
new Map(),
|
|
2290
|
+
diagnostics,
|
|
2291
|
+
);
|
|
2292
|
+
for (const [role, value] of Object.entries(roleObject)) {
|
|
2293
|
+
if (typeof value !== "string") continue;
|
|
2294
|
+
if (fieldNames.has(value)) {
|
|
2295
|
+
endpointFields.set(role, value);
|
|
2296
|
+
endpointFields.set(value, value);
|
|
2297
|
+
continue;
|
|
2298
|
+
}
|
|
2299
|
+
if (parties.has(value)) {
|
|
2300
|
+
const field = `${camel(value)}AccountId`;
|
|
2301
|
+
roleObject[role] = field;
|
|
2302
|
+
endpointFields.set(role, field);
|
|
2303
|
+
endpointFields.set(value, field);
|
|
2304
|
+
if (!fieldNames.has(field)) {
|
|
2305
|
+
fields.push({
|
|
2306
|
+
name: field,
|
|
2307
|
+
origin: partiesEntry.span,
|
|
2308
|
+
required: true,
|
|
2309
|
+
schema: {
|
|
2310
|
+
description: `The ${value.replaceAll("_", " ")} account`,
|
|
2311
|
+
pattern: "^acct_(sandbox|live)_[a-z0-9]{8,64}$",
|
|
2312
|
+
type: "string",
|
|
2313
|
+
"x-hyperscale-reference-filter": {
|
|
2314
|
+
column: "role",
|
|
2315
|
+
values: ["customer_balance"],
|
|
2316
|
+
},
|
|
2317
|
+
},
|
|
2318
|
+
type: { kind: "account" },
|
|
2319
|
+
});
|
|
2320
|
+
fieldNames.add(field);
|
|
2321
|
+
}
|
|
2322
|
+
continue;
|
|
2323
|
+
}
|
|
2324
|
+
diagnostics.push({
|
|
2325
|
+
code: "HSX1001",
|
|
2326
|
+
fix: "bind the role to a declared party or field",
|
|
2327
|
+
message: `party role refers to ${String(value)}, which is not declared`,
|
|
2328
|
+
severity: "error",
|
|
2329
|
+
span:
|
|
2330
|
+
partiesEntry.value.entries.find((entry) => entry.key.name === role)
|
|
2331
|
+
?.value.span ?? partiesEntry.span,
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
slots.parties = roleObject;
|
|
2335
|
+
}
|
|
2336
|
+
const actionEntries = body.entries.filter(
|
|
2337
|
+
(candidate) =>
|
|
2338
|
+
candidate.key.name === "action" && candidate.value.kind === "block",
|
|
2339
|
+
);
|
|
2340
|
+
const actionNames = new Set(
|
|
2341
|
+
actionEntries.map((candidate) => candidate.qualifiers[0]?.name ?? ""),
|
|
2342
|
+
);
|
|
2343
|
+
const holdDestinations = new Map<string, string>();
|
|
2344
|
+
for (const actionEntry of actionEntries) {
|
|
2345
|
+
if (actionEntry.value.kind !== "block") continue;
|
|
2346
|
+
for (const move of friendlyMoveRows(actionEntry.value)) {
|
|
2347
|
+
if (move.operation === "post" && move.from && move.to) {
|
|
2348
|
+
const destination = endpointFields.get(move.to);
|
|
2349
|
+
if (destination) holdDestinations.set(move.from, destination);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
for (const actionEntry of actionEntries) {
|
|
2354
|
+
if (actionEntry.value.kind !== "block") continue;
|
|
2355
|
+
for (const move of friendlyMoveRows(actionEntry.value)) {
|
|
2356
|
+
for (const endpoint of [move.from, move.to]) {
|
|
2357
|
+
if (
|
|
2358
|
+
!endpoint ||
|
|
2359
|
+
endpoint.startsWith("refs.") ||
|
|
2360
|
+
endpointFields.has(endpoint) ||
|
|
2361
|
+
holdDestinations.has(endpoint)
|
|
2362
|
+
) {
|
|
2363
|
+
continue;
|
|
2364
|
+
}
|
|
2365
|
+
const field = `${camel(endpoint)}AccountId`;
|
|
2366
|
+
endpointFields.set(endpoint, field);
|
|
2367
|
+
if (!fieldNames.has(field)) {
|
|
2368
|
+
fields.push({
|
|
2369
|
+
name: field,
|
|
2370
|
+
origin: actionEntry.span,
|
|
2371
|
+
required: true,
|
|
2372
|
+
schema: {
|
|
2373
|
+
pattern: "^acct_(sandbox|live)_[a-z0-9]{8,64}$",
|
|
2374
|
+
type: "string",
|
|
2375
|
+
},
|
|
2376
|
+
type: { kind: "account" },
|
|
2377
|
+
});
|
|
2378
|
+
fieldNames.add(field);
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
const actions = actionEntries.flatMap((candidate) => {
|
|
2384
|
+
const actionName = candidate.qualifiers[0];
|
|
2385
|
+
if (!actionName || candidate.value.kind !== "block") return [];
|
|
2386
|
+
return [
|
|
2387
|
+
checkAction(
|
|
2388
|
+
actionName,
|
|
2389
|
+
candidate.value,
|
|
2390
|
+
fields,
|
|
2391
|
+
name.name,
|
|
2392
|
+
endpointFields,
|
|
2393
|
+
holdDestinations,
|
|
2394
|
+
diagnostics,
|
|
2395
|
+
),
|
|
2396
|
+
];
|
|
2397
|
+
});
|
|
2398
|
+
if (!actionNames.has("create")) {
|
|
2399
|
+
diagnostics.push({
|
|
2400
|
+
code: "HSX1504",
|
|
2401
|
+
fix: 'add action create { summary: "Create ..."; steps: [] }',
|
|
2402
|
+
message: `instrument ${name.name} has no create action`,
|
|
2403
|
+
severity: "error",
|
|
2404
|
+
span: name.span,
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
const lifecycleEntry = entry(body, "lifecycle");
|
|
2409
|
+
const lifecycle =
|
|
2410
|
+
lifecycleEntry?.value.kind === "block"
|
|
2411
|
+
? checkLifecycle(
|
|
2412
|
+
name.name,
|
|
2413
|
+
lifecycleEntry.value,
|
|
2414
|
+
actionNames,
|
|
2415
|
+
diagnostics,
|
|
2416
|
+
)
|
|
2417
|
+
: undefined;
|
|
2418
|
+
if (!lifecycle) {
|
|
2419
|
+
diagnostics.push({
|
|
2420
|
+
code: "HSX1402",
|
|
2421
|
+
fix: "add lifecycle { states ...; initial ...; on ...; }",
|
|
2422
|
+
message: `instrument ${name.name} has no valid lifecycle`,
|
|
2423
|
+
severity: "error",
|
|
2424
|
+
span: name.span,
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
for (const row of body.entries) {
|
|
2429
|
+
if (
|
|
2430
|
+
row.key.name === "fields" ||
|
|
2431
|
+
row.key.name === "lifecycle" ||
|
|
2432
|
+
row.key.name === "parties" ||
|
|
2433
|
+
row.key.name === "action"
|
|
2434
|
+
)
|
|
2435
|
+
continue;
|
|
2436
|
+
if (row.key.name === "required") {
|
|
2437
|
+
const explicit = exprToJsonForUdlSlot(row.value, "required", diagnostics);
|
|
2438
|
+
const checked = checkRequiredFields(
|
|
2439
|
+
explicit,
|
|
2440
|
+
fields,
|
|
2441
|
+
row.span,
|
|
2442
|
+
diagnostics,
|
|
2443
|
+
);
|
|
2444
|
+
if (checked) slots.required = checked;
|
|
2445
|
+
continue;
|
|
2446
|
+
}
|
|
2447
|
+
const spelling = clauseSpelling(row, instrumentClauseBySpelling);
|
|
2448
|
+
const definition = instrumentClauseBySpelling.get(spelling);
|
|
2449
|
+
if (definition?.scope === "instrument") {
|
|
2450
|
+
setClause(
|
|
2451
|
+
slots,
|
|
2452
|
+
definition.target,
|
|
2453
|
+
definition.cardinality,
|
|
2454
|
+
exprToJsonForUdlSlot(row.value, definition.target, diagnostics),
|
|
2455
|
+
definition.spelling,
|
|
2456
|
+
row.span,
|
|
2457
|
+
diagnostics,
|
|
2458
|
+
);
|
|
2459
|
+
continue;
|
|
2460
|
+
}
|
|
2461
|
+
if (!definition && row.key.name !== "required") {
|
|
2462
|
+
diagnostics.push({
|
|
2463
|
+
code: "HSX1501",
|
|
2464
|
+
fix: "use a clause exported by the targeted UDL definition set",
|
|
2465
|
+
message: `instrument clause ${spelling} does not exist in the targeted UDL vocabulary`,
|
|
2466
|
+
severity: "error",
|
|
2467
|
+
span: row.key.span,
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
if (lifecycle) {
|
|
2472
|
+
slots.lifecycle = lifecycle.value;
|
|
2473
|
+
if (Object.keys(lifecycle.parked).length > 0) {
|
|
2474
|
+
slots.callerParkedStates = lifecycle.parked;
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
slots.idPrefix ??=
|
|
2478
|
+
stringSlot(body, "idPrefix", "id_prefix") ?? prefixFor(name.name);
|
|
2479
|
+
slots.summary ??= `${titleize(name.name)} instrument`;
|
|
2480
|
+
slots.title ??= titleize(name.name);
|
|
2481
|
+
linearityDiagnostics(actionEntries, diagnostics);
|
|
2482
|
+
|
|
2483
|
+
return { actions, fields, id: name.name, origin: name.span, slots };
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
function checkRequiredFields(
|
|
2487
|
+
value: JsonValue,
|
|
2488
|
+
fields: readonly TypedField[],
|
|
2489
|
+
span: Span,
|
|
2490
|
+
diagnostics: GeneralDiagnostic[],
|
|
2491
|
+
): readonly string[] | undefined {
|
|
2492
|
+
const report = (message: string, fix: string): void => {
|
|
2493
|
+
diagnostics.push({
|
|
2494
|
+
code: "HSX1023",
|
|
2495
|
+
fix,
|
|
2496
|
+
message,
|
|
2497
|
+
severity: "error",
|
|
2498
|
+
span,
|
|
2499
|
+
});
|
|
2500
|
+
};
|
|
2501
|
+
if (!Array.isArray(value)) {
|
|
2502
|
+
report(
|
|
2503
|
+
"required must be a list of field names",
|
|
2504
|
+
"write required as a list, such as [amount, currency]",
|
|
2505
|
+
);
|
|
2506
|
+
return undefined;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
const fieldByName = new Map(fields.map((field) => [field.name, field]));
|
|
2510
|
+
const seen = new Set<string>();
|
|
2511
|
+
let valid = true;
|
|
2512
|
+
for (const item of value) {
|
|
2513
|
+
if (typeof item !== "string") {
|
|
2514
|
+
valid = false;
|
|
2515
|
+
report(
|
|
2516
|
+
"required contains a value that is not a field name",
|
|
2517
|
+
"keep only declared non-optional field names in required",
|
|
2518
|
+
);
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
if (seen.has(item)) {
|
|
2522
|
+
valid = false;
|
|
2523
|
+
report(
|
|
2524
|
+
`required names ${item} more than once`,
|
|
2525
|
+
`keep one ${item} entry in required`,
|
|
2526
|
+
);
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
seen.add(item);
|
|
2530
|
+
const field = fieldByName.get(item);
|
|
2531
|
+
if (!field) {
|
|
2532
|
+
valid = false;
|
|
2533
|
+
report(
|
|
2534
|
+
`required names unknown field ${item}`,
|
|
2535
|
+
"name a declared non-optional field in required",
|
|
2536
|
+
);
|
|
2537
|
+
continue;
|
|
2538
|
+
}
|
|
2539
|
+
if (!field.required) {
|
|
2540
|
+
valid = false;
|
|
2541
|
+
report(
|
|
2542
|
+
`required names optional field ${item}`,
|
|
2543
|
+
`remove ${item} from required or make the field non-optional`,
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
return valid ? value : undefined;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
function checkFields(
|
|
2551
|
+
block: BlockExpr,
|
|
2552
|
+
aliases: ReadonlyMap<string, Expr>,
|
|
2553
|
+
diagnostics: GeneralDiagnostic[],
|
|
2554
|
+
): TypedField[] {
|
|
2555
|
+
const result: TypedField[] = [];
|
|
2556
|
+
const names = new Set<string>();
|
|
2557
|
+
for (const row of block.entries) {
|
|
2558
|
+
const name = camel(row.key.name);
|
|
2559
|
+
if (!CAMEL_CASE.test(name)) {
|
|
2560
|
+
diagnostics.push({
|
|
2561
|
+
code: "HSX1005",
|
|
2562
|
+
fix: "rename the field in lower camelCase",
|
|
2563
|
+
message: `field ${row.key.name} is not lower camelCase`,
|
|
2564
|
+
severity: "error",
|
|
2565
|
+
span: row.key.span,
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
if (names.has(name)) {
|
|
2569
|
+
diagnostics.push({
|
|
2570
|
+
code: "HSX1004",
|
|
2571
|
+
fix: `keep one ${name} field`,
|
|
2572
|
+
message: `field ${name} is declared twice`,
|
|
2573
|
+
severity: "error",
|
|
2574
|
+
span: row.key.span,
|
|
2575
|
+
});
|
|
2576
|
+
continue;
|
|
2577
|
+
}
|
|
2578
|
+
names.add(name);
|
|
2579
|
+
let typeExpr = row.value;
|
|
2580
|
+
let description: string | undefined;
|
|
2581
|
+
let required = true;
|
|
2582
|
+
let extra: Record<string, JsonValue> = {};
|
|
2583
|
+
if (row.value.kind === "block") {
|
|
2584
|
+
const typeRow = entry(row.value, "type");
|
|
2585
|
+
if (typeRow) typeExpr = typeRow.value;
|
|
2586
|
+
const desc = entry(row.value, "description") ?? entry(row.value, "desc");
|
|
2587
|
+
if (desc?.value.kind === "string") description = desc.value.value;
|
|
2588
|
+
const optional = entry(row.value, "optional");
|
|
2589
|
+
if (optional?.value.kind === "boolean") required = !optional.value.value;
|
|
2590
|
+
extra = blockToObject(
|
|
2591
|
+
{
|
|
2592
|
+
...row.value,
|
|
2593
|
+
entries: row.value.entries.filter(
|
|
2594
|
+
(item) =>
|
|
2595
|
+
!["type", "description", "desc", "optional"].includes(
|
|
2596
|
+
item.key.name,
|
|
2597
|
+
),
|
|
2598
|
+
),
|
|
2599
|
+
},
|
|
2600
|
+
new Map(),
|
|
2601
|
+
diagnostics,
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
const type = typeOf(typeExpr, aliases);
|
|
2605
|
+
const fixedMoneyIssue = invalidFixedMoneyBinding(typeExpr);
|
|
2606
|
+
if (fixedMoneyIssue) {
|
|
2607
|
+
diagnostics.push({
|
|
2608
|
+
code: "HSX1103",
|
|
2609
|
+
fix: `write ${name} as money(SAR, 2500) with a positive safe integer in minor units`,
|
|
2610
|
+
message: `field ${name} ${fixedMoneyIssue}`,
|
|
2611
|
+
severity: "error",
|
|
2612
|
+
span: typeExpr.span,
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
if (
|
|
2616
|
+
(typeExpr.kind === "ident" && typeExpr.name === "ref") ||
|
|
2617
|
+
(type.kind === "ref" && !type.target)
|
|
2618
|
+
) {
|
|
2619
|
+
diagnostics.push({
|
|
2620
|
+
code: "HSX1007",
|
|
2621
|
+
fix: "declare the target instrument as ref<instrument_id>",
|
|
2622
|
+
message: `field ${name} declares ref without an instrument target`,
|
|
2623
|
+
severity: "error",
|
|
2624
|
+
span: typeExpr.span,
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
const structuralType = Object.hasOwn(extra, "items")
|
|
2628
|
+
? "array"
|
|
2629
|
+
: Object.hasOwn(extra, "properties") ||
|
|
2630
|
+
Object.hasOwn(extra, "additionalProperties")
|
|
2631
|
+
? "object"
|
|
2632
|
+
: undefined;
|
|
2633
|
+
const schema = {
|
|
2634
|
+
...schemaFor(type, required),
|
|
2635
|
+
...extra,
|
|
2636
|
+
...(structuralType ? { type: structuralType } : {}),
|
|
2637
|
+
...(description ? { description } : {}),
|
|
2638
|
+
};
|
|
2639
|
+
result.push({ name, origin: row.span, required, schema, type });
|
|
2640
|
+
}
|
|
2641
|
+
return result;
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
function checkLifecycle(
|
|
2645
|
+
instrument: string,
|
|
2646
|
+
block: BlockExpr,
|
|
2647
|
+
actions: ReadonlySet<string>,
|
|
2648
|
+
diagnostics: GeneralDiagnostic[],
|
|
2649
|
+
):
|
|
2650
|
+
| {
|
|
2651
|
+
readonly parked: Record<string, JsonValue>;
|
|
2652
|
+
readonly value: Record<string, JsonValue>;
|
|
2653
|
+
}
|
|
2654
|
+
| undefined {
|
|
2655
|
+
const statesRows = block.entries.filter((row) => row.key.name === "states");
|
|
2656
|
+
const initialRow = entry(block, "initial");
|
|
2657
|
+
const states = statesRows.flatMap((row) =>
|
|
2658
|
+
row.value.kind === "list"
|
|
2659
|
+
? row.value.items.flatMap((item) =>
|
|
2660
|
+
item.kind === "ident"
|
|
2661
|
+
? [item.name]
|
|
2662
|
+
: item.kind === "string"
|
|
2663
|
+
? [item.value]
|
|
2664
|
+
: [],
|
|
2665
|
+
)
|
|
2666
|
+
: [],
|
|
2667
|
+
);
|
|
2668
|
+
const initial =
|
|
2669
|
+
initialRow?.value.kind === "ident"
|
|
2670
|
+
? initialRow.value.name
|
|
2671
|
+
: initialRow?.value.kind === "string"
|
|
2672
|
+
? initialRow.value.value
|
|
2673
|
+
: "";
|
|
2674
|
+
if (states.length === 0 || !initial) return undefined;
|
|
2675
|
+
const stateSet = new Set(states);
|
|
2676
|
+
if (!stateSet.has(initial)) {
|
|
2677
|
+
diagnostics.push({
|
|
2678
|
+
code: "HSX1001",
|
|
2679
|
+
fix: `add ${initial} to the states row or choose a declared initial state`,
|
|
2680
|
+
message: `lifecycle initial state ${initial} is not declared by ${instrument}`,
|
|
2681
|
+
severity: "error",
|
|
2682
|
+
span: initialRow?.span ?? block.span,
|
|
2683
|
+
});
|
|
2684
|
+
}
|
|
2685
|
+
const transitions: Record<string, JsonValue> = {};
|
|
2686
|
+
const parked: Record<string, JsonValue> = {};
|
|
2687
|
+
for (const row of block.entries) {
|
|
2688
|
+
if (row.key.name === "parked") {
|
|
2689
|
+
const state = row.qualifiers[0]?.name;
|
|
2690
|
+
if (state && row.value.kind === "string") parked[state] = row.value.value;
|
|
2691
|
+
continue;
|
|
2692
|
+
}
|
|
2693
|
+
if (row.key.name !== "on") continue;
|
|
2694
|
+
const action = row.qualifiers[0]?.name ?? "";
|
|
2695
|
+
const from = row.qualifiers.slice(1).map((item) => item.name);
|
|
2696
|
+
const to =
|
|
2697
|
+
row.value.kind === "ident"
|
|
2698
|
+
? row.value.name
|
|
2699
|
+
: row.value.kind === "string"
|
|
2700
|
+
? row.value.value
|
|
2701
|
+
: "";
|
|
2702
|
+
if (!actions.has(action)) {
|
|
2703
|
+
diagnostics.push({
|
|
2704
|
+
code: "HSX1001",
|
|
2705
|
+
fix: `declare action ${action} { ... } or remove its transition`,
|
|
2706
|
+
message: `lifecycle transition names action ${action}, which ${instrument} does not declare`,
|
|
2707
|
+
severity: "error",
|
|
2708
|
+
span: row.span,
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
for (const state of [...from, to]) {
|
|
2712
|
+
if (stateSet.has(state)) continue;
|
|
2713
|
+
diagnostics.push({
|
|
2714
|
+
code: "HSX1001",
|
|
2715
|
+
fix: `add ${state} to states or correct the transition`,
|
|
2716
|
+
message: `lifecycle transition ${action} refers to undeclared state ${state}`,
|
|
2717
|
+
severity: "error",
|
|
2718
|
+
span: row.span,
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
transitions[action] = { from, to };
|
|
2722
|
+
}
|
|
2723
|
+
return { parked, value: { initial, states, transitions } };
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
function checkAction(
|
|
2727
|
+
name: IdentExpr,
|
|
2728
|
+
body: BlockExpr,
|
|
2729
|
+
fields: readonly TypedField[],
|
|
2730
|
+
instrumentId: string,
|
|
2731
|
+
endpointFields: ReadonlyMap<string, string>,
|
|
2732
|
+
holdDestinations: ReadonlyMap<string, string>,
|
|
2733
|
+
diagnostics: GeneralDiagnostic[],
|
|
2734
|
+
): TypedAction {
|
|
2735
|
+
const slots: Record<string, JsonValue> = {};
|
|
2736
|
+
let omitsPublicAction = false;
|
|
2737
|
+
const fieldNames = new Set(fields.map((field) => field.name));
|
|
2738
|
+
for (const row of body.entries) {
|
|
2739
|
+
if (["for", "while", "loop", "recurse"].includes(row.key.name)) {
|
|
2740
|
+
diagnostics.push({
|
|
2741
|
+
code: "HSX1401",
|
|
2742
|
+
fix: "use a bounded comprehension over list<T, N> or a declared finite schedule",
|
|
2743
|
+
message: `${row.key.name} is unbounded; every HSX action must terminate at compile time`,
|
|
2744
|
+
severity: "error",
|
|
2745
|
+
span: row.key.span,
|
|
2746
|
+
});
|
|
2747
|
+
continue;
|
|
2748
|
+
}
|
|
2749
|
+
if (
|
|
2750
|
+
row.key.name === "public" &&
|
|
2751
|
+
row.qualifiers.length === 0 &&
|
|
2752
|
+
row.value.kind === "ident" &&
|
|
2753
|
+
row.value.name === "none"
|
|
2754
|
+
) {
|
|
2755
|
+
omitsPublicAction = true;
|
|
2756
|
+
continue;
|
|
2757
|
+
}
|
|
2758
|
+
const spelling = clauseSpelling(row, actionClauseBySpelling);
|
|
2759
|
+
const definition = actionClauseBySpelling.get(spelling);
|
|
2760
|
+
if (definition?.scope === "action" && spelling === "notify") {
|
|
2761
|
+
const role = row.qualifiers[0]?.name;
|
|
2762
|
+
const channel = row.qualifiers[1]?.name;
|
|
2763
|
+
if (role && channel) {
|
|
2764
|
+
const notification = { channel, role };
|
|
2765
|
+
setClause(
|
|
2766
|
+
slots,
|
|
2767
|
+
definition.target,
|
|
2768
|
+
definition.cardinality,
|
|
2769
|
+
notification,
|
|
2770
|
+
definition.spelling,
|
|
2771
|
+
row.span,
|
|
2772
|
+
diagnostics,
|
|
2773
|
+
);
|
|
2774
|
+
}
|
|
2775
|
+
continue;
|
|
2776
|
+
}
|
|
2777
|
+
if (definition?.scope === "action") {
|
|
2778
|
+
const rawValue = exprToJsonForUdlSlot(
|
|
2779
|
+
row.value,
|
|
2780
|
+
definition.target,
|
|
2781
|
+
diagnostics,
|
|
2782
|
+
);
|
|
2783
|
+
const moveOffset = Array.isArray(slots.moves) ? slots.moves.length : 0;
|
|
2784
|
+
const value =
|
|
2785
|
+
definition.target === "moves"
|
|
2786
|
+
? normalizeMoves(
|
|
2787
|
+
rawValue,
|
|
2788
|
+
name.name,
|
|
2789
|
+
instrumentId,
|
|
2790
|
+
fields,
|
|
2791
|
+
endpointFields,
|
|
2792
|
+
holdDestinations,
|
|
2793
|
+
diagnostics,
|
|
2794
|
+
row.span,
|
|
2795
|
+
moveOffset,
|
|
2796
|
+
)
|
|
2797
|
+
: rawValue;
|
|
2798
|
+
setClause(
|
|
2799
|
+
slots,
|
|
2800
|
+
definition.target,
|
|
2801
|
+
definition.cardinality,
|
|
2802
|
+
value,
|
|
2803
|
+
definition.spelling,
|
|
2804
|
+
row.span,
|
|
2805
|
+
diagnostics,
|
|
2806
|
+
);
|
|
2807
|
+
continue;
|
|
2808
|
+
}
|
|
2809
|
+
diagnostics.push({
|
|
2810
|
+
code: "HSX1501",
|
|
2811
|
+
fix: "use a clause exported by the targeted UDL definition set",
|
|
2812
|
+
message: `action clause ${spelling} does not exist in the targeted UDL vocabulary`,
|
|
2813
|
+
severity: "error",
|
|
2814
|
+
span: row.key.span,
|
|
2815
|
+
});
|
|
2816
|
+
}
|
|
2817
|
+
slots.summary ??= `${titleize(name.name)} ${fields.length === 1 ? (fields[0]?.name ?? "instrument") : "instrument"}`;
|
|
2818
|
+
slots.steps ??= [];
|
|
2819
|
+
slots.moves ??= [];
|
|
2820
|
+
if (omitsPublicAction && slots.publicAction !== undefined) {
|
|
2821
|
+
diagnostics.push({
|
|
2822
|
+
code: "HSX1506",
|
|
2823
|
+
fix: "keep either `public: none` or `public action: actionName`",
|
|
2824
|
+
message: `action ${name.name} both omits and names its public action`,
|
|
2825
|
+
severity: "error",
|
|
2826
|
+
span: name.span,
|
|
2827
|
+
});
|
|
2828
|
+
}
|
|
2829
|
+
if (
|
|
2830
|
+
!omitsPublicAction &&
|
|
2831
|
+
slots.publicAction === undefined &&
|
|
2832
|
+
slots.due === undefined &&
|
|
2833
|
+
slots.reconcile === undefined
|
|
2834
|
+
) {
|
|
2835
|
+
slots.publicAction = camel(`${name.name}_${instrumentId}`);
|
|
2836
|
+
}
|
|
2837
|
+
bindFieldReferences(slots, fieldNames, name, diagnostics);
|
|
2838
|
+
const effects = deriveUdlActionEffects(slots, udlClauseVocabulary);
|
|
2839
|
+
if (Object.keys(effects).length > 0) {
|
|
2840
|
+
slots.effects = effects as unknown as JsonValue;
|
|
2841
|
+
} else {
|
|
2842
|
+
delete slots.effects;
|
|
2843
|
+
}
|
|
2844
|
+
return { effects, name: name.name, origin: name.span, slots };
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
function normalizeMoves(
|
|
2848
|
+
value: JsonValue,
|
|
2849
|
+
action: string,
|
|
2850
|
+
instrumentId: string,
|
|
2851
|
+
fields: readonly TypedField[],
|
|
2852
|
+
endpointFields: ReadonlyMap<string, string>,
|
|
2853
|
+
holdDestinations: ReadonlyMap<string, string>,
|
|
2854
|
+
diagnostics: GeneralDiagnostic[],
|
|
2855
|
+
span: Span,
|
|
2856
|
+
offset = 0,
|
|
2857
|
+
): JsonValue {
|
|
2858
|
+
if (!Array.isArray(value)) return value;
|
|
2859
|
+
const fieldByName = new Map(fields.map((field) => [field.name, field]));
|
|
2860
|
+
return value.map((candidate, index) => {
|
|
2861
|
+
if (!isJsonObject(candidate) || candidate.bind !== undefined)
|
|
2862
|
+
return candidate;
|
|
2863
|
+
const amount =
|
|
2864
|
+
typeof candidate.amount === "string" ? candidate.amount : undefined;
|
|
2865
|
+
const from =
|
|
2866
|
+
typeof candidate.from === "string" ? candidate.from : undefined;
|
|
2867
|
+
const to = typeof candidate.to === "string" ? candidate.to : undefined;
|
|
2868
|
+
const operationName =
|
|
2869
|
+
typeof candidate.operation === "string" ? candidate.operation : "create";
|
|
2870
|
+
if (!amount || !from || !to) return candidate;
|
|
2871
|
+
const operation = `internal_transfer.${operationName}`;
|
|
2872
|
+
const key =
|
|
2873
|
+
typeof candidate.key === "string"
|
|
2874
|
+
? candidate.key
|
|
2875
|
+
: `${action}_${offset + index + 1}`;
|
|
2876
|
+
if (operationName === "post" || operationName === "void") {
|
|
2877
|
+
return {
|
|
2878
|
+
bind: {
|
|
2879
|
+
transferId: { from: "instance", path: "refs.reservationId" },
|
|
2880
|
+
},
|
|
2881
|
+
key,
|
|
2882
|
+
operation,
|
|
2883
|
+
};
|
|
2884
|
+
}
|
|
2885
|
+
const sourcePath = from.startsWith("refs.")
|
|
2886
|
+
? from
|
|
2887
|
+
: endpointFields.get(from)
|
|
2888
|
+
? `fields.${endpointFields.get(from)}`
|
|
2889
|
+
: undefined;
|
|
2890
|
+
const destinationEndpoint =
|
|
2891
|
+
operationName === "reserve"
|
|
2892
|
+
? (holdDestinations.get(to) ?? endpointFields.get(to))
|
|
2893
|
+
: endpointFields.get(to);
|
|
2894
|
+
const destinationPath = to.startsWith("refs.")
|
|
2895
|
+
? to
|
|
2896
|
+
: destinationEndpoint
|
|
2897
|
+
? `fields.${destinationEndpoint}`
|
|
2898
|
+
: undefined;
|
|
2899
|
+
if (!sourcePath || !destinationPath) {
|
|
2900
|
+
const missing = !sourcePath ? from : to;
|
|
2901
|
+
diagnostics.push({
|
|
2902
|
+
code: "HSX1001",
|
|
2903
|
+
fix: `bind ${missing} in the instrument parties block`,
|
|
2904
|
+
message: `action ${action} moves money through ${missing}, which has no bound account field`,
|
|
2905
|
+
severity: "error",
|
|
2906
|
+
span,
|
|
2907
|
+
});
|
|
2908
|
+
return candidate;
|
|
2909
|
+
}
|
|
2910
|
+
const amountPath = amount.includes(".") ? amount : `fields.${amount}`;
|
|
2911
|
+
const move: Record<string, JsonValue> = {
|
|
2912
|
+
bind: {
|
|
2913
|
+
amount: { from: "instance", path: amountPath },
|
|
2914
|
+
currency: { from: "instance", path: "fields.currency" },
|
|
2915
|
+
destinationAccountId: {
|
|
2916
|
+
from: "instance",
|
|
2917
|
+
path: destinationPath,
|
|
2918
|
+
},
|
|
2919
|
+
sourceAccountId: {
|
|
2920
|
+
from: "instance",
|
|
2921
|
+
path: sourcePath,
|
|
2922
|
+
},
|
|
2923
|
+
"metadata.instrumentId": { from: "const", value: instrumentId },
|
|
2924
|
+
"metadata.instrumentInstanceId": {
|
|
2925
|
+
from: "instance",
|
|
2926
|
+
path: "instrumentInstanceId",
|
|
2927
|
+
},
|
|
2928
|
+
"metadata.phase": { from: "const", value: action },
|
|
2929
|
+
productId: { from: "instance", path: "productId" },
|
|
2930
|
+
},
|
|
2931
|
+
key,
|
|
2932
|
+
operation,
|
|
2933
|
+
};
|
|
2934
|
+
if (operationName === "reserve") {
|
|
2935
|
+
move.capture = {
|
|
2936
|
+
[camel(`${action}_${key}_transfer_id`)]: "transferId",
|
|
2937
|
+
};
|
|
2938
|
+
} else if (operationName === "create") {
|
|
2939
|
+
move.capture = {
|
|
2940
|
+
[camel(`${action}_${key}_transfer_id`)]: "transferId",
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
if (
|
|
2944
|
+
!amountPath.startsWith("refs.") &&
|
|
2945
|
+
!fieldByName.has(amountPath.replace(/^fields\./, ""))
|
|
2946
|
+
) {
|
|
2947
|
+
diagnostics.push({
|
|
2948
|
+
code: "HSX1001",
|
|
2949
|
+
fix: `declare the money field named by ${amount}`,
|
|
2950
|
+
message: `action ${action} moves ${amount}, which is not a declared money field`,
|
|
2951
|
+
severity: "error",
|
|
2952
|
+
span,
|
|
2953
|
+
});
|
|
2954
|
+
}
|
|
2955
|
+
return move;
|
|
2956
|
+
});
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
function friendlyMoveRows(body: BlockExpr): readonly {
|
|
2960
|
+
readonly from?: string;
|
|
2961
|
+
readonly operation?: string;
|
|
2962
|
+
readonly to?: string;
|
|
2963
|
+
}[] {
|
|
2964
|
+
const moves = entry(body, "moves")?.value;
|
|
2965
|
+
if (moves?.kind !== "list") return [];
|
|
2966
|
+
return moves.items.flatMap((item) => {
|
|
2967
|
+
if (item.kind !== "block") return [];
|
|
2968
|
+
const from = stringSlot(item, "from");
|
|
2969
|
+
const operation = stringSlot(item, "operation");
|
|
2970
|
+
const to = stringSlot(item, "to");
|
|
2971
|
+
return [
|
|
2972
|
+
{
|
|
2973
|
+
...(from ? { from } : {}),
|
|
2974
|
+
...(operation ? { operation } : {}),
|
|
2975
|
+
...(to ? { to } : {}),
|
|
2976
|
+
},
|
|
2977
|
+
];
|
|
2978
|
+
});
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
function isJsonObject(value: JsonValue): value is Record<string, JsonValue> {
|
|
2982
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
function bindFieldReferences(
|
|
2986
|
+
slots: Readonly<Record<string, JsonValue>>,
|
|
2987
|
+
fields: ReadonlySet<string>,
|
|
2988
|
+
action: IdentExpr,
|
|
2989
|
+
diagnostics: GeneralDiagnostic[],
|
|
2990
|
+
): void {
|
|
2991
|
+
const externalFieldSlot = (path: readonly string[]): boolean => {
|
|
2992
|
+
const key = path.at(-1);
|
|
2993
|
+
return (
|
|
2994
|
+
(path.includes("requiresAggregate") && key === "refField") ||
|
|
2995
|
+
(path.includes("requiresAggregate") && key === "amountField") ||
|
|
2996
|
+
(path.includes("signedSum") && key === "refField") ||
|
|
2997
|
+
(path.includes("signedSum") && key === "amountField") ||
|
|
2998
|
+
(path.includes("remainder") && key === "refField") ||
|
|
2999
|
+
(path.includes("remainder") && key === "amountField") ||
|
|
3000
|
+
(path.includes("requiresExposure") && key === "capField") ||
|
|
3001
|
+
(path.includes("reconcile") &&
|
|
3002
|
+
path.includes("exception") &&
|
|
3003
|
+
["amountField", "reasonField", "refField"].includes(key ?? ""))
|
|
3004
|
+
);
|
|
3005
|
+
};
|
|
3006
|
+
const visit = (value: JsonValue, path: readonly string[] = []): void => {
|
|
3007
|
+
if (Array.isArray(value)) {
|
|
3008
|
+
value.forEach((item, index) => visit(item, [...path, String(index)]));
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
if (value && typeof value === "object") {
|
|
3012
|
+
Object.entries(value).forEach(([childKey, child]) =>
|
|
3013
|
+
visit(child, [...path, childKey]),
|
|
3014
|
+
);
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
const key = path.at(-1) ?? "";
|
|
3018
|
+
if (
|
|
3019
|
+
typeof value === "string" &&
|
|
3020
|
+
key.endsWith("Field") &&
|
|
3021
|
+
!externalFieldSlot(path) &&
|
|
3022
|
+
!fields.has(value)
|
|
3023
|
+
) {
|
|
3024
|
+
diagnostics.push({
|
|
3025
|
+
code: "HSX1001",
|
|
3026
|
+
fix: `declare field ${value} or correct ${key}`,
|
|
3027
|
+
message: `action ${action.name} refers to field ${value}, which does not exist`,
|
|
3028
|
+
severity: "error",
|
|
3029
|
+
span: action.span,
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
};
|
|
3033
|
+
visit(slots as unknown as JsonValue);
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
function crossInstrumentReferenceDiagnostics(
|
|
3037
|
+
instruments: readonly TypedInstrument[],
|
|
3038
|
+
diagnostics: GeneralDiagnostic[],
|
|
3039
|
+
): void {
|
|
3040
|
+
const byId = new Map(
|
|
3041
|
+
instruments.map((instrument) => [instrument.id, instrument]),
|
|
3042
|
+
);
|
|
3043
|
+
const report = (
|
|
3044
|
+
owner: TypedInstrument,
|
|
3045
|
+
span: Span,
|
|
3046
|
+
message: string,
|
|
3047
|
+
fix: string,
|
|
3048
|
+
): void => {
|
|
3049
|
+
diagnostics.push({
|
|
3050
|
+
code: "HSX1007",
|
|
3051
|
+
fix,
|
|
3052
|
+
message: `${owner.id}: ${message}`,
|
|
3053
|
+
severity: "error",
|
|
3054
|
+
span,
|
|
3055
|
+
});
|
|
3056
|
+
};
|
|
3057
|
+
const fieldIn = (instrument: TypedInstrument, name: string) =>
|
|
3058
|
+
instrument.fields.find((field) => field.name === name);
|
|
3059
|
+
const statesOf = (instrument: TypedInstrument): ReadonlySet<string> => {
|
|
3060
|
+
const lifecycle = jsonObject(instrument.slots.lifecycle);
|
|
3061
|
+
return new Set(
|
|
3062
|
+
Array.isArray(lifecycle?.states)
|
|
3063
|
+
? lifecycle.states.filter(
|
|
3064
|
+
(state): state is string => typeof state === "string",
|
|
3065
|
+
)
|
|
3066
|
+
: [],
|
|
3067
|
+
);
|
|
3068
|
+
};
|
|
3069
|
+
|
|
3070
|
+
for (const owner of instruments) {
|
|
3071
|
+
for (const field of owner.fields) {
|
|
3072
|
+
if (field.type.kind !== "ref" || !field.type.target) continue;
|
|
3073
|
+
if (!byId.has(field.type.target)) {
|
|
3074
|
+
report(
|
|
3075
|
+
owner,
|
|
3076
|
+
field.origin,
|
|
3077
|
+
`field ${field.name} has ref<${field.type.target}>, but that instrument does not exist`,
|
|
3078
|
+
`declare instrument ${field.type.target} or correct the ref target`,
|
|
3079
|
+
);
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
for (const invariant of jsonObjects(owner.slots.aggregateInvariants)) {
|
|
3084
|
+
const childId = invariant.childInstrumentId;
|
|
3085
|
+
if (typeof childId !== "string") continue;
|
|
3086
|
+
const child = byId.get(childId);
|
|
3087
|
+
if (!child) {
|
|
3088
|
+
report(
|
|
3089
|
+
owner,
|
|
3090
|
+
owner.origin,
|
|
3091
|
+
`aggregate invariant refers to instrument ${childId}, which does not exist`,
|
|
3092
|
+
`declare instrument ${childId} or correct childInstrumentId`,
|
|
3093
|
+
);
|
|
3094
|
+
continue;
|
|
3095
|
+
}
|
|
3096
|
+
const childRefField = invariant.childRefField;
|
|
3097
|
+
if (typeof childRefField === "string") {
|
|
3098
|
+
const field = fieldIn(child, childRefField);
|
|
3099
|
+
if (
|
|
3100
|
+
!field ||
|
|
3101
|
+
field.type.kind !== "ref" ||
|
|
3102
|
+
(field.type.target !== undefined && field.type.target !== owner.id)
|
|
3103
|
+
) {
|
|
3104
|
+
report(
|
|
3105
|
+
owner,
|
|
3106
|
+
owner.origin,
|
|
3107
|
+
`${childId}.${childRefField} is not ref<${owner.id}>`,
|
|
3108
|
+
`declare ${childId}.${childRefField} as ref<${owner.id}> or correct childRefField`,
|
|
3109
|
+
);
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
const childField = invariant.childField;
|
|
3113
|
+
if (
|
|
3114
|
+
typeof childField === "string" &&
|
|
3115
|
+
fieldIn(child, childField)?.type.kind !== "money"
|
|
3116
|
+
) {
|
|
3117
|
+
report(
|
|
3118
|
+
owner,
|
|
3119
|
+
owner.origin,
|
|
3120
|
+
`${childId}.${childField} is not declared money`,
|
|
3121
|
+
`declare ${childId}.${childField} as money<C> or correct childField`,
|
|
3122
|
+
);
|
|
3123
|
+
}
|
|
3124
|
+
const childStates = statesOf(child);
|
|
3125
|
+
for (const status of Array.isArray(invariant.childStatuses)
|
|
3126
|
+
? invariant.childStatuses
|
|
3127
|
+
: []) {
|
|
3128
|
+
if (typeof status === "string" && !childStates.has(status)) {
|
|
3129
|
+
report(
|
|
3130
|
+
owner,
|
|
3131
|
+
owner.origin,
|
|
3132
|
+
`${childId} has no lifecycle state ${status}`,
|
|
3133
|
+
`add ${status} to ${childId} or correct childStatuses`,
|
|
3134
|
+
);
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
const parentField = invariant.parentField;
|
|
3138
|
+
const parent =
|
|
3139
|
+
typeof parentField === "string"
|
|
3140
|
+
? fieldIn(owner, parentField)
|
|
3141
|
+
: undefined;
|
|
3142
|
+
if (
|
|
3143
|
+
typeof parentField === "string" &&
|
|
3144
|
+
(!parent ||
|
|
3145
|
+
(invariant.count === true
|
|
3146
|
+
? parent.type.kind !== "integer"
|
|
3147
|
+
: parent.type.kind !== "money"))
|
|
3148
|
+
) {
|
|
3149
|
+
const expected = invariant.count === true ? "integer" : "money<C>";
|
|
3150
|
+
report(
|
|
3151
|
+
owner,
|
|
3152
|
+
owner.origin,
|
|
3153
|
+
`${owner.id}.${parentField} is not declared ${expected}`,
|
|
3154
|
+
`declare ${owner.id}.${parentField} as ${expected} or correct parentField`,
|
|
3155
|
+
);
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
const checkRelation = (
|
|
3160
|
+
action: TypedAction,
|
|
3161
|
+
relation: Readonly<Record<string, JsonValue>>,
|
|
3162
|
+
targetKey: "childInstrumentId" | "instrumentId",
|
|
3163
|
+
checkAmountField = true,
|
|
3164
|
+
): void => {
|
|
3165
|
+
const targetId = relation[targetKey];
|
|
3166
|
+
if (typeof targetId !== "string") return;
|
|
3167
|
+
const target = byId.get(targetId);
|
|
3168
|
+
if (!target) {
|
|
3169
|
+
report(
|
|
3170
|
+
owner,
|
|
3171
|
+
action.origin,
|
|
3172
|
+
`action ${action.name} refers to instrument ${targetId}, which does not exist`,
|
|
3173
|
+
`declare instrument ${targetId} or correct ${targetKey}`,
|
|
3174
|
+
);
|
|
3175
|
+
return;
|
|
3176
|
+
}
|
|
3177
|
+
const refField = relation.refField;
|
|
3178
|
+
if (typeof refField === "string") {
|
|
3179
|
+
const field = fieldIn(target, refField);
|
|
3180
|
+
if (!field) {
|
|
3181
|
+
report(
|
|
3182
|
+
owner,
|
|
3183
|
+
action.origin,
|
|
3184
|
+
`${targetId}.${refField} does not exist`,
|
|
3185
|
+
`declare ${refField} on ${targetId} as ref<${owner.id}> or correct the clause`,
|
|
3186
|
+
);
|
|
3187
|
+
} else if (
|
|
3188
|
+
field.type.kind !== "ref" ||
|
|
3189
|
+
(field.type.target !== undefined && field.type.target !== owner.id)
|
|
3190
|
+
) {
|
|
3191
|
+
report(
|
|
3192
|
+
owner,
|
|
3193
|
+
action.origin,
|
|
3194
|
+
`${targetId}.${refField} has ${typeWords(field.type)}; this relation needs ref<${owner.id}>`,
|
|
3195
|
+
`change ${targetId}.${refField} to ref<${owner.id}>`,
|
|
3196
|
+
);
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
const check = jsonObject(relation.check);
|
|
3200
|
+
const amountField = relation.amountField ?? check?.amountField;
|
|
3201
|
+
if (
|
|
3202
|
+
checkAmountField &&
|
|
3203
|
+
typeof amountField === "string" &&
|
|
3204
|
+
relation.path !== "refs"
|
|
3205
|
+
) {
|
|
3206
|
+
const field = fieldIn(target, amountField);
|
|
3207
|
+
if (!field || field.type.kind !== "money") {
|
|
3208
|
+
report(
|
|
3209
|
+
owner,
|
|
3210
|
+
action.origin,
|
|
3211
|
+
`${targetId}.${amountField} is not declared money`,
|
|
3212
|
+
`declare ${targetId}.${amountField} as money<C> or correct the clause`,
|
|
3213
|
+
);
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
const targetField = check?.targetField;
|
|
3217
|
+
if (
|
|
3218
|
+
typeof targetField === "string" &&
|
|
3219
|
+
fieldIn(owner, targetField)?.type.kind !== "money"
|
|
3220
|
+
) {
|
|
3221
|
+
report(
|
|
3222
|
+
owner,
|
|
3223
|
+
action.origin,
|
|
3224
|
+
`${owner.id}.${targetField} is not declared money`,
|
|
3225
|
+
`declare ${owner.id}.${targetField} as money<C> or correct the aggregate target`,
|
|
3226
|
+
);
|
|
3227
|
+
}
|
|
3228
|
+
const statuses = relation.statuses;
|
|
3229
|
+
if (Array.isArray(statuses)) {
|
|
3230
|
+
const targetStates = statesOf(target);
|
|
3231
|
+
for (const status of statuses) {
|
|
3232
|
+
if (typeof status === "string" && !targetStates.has(status)) {
|
|
3233
|
+
report(
|
|
3234
|
+
owner,
|
|
3235
|
+
action.origin,
|
|
3236
|
+
`${targetId} has no lifecycle state ${status}`,
|
|
3237
|
+
`add ${status} to ${targetId} or correct the clause status`,
|
|
3238
|
+
);
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
};
|
|
3243
|
+
|
|
3244
|
+
for (const action of owner.actions) {
|
|
3245
|
+
for (const gate of jsonObjects(action.slots.requiresRefs)) {
|
|
3246
|
+
const fieldName = gate.field;
|
|
3247
|
+
if (typeof fieldName !== "string") continue;
|
|
3248
|
+
const field = fieldIn(owner, fieldName);
|
|
3249
|
+
if (!field || field.type.kind !== "ref" || !field.type.target) continue;
|
|
3250
|
+
const target = byId.get(field.type.target);
|
|
3251
|
+
if (!target) continue;
|
|
3252
|
+
const statuses = Array.isArray(gate.statuses) ? gate.statuses : [];
|
|
3253
|
+
const targetStates = statesOf(target);
|
|
3254
|
+
for (const status of statuses) {
|
|
3255
|
+
if (typeof status === "string" && !targetStates.has(status)) {
|
|
3256
|
+
report(
|
|
3257
|
+
owner,
|
|
3258
|
+
action.origin,
|
|
3259
|
+
`${field.type.target} has no lifecycle state ${status}`,
|
|
3260
|
+
`correct requires refs statuses for ${fieldName}`,
|
|
3261
|
+
);
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
for (const relation of jsonObjects(action.slots.requiresAggregate)) {
|
|
3266
|
+
checkRelation(action, relation, "instrumentId");
|
|
3267
|
+
}
|
|
3268
|
+
for (const relation of jsonObjects(action.slots.requiresExposure)) {
|
|
3269
|
+
const childId = relation.childInstrumentId;
|
|
3270
|
+
if (typeof childId === "string" && childId !== owner.id) {
|
|
3271
|
+
report(
|
|
3272
|
+
owner,
|
|
3273
|
+
action.origin,
|
|
3274
|
+
`requires exposure names child ${childId}, but the action belongs to ${owner.id}`,
|
|
3275
|
+
`set childInstrumentId to ${owner.id}`,
|
|
3276
|
+
);
|
|
3277
|
+
}
|
|
3278
|
+
const amountField = relation.amountField;
|
|
3279
|
+
if (
|
|
3280
|
+
typeof amountField === "string" &&
|
|
3281
|
+
fieldIn(owner, amountField)?.type.kind !== "money"
|
|
3282
|
+
) {
|
|
3283
|
+
report(
|
|
3284
|
+
owner,
|
|
3285
|
+
action.origin,
|
|
3286
|
+
`${owner.id}.${amountField} is not declared money`,
|
|
3287
|
+
`declare ${amountField} as money<C> or correct requires exposure`,
|
|
3288
|
+
);
|
|
3289
|
+
}
|
|
3290
|
+
const anchorField = relation.anchorField;
|
|
3291
|
+
const anchor =
|
|
3292
|
+
typeof anchorField === "string"
|
|
3293
|
+
? fieldIn(owner, anchorField)
|
|
3294
|
+
: undefined;
|
|
3295
|
+
if (
|
|
3296
|
+
typeof anchorField === "string" &&
|
|
3297
|
+
(!anchor || anchor.type.kind !== "ref" || !anchor.type.target)
|
|
3298
|
+
) {
|
|
3299
|
+
report(
|
|
3300
|
+
owner,
|
|
3301
|
+
action.origin,
|
|
3302
|
+
`${owner.id}.${anchorField} is not a typed cross-instrument ref`,
|
|
3303
|
+
`declare ${anchorField} as ref<parent_instrument>`,
|
|
3304
|
+
);
|
|
3305
|
+
} else if (anchor?.type.target) {
|
|
3306
|
+
const parent = byId.get(anchor.type.target);
|
|
3307
|
+
const capField = relation.capField;
|
|
3308
|
+
if (
|
|
3309
|
+
parent &&
|
|
3310
|
+
typeof capField === "string" &&
|
|
3311
|
+
fieldIn(parent, capField)?.type.kind !== "money"
|
|
3312
|
+
) {
|
|
3313
|
+
report(
|
|
3314
|
+
owner,
|
|
3315
|
+
action.origin,
|
|
3316
|
+
`${parent.id}.${capField} is not declared money`,
|
|
3317
|
+
`declare ${parent.id}.${capField} as money<C> or correct requires exposure`,
|
|
3318
|
+
);
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
const signedSum = jsonObject(action.slots.signedSum);
|
|
3323
|
+
for (const relation of jsonObjects(signedSum?.sources)) {
|
|
3324
|
+
checkRelation(action, relation, "instrumentId");
|
|
3325
|
+
}
|
|
3326
|
+
const remainder = jsonObject(action.slots.remainder);
|
|
3327
|
+
for (const relation of jsonObjects(remainder?.collected)) {
|
|
3328
|
+
checkRelation(action, relation, "instrumentId");
|
|
3329
|
+
}
|
|
3330
|
+
for (const reconcile of jsonObjects(action.slots.reconcile)) {
|
|
3331
|
+
const exception = jsonObject(reconcile.exception);
|
|
3332
|
+
if (!exception) continue;
|
|
3333
|
+
checkRelation(action, exception, "childInstrumentId", false);
|
|
3334
|
+
const childId = exception.childInstrumentId;
|
|
3335
|
+
if (typeof childId !== "string") continue;
|
|
3336
|
+
const child = byId.get(childId);
|
|
3337
|
+
if (!child) continue;
|
|
3338
|
+
const required = new Set(requiredFields(child));
|
|
3339
|
+
for (const [key, expectedKind, expectedWords] of [
|
|
3340
|
+
["amountField", "money", "money<C>"],
|
|
3341
|
+
["reasonField", "text", "text"],
|
|
3342
|
+
] as const) {
|
|
3343
|
+
const fieldName = exception[key];
|
|
3344
|
+
if (typeof fieldName !== "string") continue;
|
|
3345
|
+
const field = fieldIn(child, fieldName);
|
|
3346
|
+
if (!field) {
|
|
3347
|
+
report(
|
|
3348
|
+
owner,
|
|
3349
|
+
action.origin,
|
|
3350
|
+
`reconcile exception ${key} names ${childId}.${fieldName}, which does not exist`,
|
|
3351
|
+
`declare ${childId}.${fieldName} as ${expectedWords} or correct ${key}`,
|
|
3352
|
+
);
|
|
3353
|
+
} else if (field.type.kind !== expectedKind) {
|
|
3354
|
+
report(
|
|
3355
|
+
owner,
|
|
3356
|
+
action.origin,
|
|
3357
|
+
`reconcile exception ${key} names ${childId}.${fieldName}, which has ${typeWords(field.type)}; expected ${expectedWords}`,
|
|
3358
|
+
`change ${childId}.${fieldName} to ${expectedWords} or correct ${key}`,
|
|
3359
|
+
);
|
|
3360
|
+
} else if (!field.required || !required.has(fieldName)) {
|
|
3361
|
+
report(
|
|
3362
|
+
owner,
|
|
3363
|
+
action.origin,
|
|
3364
|
+
`reconcile exception ${key} names ${childId}.${fieldName}, which is optional; exception fields must be required`,
|
|
3365
|
+
`add ${childId}.${fieldName} to the child required list or correct ${key}`,
|
|
3366
|
+
);
|
|
3367
|
+
} else if (key === "reasonField") {
|
|
3368
|
+
const constraint =
|
|
3369
|
+
field.schema.pattern !== undefined
|
|
3370
|
+
? "pattern"
|
|
3371
|
+
: field.schema.format !== undefined
|
|
3372
|
+
? "format"
|
|
3373
|
+
: field.schema.enum !== undefined
|
|
3374
|
+
? "enum"
|
|
3375
|
+
: field.schema.const !== undefined
|
|
3376
|
+
? "const"
|
|
3377
|
+
: undefined;
|
|
3378
|
+
if (constraint) {
|
|
3379
|
+
const article = constraint === "enum" ? "an" : "a";
|
|
3380
|
+
report(
|
|
3381
|
+
owner,
|
|
3382
|
+
action.origin,
|
|
3383
|
+
`reason field ${childId}.${fieldName} carries ${article} ${constraint} constraint; reason fields must be unconstrained text`,
|
|
3384
|
+
`remove the ${constraint} constraint from ${childId}.${fieldName} or correct reasonField`,
|
|
3385
|
+
);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
function jsonObject(
|
|
3395
|
+
value: JsonValue | undefined,
|
|
3396
|
+
): Readonly<Record<string, JsonValue>> | undefined {
|
|
3397
|
+
return value !== undefined && isJsonObject(value) ? value : undefined;
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
function jsonObjects(
|
|
3401
|
+
value: JsonValue | undefined,
|
|
3402
|
+
): readonly Readonly<Record<string, JsonValue>>[] {
|
|
3403
|
+
if (Array.isArray(value)) return value.filter(isJsonObject);
|
|
3404
|
+
const object = jsonObject(value);
|
|
3405
|
+
return object ? [object] : [];
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
function linearityDiagnostics(
|
|
3409
|
+
actionEntries: readonly Entry[],
|
|
3410
|
+
diagnostics: GeneralDiagnostic[],
|
|
3411
|
+
): void {
|
|
3412
|
+
const clauses = actionEntries.flatMap((action) => {
|
|
3413
|
+
if (action.value.kind !== "block") return [];
|
|
3414
|
+
return action.value.entries.flatMap((row) => {
|
|
3415
|
+
const spelling = clauseSpelling(row, actionClauseBySpelling);
|
|
3416
|
+
const definition = actionClauseBySpelling.get(spelling);
|
|
3417
|
+
return definition?.scope === "action"
|
|
3418
|
+
? [{ definition, row, spelling }]
|
|
3419
|
+
: [];
|
|
3420
|
+
});
|
|
3421
|
+
});
|
|
3422
|
+
const sinks = clauses.filter(
|
|
3423
|
+
({ definition }) =>
|
|
3424
|
+
"linearSink" in definition && definition.linearSink === true,
|
|
3425
|
+
);
|
|
3426
|
+
for (const producer of clauses) {
|
|
3427
|
+
const names = linearOutputNames(producer.row, producer.definition);
|
|
3428
|
+
for (const name of names) {
|
|
3429
|
+
const uses = sinks.reduce(
|
|
3430
|
+
(count, sink) => count + countIdentifier(sink.row.value, name),
|
|
3431
|
+
0,
|
|
3432
|
+
);
|
|
3433
|
+
if (uses === 0) {
|
|
3434
|
+
diagnostics.push({
|
|
3435
|
+
code: "HSX1202",
|
|
3436
|
+
fix: `route ${name} with moves, payout, distribution, a fee assignment, or an explicit remainder sink`,
|
|
3437
|
+
message: `money ${name} produced by ${producer.spelling} is never consumed`,
|
|
3438
|
+
severity: "error",
|
|
3439
|
+
span: producer.row.span,
|
|
3440
|
+
});
|
|
3441
|
+
} else if (uses > 1) {
|
|
3442
|
+
diagnostics.push({
|
|
3443
|
+
code: "HSX1201",
|
|
3444
|
+
fix: `leave exactly one sink for ${name}`,
|
|
3445
|
+
message: `money ${name} is consumed ${uses} times; each money value must be consumed exactly once`,
|
|
3446
|
+
severity: "error",
|
|
3447
|
+
span: producer.row.span,
|
|
3448
|
+
});
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
function linearOutputNames(
|
|
3455
|
+
row: Entry,
|
|
3456
|
+
definition: ClauseDefinition,
|
|
3457
|
+
): readonly string[] {
|
|
3458
|
+
if (!("linearOutputs" in definition) || !definition.linearOutputs) return [];
|
|
3459
|
+
const spellingQualifierCount = definition.spelling.split(" ").length - 1;
|
|
3460
|
+
const explicitBinding = row.qualifiers.slice(spellingQualifierCount).at(-1);
|
|
3461
|
+
if (explicitBinding) return [explicitBinding.name];
|
|
3462
|
+
return definition.linearOutputs
|
|
3463
|
+
.flatMap((path) => {
|
|
3464
|
+
const value = exprAtPath(row.value, path.split("."));
|
|
3465
|
+
if (value?.kind === "ident") return [value.name];
|
|
3466
|
+
if (value?.kind === "string") return [value.value];
|
|
3467
|
+
if (value?.kind === "path") return [value.parts.at(-1)?.name ?? ""];
|
|
3468
|
+
return [];
|
|
3469
|
+
})
|
|
3470
|
+
.filter((name) => name.length > 0);
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
function exprAtPath(expr: Expr, path: readonly string[]): Expr | undefined {
|
|
3474
|
+
if (path.length === 0) return expr;
|
|
3475
|
+
const block = expr.kind === "decided_amount" ? expr.body : expr;
|
|
3476
|
+
if (block.kind !== "block") return undefined;
|
|
3477
|
+
const [head, ...tail] = path;
|
|
3478
|
+
const row = block.entries.find(
|
|
3479
|
+
(candidate) => camel(candidate.key.name) === camel(head ?? ""),
|
|
3480
|
+
);
|
|
3481
|
+
return row ? exprAtPath(row.value, tail) : undefined;
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
function countIdentifier(expr: Expr, name: string): number {
|
|
3485
|
+
if (expr.kind === "ident") return expr.name === name ? 1 : 0;
|
|
3486
|
+
if (expr.kind === "string")
|
|
3487
|
+
return expr.value === name || expr.value.endsWith(`.${name}`) ? 1 : 0;
|
|
3488
|
+
if (expr.kind === "path") return expr.parts.at(-1)?.name === name ? 1 : 0;
|
|
3489
|
+
if (expr.kind === "settlement_ref") return expr.member.name === name ? 1 : 0;
|
|
3490
|
+
if (expr.kind === "list")
|
|
3491
|
+
return expr.items.reduce(
|
|
3492
|
+
(sum, item) => sum + countIdentifier(item, name),
|
|
3493
|
+
0,
|
|
3494
|
+
);
|
|
3495
|
+
if (expr.kind === "block")
|
|
3496
|
+
return expr.entries.reduce(
|
|
3497
|
+
(sum, row) => sum + countIdentifier(row.value, name),
|
|
3498
|
+
0,
|
|
3499
|
+
);
|
|
3500
|
+
if (expr.kind === "call" || expr.kind === "type_apply") {
|
|
3501
|
+
return expr.args.reduce(
|
|
3502
|
+
(sum, item) => sum + countIdentifier(item, name),
|
|
3503
|
+
0,
|
|
3504
|
+
);
|
|
3505
|
+
}
|
|
3506
|
+
if (expr.kind === "apply") {
|
|
3507
|
+
return [...expr.args, ...expr.typeArgs].reduce(
|
|
3508
|
+
(sum, item) => sum + countIdentifier(item, name),
|
|
3509
|
+
0,
|
|
3510
|
+
);
|
|
3511
|
+
}
|
|
3512
|
+
if (expr.kind === "binding") return countIdentifier(expr.type, name);
|
|
3513
|
+
if (expr.kind === "decided_amount") return countIdentifier(expr.body, name);
|
|
3514
|
+
return 0;
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
function clauseSpelling(
|
|
3518
|
+
row: Entry,
|
|
3519
|
+
vocabulary: ReadonlyMap<string, (typeof udlClauseVocabulary)[number]>,
|
|
3520
|
+
): string {
|
|
3521
|
+
const key = row.key.name.replaceAll("_", " ");
|
|
3522
|
+
const first = row.qualifiers[0]?.name;
|
|
3523
|
+
if (!first) return key;
|
|
3524
|
+
const candidates = [
|
|
3525
|
+
`${key} ${first}`,
|
|
3526
|
+
`${key} ${first.replaceAll("_", " ")}`,
|
|
3527
|
+
];
|
|
3528
|
+
return candidates.find((candidate) => vocabulary.has(candidate)) ?? key;
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
function setClause(
|
|
3532
|
+
target: Record<string, JsonValue>,
|
|
3533
|
+
path: string,
|
|
3534
|
+
cardinality: "one" | "many" | undefined,
|
|
3535
|
+
value: JsonValue,
|
|
3536
|
+
spelling: string,
|
|
3537
|
+
span: Span,
|
|
3538
|
+
diagnostics: GeneralDiagnostic[],
|
|
3539
|
+
): void {
|
|
3540
|
+
const [head, tail] = path.split(".");
|
|
3541
|
+
if (!head) return;
|
|
3542
|
+
if (!tail) {
|
|
3543
|
+
const resolved = clauseValue(
|
|
3544
|
+
target[head],
|
|
3545
|
+
cardinality,
|
|
3546
|
+
value,
|
|
3547
|
+
spelling,
|
|
3548
|
+
span,
|
|
3549
|
+
diagnostics,
|
|
3550
|
+
);
|
|
3551
|
+
if (resolved !== undefined) target[head] = resolved;
|
|
3552
|
+
return;
|
|
3553
|
+
}
|
|
3554
|
+
const nested =
|
|
3555
|
+
target[head] &&
|
|
3556
|
+
typeof target[head] === "object" &&
|
|
3557
|
+
!Array.isArray(target[head])
|
|
3558
|
+
? { ...(target[head] as Record<string, JsonValue>) }
|
|
3559
|
+
: {};
|
|
3560
|
+
const resolved = clauseValue(
|
|
3561
|
+
nested[tail],
|
|
3562
|
+
cardinality,
|
|
3563
|
+
value,
|
|
3564
|
+
spelling,
|
|
3565
|
+
span,
|
|
3566
|
+
diagnostics,
|
|
3567
|
+
);
|
|
3568
|
+
if (resolved !== undefined) nested[tail] = resolved;
|
|
3569
|
+
target[head] = nested;
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
function clauseValue(
|
|
3573
|
+
current: JsonValue | undefined,
|
|
3574
|
+
cardinality: "one" | "many" | undefined,
|
|
3575
|
+
value: JsonValue,
|
|
3576
|
+
spelling: string,
|
|
3577
|
+
span: Span,
|
|
3578
|
+
diagnostics: GeneralDiagnostic[],
|
|
3579
|
+
): JsonValue | undefined {
|
|
3580
|
+
if (cardinality === "many") {
|
|
3581
|
+
const next = Array.isArray(value) ? value : [value];
|
|
3582
|
+
return Array.isArray(current) ? [...current, ...next] : next;
|
|
3583
|
+
}
|
|
3584
|
+
if (current === undefined) return value;
|
|
3585
|
+
diagnostics.push({
|
|
3586
|
+
code: "HSX1505",
|
|
3587
|
+
fix: `keep one ${spelling} clause`,
|
|
3588
|
+
message: `${spelling} is a single-valued UDL clause and appears more than once`,
|
|
3589
|
+
severity: "error",
|
|
3590
|
+
span,
|
|
3591
|
+
});
|
|
3592
|
+
return undefined;
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
function entry(block: BlockExpr, ...names: string[]): Entry | undefined {
|
|
3596
|
+
return block.entries.find((candidate) => names.includes(candidate.key.name));
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
function stringSlot(block: BlockExpr, ...names: string[]): string | undefined {
|
|
3600
|
+
const found = entry(block, ...names)?.value;
|
|
3601
|
+
if (found?.kind === "string") return found.value;
|
|
3602
|
+
if (found?.kind === "ident") return found.name;
|
|
3603
|
+
return undefined;
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
function blockToObject(
|
|
3607
|
+
block: BlockExpr | undefined,
|
|
3608
|
+
substitutions: ReadonlyMap<string, Expr>,
|
|
3609
|
+
diagnostics: GeneralDiagnostic[],
|
|
3610
|
+
): Record<string, JsonValue> {
|
|
3611
|
+
if (!block) return {};
|
|
3612
|
+
const result: Record<string, JsonValue> = {};
|
|
3613
|
+
for (const row of block.entries) {
|
|
3614
|
+
const value = substituteExpr(row.value, substitutions);
|
|
3615
|
+
if (twoArgumentMoneyCall(value)) {
|
|
3616
|
+
diagnostics.push({
|
|
3617
|
+
code: "HSX1104",
|
|
3618
|
+
fix: "bind money(CUR, amount) to a declared field whose UDL schema can carry const",
|
|
3619
|
+
message: `UDL slot ${camel(row.key.name)} cannot carry a fixed money binding`,
|
|
3620
|
+
severity: "error",
|
|
3621
|
+
span: row.value.span,
|
|
3622
|
+
});
|
|
3623
|
+
continue;
|
|
3624
|
+
}
|
|
3625
|
+
result[row.key.quoted ? row.key.name : camel(row.key.name)] = exprToJson(
|
|
3626
|
+
value,
|
|
3627
|
+
diagnostics,
|
|
3628
|
+
);
|
|
3629
|
+
}
|
|
3630
|
+
return result;
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3633
|
+
function exprToJsonForUdlSlot(
|
|
3634
|
+
expr: Expr,
|
|
3635
|
+
slot: string,
|
|
3636
|
+
diagnostics: GeneralDiagnostic[],
|
|
3637
|
+
): JsonValue {
|
|
3638
|
+
if (twoArgumentMoneyCall(expr)) {
|
|
3639
|
+
diagnostics.push({
|
|
3640
|
+
code: "HSX1104",
|
|
3641
|
+
fix: "bind money(CUR, amount) to a declared field whose UDL schema can carry const",
|
|
3642
|
+
message: `UDL slot ${slot} cannot carry a fixed money binding`,
|
|
3643
|
+
severity: "error",
|
|
3644
|
+
span: expr.span,
|
|
3645
|
+
});
|
|
3646
|
+
}
|
|
3647
|
+
if (slot === "callerParkedStates" && expr.kind === "block") {
|
|
3648
|
+
return Object.fromEntries(
|
|
3649
|
+
expr.entries.map((row) => [
|
|
3650
|
+
row.key.name,
|
|
3651
|
+
exprToJson(row.value, diagnostics),
|
|
3652
|
+
]),
|
|
3653
|
+
);
|
|
3654
|
+
}
|
|
3655
|
+
return exprToJson(expr, diagnostics);
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
function exprToJson(expr: Expr, diagnostics: GeneralDiagnostic[]): JsonValue {
|
|
3659
|
+
switch (expr.kind) {
|
|
3660
|
+
case "boolean":
|
|
3661
|
+
return expr.value;
|
|
3662
|
+
case "string":
|
|
3663
|
+
return expr.value;
|
|
3664
|
+
case "number": {
|
|
3665
|
+
if (expr.raw.includes(".")) {
|
|
3666
|
+
diagnostics.push({
|
|
3667
|
+
code: "HSX1103",
|
|
3668
|
+
fix: "use an integer, percent, bps, or currency-indexed money literal",
|
|
3669
|
+
message: `${expr.raw} is a free decimal; HSX has no float type`,
|
|
3670
|
+
severity: "error",
|
|
3671
|
+
span: expr.span,
|
|
3672
|
+
});
|
|
3673
|
+
return expr.raw;
|
|
3674
|
+
}
|
|
3675
|
+
return Number(expr.raw);
|
|
3676
|
+
}
|
|
3677
|
+
case "percent":
|
|
3678
|
+
return expr.bps;
|
|
3679
|
+
case "money": {
|
|
3680
|
+
const precision = minorUnits[expr.currency.name] ?? 2;
|
|
3681
|
+
const [whole = "0", fraction = ""] = expr.raw.split(".");
|
|
3682
|
+
if (fraction.length > precision) {
|
|
3683
|
+
diagnostics.push({
|
|
3684
|
+
code: "HSX1102",
|
|
3685
|
+
fix: `round the literal to ${precision} minor-unit digits`,
|
|
3686
|
+
message: `${expr.currency.name} ${expr.raw} is finer than ${expr.currency.name} minor units`,
|
|
3687
|
+
severity: "error",
|
|
3688
|
+
span: expr.span,
|
|
3689
|
+
});
|
|
3690
|
+
}
|
|
3691
|
+
return `${whole}${fraction.padEnd(precision, "0")}`.replace(
|
|
3692
|
+
/^0+(?=\d)/,
|
|
3693
|
+
"",
|
|
3694
|
+
);
|
|
3695
|
+
}
|
|
3696
|
+
case "ident":
|
|
3697
|
+
return expr.name;
|
|
3698
|
+
case "path":
|
|
3699
|
+
return expr.parts.map((part) => part.name).join(".");
|
|
3700
|
+
case "settlement_ref":
|
|
3701
|
+
return `${expr.owner.name}.${expr.member.name}`;
|
|
3702
|
+
case "list":
|
|
3703
|
+
return expr.items.map((item) => exprToJson(item, diagnostics));
|
|
3704
|
+
case "block":
|
|
3705
|
+
return blockToObject(expr, new Map(), diagnostics);
|
|
3706
|
+
case "call":
|
|
3707
|
+
return `${expr.callee.name}(${expr.args.map((arg) => String(exprToJson(arg, diagnostics))).join(",")})`;
|
|
3708
|
+
case "type_apply":
|
|
3709
|
+
return `${expr.callee.name}<${expr.args.map((arg) => String(exprToJson(arg, diagnostics))).join(",")}>`;
|
|
3710
|
+
case "binding":
|
|
3711
|
+
return exprToJson(expr.type, diagnostics);
|
|
3712
|
+
case "decided_amount":
|
|
3713
|
+
return blockToObject(expr.body, new Map(), diagnostics);
|
|
3714
|
+
case "port_ref":
|
|
3715
|
+
return expr.name.name;
|
|
3716
|
+
case "apply":
|
|
3717
|
+
return `${expr.callee.parts.map((part) => part.name).join(".")}()`;
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
function typeOf(
|
|
3722
|
+
expr: Expr,
|
|
3723
|
+
aliases: ReadonlyMap<string, Expr> = new Map(),
|
|
3724
|
+
seen: ReadonlySet<string> = new Set(),
|
|
3725
|
+
): HsxType {
|
|
3726
|
+
if (expr.kind === "binding") return typeOf(expr.type, aliases, seen);
|
|
3727
|
+
if (expr.kind === "money") {
|
|
3728
|
+
return { currency: expr.currency.name, kind: "money" };
|
|
3729
|
+
}
|
|
3730
|
+
if (expr.kind === "type_apply" || expr.kind === "call") {
|
|
3731
|
+
const kind = expr.callee.name;
|
|
3732
|
+
const argument = expr.args[0];
|
|
3733
|
+
const name =
|
|
3734
|
+
argument?.kind === "ident"
|
|
3735
|
+
? argument.name
|
|
3736
|
+
: argument?.kind === "string"
|
|
3737
|
+
? argument.value
|
|
3738
|
+
: undefined;
|
|
3739
|
+
if (kind === "money") {
|
|
3740
|
+
const amount = expr.args[1];
|
|
3741
|
+
const fixedAmount =
|
|
3742
|
+
expr.args.length === 2 &&
|
|
3743
|
+
amount?.kind === "number" &&
|
|
3744
|
+
/^[1-9][0-9]{0,17}$/.test(amount.raw) &&
|
|
3745
|
+
Number.isSafeInteger(Number(amount.raw))
|
|
3746
|
+
? amount.raw
|
|
3747
|
+
: undefined;
|
|
3748
|
+
return {
|
|
3749
|
+
...(name ? { currency: name } : {}),
|
|
3750
|
+
...(fixedAmount ? { fixedAmount } : {}),
|
|
3751
|
+
kind: "money",
|
|
3752
|
+
};
|
|
3753
|
+
}
|
|
3754
|
+
if (kind === "account")
|
|
3755
|
+
return { ...(name ? { currency: name } : {}), kind: "account" };
|
|
3756
|
+
if (kind === "ref" || kind === "id")
|
|
3757
|
+
return { kind: "ref", ...(name ? { target: name } : {}) };
|
|
3758
|
+
}
|
|
3759
|
+
if (expr.kind === "ident") {
|
|
3760
|
+
const alias = aliases.get(expr.name);
|
|
3761
|
+
if (alias && !seen.has(expr.name)) {
|
|
3762
|
+
return typeOf(alias, aliases, new Set([...seen, expr.name]));
|
|
3763
|
+
}
|
|
3764
|
+
if (CURRENCY.test(expr.name))
|
|
3765
|
+
return { currency: expr.name, kind: "unknown" };
|
|
3766
|
+
if (
|
|
3767
|
+
[
|
|
3768
|
+
"boolean",
|
|
3769
|
+
"account",
|
|
3770
|
+
"bps",
|
|
3771
|
+
"condition",
|
|
3772
|
+
"date",
|
|
3773
|
+
"integer",
|
|
3774
|
+
"party",
|
|
3775
|
+
"percent",
|
|
3776
|
+
"ref",
|
|
3777
|
+
"text",
|
|
3778
|
+
].includes(expr.name)
|
|
3779
|
+
)
|
|
3780
|
+
return { kind: expr.name as HsxType["kind"] };
|
|
3781
|
+
}
|
|
3782
|
+
if (expr.kind === "percent") return { kind: "percent" };
|
|
3783
|
+
if (expr.kind === "boolean") return { kind: "boolean" };
|
|
3784
|
+
if (expr.kind === "number" && !expr.raw.includes("."))
|
|
3785
|
+
return { kind: "integer" };
|
|
3786
|
+
return { kind: "unknown" };
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3789
|
+
function schemaFor(
|
|
3790
|
+
type: HsxType,
|
|
3791
|
+
required: boolean,
|
|
3792
|
+
): Record<string, JsonValue> {
|
|
3793
|
+
switch (type.kind) {
|
|
3794
|
+
case "money":
|
|
3795
|
+
return type.fixedAmount
|
|
3796
|
+
? {
|
|
3797
|
+
const: type.fixedAmount,
|
|
3798
|
+
pattern: MONEY_PATTERN,
|
|
3799
|
+
type: "string",
|
|
3800
|
+
}
|
|
3801
|
+
: {
|
|
3802
|
+
pattern: required ? MONEY_PATTERN : OPTIONAL_MONEY_PATTERN,
|
|
3803
|
+
type: "string",
|
|
3804
|
+
};
|
|
3805
|
+
case "bps":
|
|
3806
|
+
case "percent":
|
|
3807
|
+
return { maximum: 10_000, minimum: 0, type: "integer" };
|
|
3808
|
+
case "integer":
|
|
3809
|
+
return { type: "integer" };
|
|
3810
|
+
case "boolean":
|
|
3811
|
+
return { type: "boolean" };
|
|
3812
|
+
case "date":
|
|
3813
|
+
return { format: "hyperscale-date-time", type: "string" };
|
|
3814
|
+
case "ref":
|
|
3815
|
+
return { type: "string" };
|
|
3816
|
+
default:
|
|
3817
|
+
return { type: "string" };
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
function invalidFixedMoneyBinding(expr: Expr): string | undefined {
|
|
3822
|
+
const candidate = twoArgumentMoneyCall(expr);
|
|
3823
|
+
if (!candidate) return undefined;
|
|
3824
|
+
const amount = candidate.args[1];
|
|
3825
|
+
if (
|
|
3826
|
+
candidate.args.length === 2 &&
|
|
3827
|
+
amount?.kind === "number" &&
|
|
3828
|
+
/^[1-9][0-9]{0,17}$/.test(amount.raw) &&
|
|
3829
|
+
Number.isSafeInteger(Number(amount.raw))
|
|
3830
|
+
) {
|
|
3831
|
+
return undefined;
|
|
3832
|
+
}
|
|
3833
|
+
return "fixes money to an invalid amount; this UDL field slot accepts one fixed minor-unit integer";
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
function twoArgumentMoneyCall(
|
|
3837
|
+
expr: Expr,
|
|
3838
|
+
): Extract<Expr, { readonly kind: "call" }> | undefined {
|
|
3839
|
+
const candidate = expr.kind === "binding" ? expr.type : expr;
|
|
3840
|
+
return candidate.kind === "call" &&
|
|
3841
|
+
candidate.callee.name === "money" &&
|
|
3842
|
+
candidate.args.length >= 2
|
|
3843
|
+
? candidate
|
|
3844
|
+
: undefined;
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
function typeWords(type: HsxType): string {
|
|
3848
|
+
return type.currency ? `${type.kind}<${type.currency}>` : type.kind;
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
function camel(value: string): string {
|
|
3852
|
+
return value.replace(/_([a-z0-9])/g, (_, letter: string) =>
|
|
3853
|
+
letter.toUpperCase(),
|
|
3854
|
+
);
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
function prefixFor(value: string): string {
|
|
3858
|
+
const parts = value.split("_");
|
|
3859
|
+
return parts.length > 1
|
|
3860
|
+
? parts
|
|
3861
|
+
.map((part) => part[0] ?? "")
|
|
3862
|
+
.join("")
|
|
3863
|
+
.slice(0, 8)
|
|
3864
|
+
: value.slice(0, 4);
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
function titleize(value: string): string {
|
|
3868
|
+
return value
|
|
3869
|
+
.replaceAll("_", " ")
|
|
3870
|
+
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
function sentenceCase(value: string): string {
|
|
3874
|
+
const words = value.replaceAll("_", " ");
|
|
3875
|
+
return `${words[0]?.toUpperCase() ?? ""}${words.slice(1)}`;
|
|
3876
|
+
}
|