@ldclabs/kip-lang 0.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/LICENSE +21 -0
  3. package/README.md +89 -64
  4. package/dist/ast.d.ts +511 -145
  5. package/dist/ast.d.ts.map +1 -1
  6. package/dist/diagnostics.d.ts +8 -2
  7. package/dist/diagnostics.d.ts.map +1 -1
  8. package/dist/diagnostics.js +32 -3
  9. package/dist/diagnostics.js.map +1 -1
  10. package/dist/exec-ast.d.ts +514 -149
  11. package/dist/exec-ast.d.ts.map +1 -1
  12. package/dist/exec-ast.js +8 -7
  13. package/dist/exec-ast.js.map +1 -1
  14. package/dist/formatter.d.ts.map +1 -1
  15. package/dist/formatter.js +870 -479
  16. package/dist/formatter.js.map +1 -1
  17. package/dist/index.d.ts +4 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/lexer.d.ts.map +1 -1
  22. package/dist/lexer.js +12 -30
  23. package/dist/lexer.js.map +1 -1
  24. package/dist/lower.d.ts +1 -2
  25. package/dist/lower.d.ts.map +1 -1
  26. package/dist/lower.js +1287 -598
  27. package/dist/lower.js.map +1 -1
  28. package/dist/parser.d.ts.map +1 -1
  29. package/dist/parser.js +2410 -1300
  30. package/dist/parser.js.map +1 -1
  31. package/dist/semantics.d.ts +11 -7
  32. package/dist/semantics.d.ts.map +1 -1
  33. package/dist/semantics.js +295 -180
  34. package/dist/semantics.js.map +1 -1
  35. package/dist/token.d.ts +130 -40
  36. package/dist/token.d.ts.map +1 -1
  37. package/dist/token.js +264 -83
  38. package/dist/token.js.map +1 -1
  39. package/dist/version.d.ts +2 -2
  40. package/dist/version.js +2 -2
  41. package/package.json +35 -5
  42. package/src/ast.ts +914 -0
  43. package/src/budget.ts +108 -0
  44. package/src/diagnostics.ts +182 -0
  45. package/src/errors.ts +42 -0
  46. package/src/exec-ast.ts +614 -0
  47. package/src/formatter.ts +1339 -0
  48. package/src/index.ts +226 -0
  49. package/src/lexer.ts +459 -0
  50. package/src/lower.ts +2011 -0
  51. package/src/parser.ts +3506 -0
  52. package/src/semantics.ts +392 -0
  53. package/src/token.ts +408 -0
  54. package/src/version.ts +13 -0
package/dist/lower.js CHANGED
@@ -1,12 +1,4 @@
1
1
  import { invalidSyntax } from './errors.js';
2
- /**
3
- * `LIMIT` is a `usize` in the reference grammar, which is 32-bit on the
4
- * WebAssembly target every JavaScript KIP engine is compiled for.
5
- */
6
- const MAX_LIMIT = 0xffffffff;
7
- /** A KIP integer is whatever an i64 or a u64 can hold, so the union of both. */
8
- const I64_MIN = -(2n ** 63n);
9
- const U64_MAX = 2n ** 64n - 1n;
10
2
  const AGGREGATIONS = new Map([
11
3
  ['COUNT', 'Count'],
12
4
  ['SUM', 'Sum'],
@@ -21,7 +13,11 @@ const FILTER_FUNCTIONS = new Map([
21
13
  ['REGEX', 'Regex'],
22
14
  ['IN', 'In'],
23
15
  ['IS_NULL', 'IsNull'],
24
- ['IS_NOT_NULL', 'IsNotNull']
16
+ ['IS_NOT_NULL', 'IsNotNull'],
17
+ ['IS_LITERAL', 'IsLiteral'],
18
+ ['IS_ELEMENT', 'IsElement'],
19
+ ['IS_KIND', 'IsKind'],
20
+ ['LITERAL_TYPE', 'LiteralType']
25
21
  ]);
26
22
  const UPDATE_FUNCTIONS = new Map([
27
23
  ['ADD', 'Add'],
@@ -43,10 +39,47 @@ const COMPARISONS = new Map([
43
39
  ['<=', 'LessEqual'],
44
40
  ['>=', 'GreaterEqual']
45
41
  ]);
46
- const CONCEPT_IDENTITY_HELP = 'a concept must be addressed by {id: "<id>"} or {type: "<Type>", name: "<name>"} — ' +
47
- '{type: ...} or {name: ...} alone identifies no single node';
48
- const PROPOSITION_IDENTITY_HELP = 'a proposition must be addressed by (id: "<id>") or by a subject, a literal ' +
49
- 'predicate and an object that each identify one element';
42
+ /**
43
+ * Engine-owned state no cognitive mutation may write (Spec §6.3, §2.11).
44
+ *
45
+ * These are checked by name on every mutation, not just on UPDATE: author
46
+ * content that could rewrite engine truth or its own authority is exactly
47
+ * what "external cognition cannot self-escalate authority" forbids.
48
+ */
49
+ const PROTECTED_FIELDS = new Set([
50
+ '_system',
51
+ 'governance',
52
+ 'space_id',
53
+ 'space_seq'
54
+ ]);
55
+ /**
56
+ * Assertion payload that is immutable after creation (Spec §13.7).
57
+ *
58
+ * Changing epistemic commitment means a new Assertion plus supersession, so
59
+ * an UPDATE naming one of these is the `EpistemicRevisionRequired` mistake
60
+ * caught statically wherever the WHERE block says the target is an Assertion.
61
+ */
62
+ const ASSERTION_IMMUTABLE = new Set([
63
+ 'proposition_id',
64
+ 'proposition',
65
+ 'asserted_by',
66
+ 'stance',
67
+ 'mode',
68
+ 'confidence',
69
+ 'asserted_at',
70
+ 'valid_time',
71
+ 'evidence_refs'
72
+ ]);
73
+ /** Evidence payload and observation identity are immutable (Spec §15.5). */
74
+ const EVIDENCE_IMMUTABLE = new Set([
75
+ 'evidence_class',
76
+ 'payload',
77
+ 'content_digest',
78
+ 'media_type',
79
+ 'observed_at'
80
+ ]);
81
+ /** A Proposition tuple is immutable after creation (Spec §12.5). */
82
+ const PROPOSITION_IMMUTABLE = new Set(['subject', 'predicate', 'object']);
50
83
  /**
51
84
  * Lowers a parsed program carrying exactly one command.
52
85
  *
@@ -62,280 +95,352 @@ export function lower(program) {
62
95
  if (!first) {
63
96
  throw invalidSyntax('expected a KIP command, found none');
64
97
  }
65
- if (!second)
66
- return lowerStatement(first);
67
- // Consecutive UPSERT blocks are one command, not a batch: a schema capsule
68
- // is written as several `UPSERT { ... }` in sequence and applies as a unit.
69
- // Anything else following a complete command is trailing content.
70
- if (program.statements.every((s) => s.kind === 'UpsertStatement')) {
71
- return {
72
- Kml: {
73
- Upsert: program.statements.map(lowerUpsert)
74
- }
75
- };
98
+ if (second) {
99
+ throw invalidSyntax('expected one KIP command, found several: wrap consecutive mutations in ' +
100
+ 'MUTATE { ... } to make them one transaction, or use lowerAll for a batch', second.range);
76
101
  }
77
- throw invalidSyntax(`expected a single KIP command, found ${program.statements.length}`, second.range);
102
+ return lowerStatement(first);
78
103
  }
79
- /** Lowers every command in a multi-statement program, in source order. */
104
+ /** Lowers every statement in a multi-command source text. */
80
105
  export function lowerAll(program) {
106
+ if (program.statements.length === 0) {
107
+ throw invalidSyntax('expected at least one KIP command, found none');
108
+ }
81
109
  return program.statements.map(lowerStatement);
82
110
  }
83
- /** Lowers one statement. */
84
111
  export function lowerStatement(stmt) {
85
112
  switch (stmt.kind) {
86
113
  case 'FindStatement':
87
114
  return { Kql: lowerFind(stmt) };
88
- case 'UpsertStatement':
89
- return { Kml: { Upsert: [lowerUpsert(stmt)] } };
115
+ case 'MutateStatement':
116
+ return { Kml: lowerMutate(stmt) };
117
+ case 'CreateConceptStatement':
118
+ case 'UpsertConceptStatement':
119
+ case 'EnsurePropositionStatement':
120
+ case 'AssertStatement':
121
+ case 'CreateEvidenceStatement':
122
+ case 'CreateAssertionStatement':
123
+ case 'CreateActivityStatement':
90
124
  case 'UpdateStatement':
91
- return { Kml: { Update: lowerUpdate(stmt) } };
92
- case 'MergeStatement':
93
- return { Kml: { Merge: lowerMerge(stmt) } };
94
- case 'DeleteStatement':
95
- return { Kml: { Delete: lowerDelete(stmt) } };
96
- case 'DescribeStatement':
97
- return { Meta: { Describe: lowerDescribe(stmt) } };
98
- case 'SearchStatement':
99
- return { Meta: { Search: lowerSearch(stmt) } };
100
- case 'ExportStatement': {
101
- const exportCmd = {
102
- target: varName(stmt.target, stmt.range),
103
- where_clauses: lowerWhere(stmt.where),
104
- limit: lowerLimit(stmt.limit)
125
+ case 'RetractAssertionStatement':
126
+ case 'SupersedeAssertionStatement':
127
+ case 'CorrectEvidenceStatement':
128
+ case 'TransitionActivityStatement':
129
+ case 'SetRetentionStatement':
130
+ case 'ArchiveStatement':
131
+ case 'TombstoneStatement':
132
+ case 'PurgeStatement':
133
+ case 'MergeConceptStatement':
134
+ const clauses = lowerMutationClause(stmt, 0);
135
+ assertUniqueHandles(clauses, stmt.range);
136
+ assertResolvedHandles(clauses, stmt.range);
137
+ return {
138
+ Kml: {
139
+ explicit_transaction: false,
140
+ clauses
141
+ }
105
142
  };
106
- const cursor = lowerCursor(stmt.cursor);
107
- if (cursor !== null)
108
- exportCmd.cursor = cursor;
109
- return { Meta: { Export: exportCmd } };
110
- }
143
+ default:
144
+ return { Meta: lowerMeta(stmt) };
111
145
  }
112
146
  }
113
147
  // ---------------------------------------------------------------------------
114
148
  // KQL
115
149
  // ---------------------------------------------------------------------------
116
150
  function lowerFind(stmt) {
117
- if (!stmt.where) {
118
- throw invalidSyntax('FIND requires a WHERE clause', stmt.range);
151
+ if (stmt.projections.length === 0) {
152
+ throw invalidSyntax('FIND requires at least one projection', stmt.range);
119
153
  }
120
154
  return {
121
- find_clause: { expressions: stmt.projections.map(lowerFindExpression) },
155
+ find_clause: {
156
+ expressions: stmt.projections.map((p) => lowerFindExpression(p))
157
+ },
122
158
  where_clauses: lowerWhere(stmt.where),
123
- order_by: stmt.orderBy ? stmt.orderBy.keys.map(lowerOrderByKey) : null,
124
- limit: lowerLimit(stmt.limit),
125
- cursor: lowerCursor(stmt.cursor)
159
+ as_of: stmt.asOf ? lowerAsOf(stmt.asOf) : null,
160
+ for_time: stmt.forTime ? lowerScalar(stmt.forTime.value) : null,
161
+ epistemic: stmt.epistemic ? lowerBoundObject(stmt.epistemic.options) : null,
162
+ order_by: stmt.orderBy ? stmt.orderBy.items.map(lowerOrderItem) : null,
163
+ limit: stmt.limit ? lowerScalar(stmt.limit.value) : null,
164
+ cursor: stmt.cursor ? lowerScalar(stmt.cursor.value) : null
126
165
  };
127
166
  }
167
+ function lowerAsOf(clause) {
168
+ const value = lowerScalar(clause.value);
169
+ switch (clause.basis) {
170
+ case 'SEQ':
171
+ return { Seq: value };
172
+ case 'TX':
173
+ return { Tx: value };
174
+ case 'TIME':
175
+ return { Time: value };
176
+ }
177
+ }
128
178
  function lowerFindExpression(expr) {
129
- if (expr.kind === 'FunctionCallExpr') {
130
- const { func, arg, distinct } = aggregation(expr);
131
- return { Aggregation: { func, var: arg, distinct } };
132
- }
133
- return { Variable: dotPathVar(expr) };
134
- }
135
- function lowerOrderByKey(key) {
136
- const direction = key.direction === 'DESC' ? 'Desc' : 'Asc';
137
- if (key.expression.kind === 'FunctionCallExpr') {
138
- const { func, arg, distinct } = aggregation(key.expression);
139
- // A sort key carries no `distinct` flag, so accepting the modifier would
140
- // sort by a different aggregate than the one projected.
141
- if (distinct) {
142
- throw invalidSyntax('ORDER BY takes no DISTINCT: sort by the projected aggregate instead', key.expression.range);
179
+ if (expr.kind === 'AggregateExpr') {
180
+ const func = AGGREGATIONS.get(expr.name.toUpperCase());
181
+ if (!func) {
182
+ throw invalidSyntax(`unknown aggregate ${expr.name}`, expr.range);
143
183
  }
144
- return { variable: arg, direction, aggregation: func };
184
+ return {
185
+ Aggregation: {
186
+ func,
187
+ var: lowerDotPath(expr.argument),
188
+ distinct: expr.distinct
189
+ }
190
+ };
191
+ }
192
+ return { Variable: lowerDotPath(expr) };
193
+ }
194
+ function lowerOrderItem(item) {
195
+ const direction = item.direction === 'DESC' ? 'Desc' : 'Asc';
196
+ if (item.expression.kind === 'AggregateExpr') {
197
+ const func = AGGREGATIONS.get(item.expression.name.toUpperCase());
198
+ if (!func) {
199
+ throw invalidSyntax(`unknown aggregate ${item.expression.name}`, item.expression.range);
200
+ }
201
+ return {
202
+ variable: lowerDotPath(item.expression.argument),
203
+ direction,
204
+ aggregation: func
205
+ };
145
206
  }
146
207
  return {
147
- variable: dotPathVar(key.expression),
208
+ variable: lowerDotPath(item.expression),
148
209
  direction,
149
210
  aggregation: null
150
211
  };
151
212
  }
152
- /** Reads `COUNT(?x)`, `COUNT(DISTINCT ?x)`, `SUM(?x.n)` and friends. */
153
- function aggregation(expr) {
154
- // `COUNT(DISTINCT ?x)` has no separate syntax for the modifier, so it parses
155
- // as a nested call `DISTINCT(?x)`. Unwrap it before looking at arity.
156
- let args = expr.args;
157
- let distinct = false;
158
- const [head] = args;
159
- if (args.length === 1 &&
160
- head &&
161
- head.kind === 'FunctionCallExpr' &&
162
- head.name === 'DISTINCT') {
163
- distinct = true;
164
- args = head.args;
165
- }
166
- const func = AGGREGATIONS.get(expr.name);
167
- if (!func) {
168
- throw invalidSyntax(`unknown aggregation function ${expr.name}(...): expected COUNT, SUM, AVG, MIN or MAX`, expr.range);
169
- }
170
- if (args.length !== 1) {
171
- throw invalidSyntax(`${expr.name} takes exactly one argument, got ${args.length}`, expr.range);
172
- }
173
- return { func, arg: dotPathVar(args[0]), distinct };
174
- }
175
- function lowerWhere(where) {
176
- const clauses = where.patterns.map(lowerWherePattern);
177
- if (clauses.length === 0) {
178
- throw invalidSyntax('WHERE must contain at least one clause', where.range);
213
+ /** A projection or sort key must resolve to one variable plus a path. */
214
+ function lowerDotPath(expr) {
215
+ if (expr.kind === 'VariableRef') {
216
+ return { var: varName(expr.name, expr.range), path: [] };
179
217
  }
180
- return clauses;
218
+ if (expr.kind === 'FieldAccess') {
219
+ const path = expr.steps.map((step) => step.kind === 'DotStep'
220
+ ? { Field: step.name }
221
+ : { Key: step.key.parsed });
222
+ return { var: varName(expr.base.name, expr.base.range), path };
223
+ }
224
+ throw invalidSyntax(`expected a variable or a dot path, found ${describeExpression(expr)}`, expr.range);
225
+ }
226
+ function lowerWhere(clause) {
227
+ return clause.patterns.map(lowerWherePattern);
181
228
  }
182
229
  function lowerWherePattern(pattern) {
183
230
  switch (pattern.kind) {
184
- case 'ConceptPattern': {
185
- if (!pattern.variable) {
186
- throw invalidSyntax('a concept clause in WHERE must bind a variable, e.g. ?x {type: "T"}', pattern.range);
187
- }
231
+ case 'ConceptPattern':
188
232
  return {
189
233
  Concept: {
190
- variable: varName(pattern.variable, pattern.range),
191
- matcher: lowerConceptMatcher(pattern.matcher)
234
+ variable: varName(pattern.variable.name, pattern.variable.range),
235
+ matcher: lowerObjectMatcher(pattern.matcher)
192
236
  }
193
237
  };
194
- }
195
238
  case 'PropositionPattern':
196
239
  return {
197
240
  Proposition: {
198
241
  variable: pattern.variable
199
- ? varName(pattern.variable, pattern.range)
242
+ ? varName(pattern.variable.name, pattern.variable.range)
200
243
  : null,
201
- matcher: lowerPropositionMatcher(pattern)
244
+ matcher: lowerPropositionMatcher(pattern.tuple)
245
+ }
246
+ };
247
+ case 'AssertionPattern':
248
+ return {
249
+ Assertion: {
250
+ variable: varName(pattern.variable.name, pattern.variable.range),
251
+ matcher: lowerObjectMatcher(pattern.matcher)
252
+ }
253
+ };
254
+ case 'EvidencePattern':
255
+ return {
256
+ Evidence: {
257
+ variable: varName(pattern.variable.name, pattern.variable.range),
258
+ matcher: lowerObjectMatcher(pattern.matcher)
259
+ }
260
+ };
261
+ case 'ActivityPattern':
262
+ return {
263
+ Activity: {
264
+ variable: varName(pattern.variable.name, pattern.variable.range),
265
+ matcher: lowerObjectMatcher(pattern.matcher)
266
+ }
267
+ };
268
+ case 'StructuralPattern':
269
+ return {
270
+ Structural: {
271
+ variable: pattern.variable
272
+ ? varName(pattern.variable.name, pattern.variable.range)
273
+ : null,
274
+ subject: lowerTerm(pattern.subject),
275
+ field: lowerSymbol(pattern.field),
276
+ object: lowerTerm(pattern.object)
277
+ }
278
+ };
279
+ case 'BeliefPattern': {
280
+ let target;
281
+ if (pattern.proposition) {
282
+ target = {
283
+ Proposition: varName(pattern.proposition.name, pattern.proposition.range)
284
+ };
285
+ }
286
+ else if (pattern.propositionId) {
287
+ // Same slot, same reference form as `?p PROPOSITION (id: ...)`.
288
+ target = { Id: lowerScalar(pattern.propositionId) };
289
+ }
290
+ else {
291
+ if (!pattern.subject || !pattern.predicate || !pattern.object) {
292
+ throw invalidSyntax('BELIEF requires one bound Proposition, an (id: ...) reference, or a full (subject, predicate, object) tuple', pattern.range);
293
+ }
294
+ target = {
295
+ Tuple: {
296
+ subject: lowerPropositionSubject(pattern.subject),
297
+ predicate: { Atom: lowerPredAtom(pattern.predicate) },
298
+ object: lowerTerm(pattern.object)
299
+ }
300
+ };
301
+ }
302
+ return {
303
+ Belief: {
304
+ variable: varName(pattern.variable.name, pattern.variable.range),
305
+ target
306
+ }
307
+ };
308
+ }
309
+ case 'BeliefSlotPattern':
310
+ return {
311
+ BeliefSlot: {
312
+ variable: varName(pattern.variable.name, pattern.variable.range),
313
+ subject: lowerPropositionSubject(pattern.subject),
314
+ predicate: lowerPredAtom(pattern.predicate)
202
315
  }
203
316
  };
204
317
  case 'FilterClause':
205
318
  return { Filter: { expression: lowerFilter(pattern.expression) } };
206
319
  case 'NotClause':
207
- return { Not: nonEmptyBlock(pattern.patterns, 'NOT', pattern.range) };
320
+ return { Not: pattern.patterns.map(lowerWherePattern) };
208
321
  case 'OptionalClause':
209
- return {
210
- Optional: nonEmptyBlock(pattern.patterns, 'OPTIONAL', pattern.range)
211
- };
322
+ return { Optional: pattern.patterns.map(lowerWherePattern) };
212
323
  case 'UnionClause':
213
- return { Union: nonEmptyBlock(pattern.patterns, 'UNION', pattern.range) };
324
+ return { Union: pattern.patterns.map(lowerWherePattern) };
214
325
  }
215
326
  }
216
- function nonEmptyBlock(patterns, name, range) {
217
- if (patterns.length === 0) {
218
- throw invalidSyntax(`${name} block must contain at least one clause`, range);
327
+ function lowerPropositionMatcher(tuple) {
328
+ if (tuple.id)
329
+ return { Id: lowerScalar(tuple.id) };
330
+ if (!tuple.subject || !tuple.predicate || !tuple.object) {
331
+ throw invalidSyntax('a Proposition expression is either (subject, predicate, object) or (id: ...)', tuple.range);
219
332
  }
220
- return patterns.map(lowerWherePattern);
333
+ return {
334
+ Tuple: {
335
+ subject: lowerPropositionSubject(tuple.subject),
336
+ predicate: lowerPredicate(tuple.predicate),
337
+ object: lowerTerm(tuple.object)
338
+ }
339
+ };
221
340
  }
222
341
  /**
223
- * Collapses `{id: ..., type: ..., name: ...}` to the one identified form it
224
- * expresses.
342
+ * Resolves the tuple a resolve-or-create statement needs.
225
343
  *
226
- * A duplicate or unknown key is rejected rather than resolved by precedence:
227
- * in generated KML both are far more often a mistake than an intent, and
228
- * silently keeping the last `type:` would run a different query than the one
229
- * written. A `null` value reads as "key absent", which is how the reference
230
- * grammar treats it.
344
+ * `(id: ...)` is match-only: it names a Proposition that must already exist,
345
+ * so it cannot drive ENSURE PROPOSITION or the ASSERT sugar that desugars
346
+ * through it whose job is to create the tuple when it is absent.
231
347
  */
232
- function lowerConceptMatcher(matcher) {
233
- let id;
234
- let type;
235
- let name;
236
- const seen = new Set();
237
- for (const entry of matcher.entries) {
238
- if (entry.key !== 'id' && entry.key !== 'type' && entry.key !== 'name') {
239
- throw invalidSyntax(`invalid key in concept clause: ${entry.key} (expected id, type or name)`, entry.range);
240
- }
241
- if (seen.has(entry.key)) {
242
- throw invalidSyntax(`duplicate key in concept clause: ${entry.key}`, entry.range);
243
- }
244
- seen.add(entry.key);
245
- const value = matcherString(entry);
246
- if (value === undefined)
247
- continue;
248
- if (entry.key === 'id')
249
- id = value;
250
- else if (entry.key === 'type')
251
- type = value;
252
- else
253
- name = value;
254
- }
255
- if (id !== undefined) {
256
- if (type !== undefined || name !== undefined) {
257
- throw invalidSyntax('a concept clause cannot combine id with type or name', matcher.range);
258
- }
259
- return { ID: id };
260
- }
261
- if (type !== undefined && name !== undefined)
262
- return { Object: { type, name } };
263
- if (type !== undefined)
264
- return { Type: type };
265
- if (name !== undefined)
266
- return { Name: name };
267
- throw invalidSyntax('a concept clause must carry at least one of id, type or name', matcher.range);
268
- }
269
- function matcherString(entry) {
270
- const value = entry.value;
271
- if (value.kind === 'StringLiteral')
272
- return value.parsed;
273
- if (value.kind === 'NullLiteral')
274
- return undefined;
275
- throw invalidSyntax(`concept clause key ${entry.key} expects a quoted string or null`, value.range);
276
- }
277
- /** A KML endpoint must address exactly one element. */
278
- function requireUniqueConcept(matcher, range) {
279
- if ('ID' in matcher || 'Object' in matcher)
280
- return matcher;
281
- throw invalidSyntax(CONCEPT_IDENTITY_HELP, range);
282
- }
283
- function lowerPropositionMatcher(pattern) {
284
- if (pattern.id) {
285
- if (pattern.id.kind !== 'StringLiteral') {
286
- throw invalidSyntax('a proposition id must be a quoted string', pattern.id.range);
287
- }
288
- return { ID: pattern.id.parsed };
348
+ function requireStructuralTuple(tuple, statement) {
349
+ if (tuple.id) {
350
+ throw invalidSyntax(`${statement} needs a (subject, predicate, object) tuple: (id: ...) only matches an ` +
351
+ 'existing Proposition, and no structure can be created from an id', tuple.range);
352
+ }
353
+ const predicateExpression = tuple.predicate;
354
+ const predicate = lowerPredicate(predicateExpression);
355
+ if (!('Atom' in predicate)) {
356
+ throw invalidSyntax(`${statement} needs one exact predicate; alternation and hop quantifiers are KQL traversal forms`, tuple.predicate.range);
289
357
  }
290
- if (!pattern.subject || !pattern.predicate || !pattern.object) {
291
- throw invalidSyntax('a proposition clause needs a subject, a predicate and an object', pattern.range);
358
+ if ('Variable' in predicate.Atom) {
359
+ throw invalidSyntax(`${statement} needs an exact quoted predicate or :parameter; ?variables are KQL read-pattern syntax`, predicateExpression.range);
292
360
  }
293
361
  return {
294
- Object: {
295
- subject: lowerEndpoint(pattern.subject),
296
- predicate: lowerPredicate(pattern.predicate),
297
- object: lowerEndpoint(pattern.object)
298
- }
362
+ subject: lowerPropositionSubject(tuple.subject),
363
+ predicate: predicate.Atom,
364
+ object: lowerTerm(tuple.object)
299
365
  };
300
366
  }
301
- function lowerEndpoint(endpoint) {
302
- switch (endpoint.kind) {
367
+ function lowerPredicate(expr) {
368
+ const [only] = expr.atoms;
369
+ if (expr.atoms.length === 1 && only && !only.quantifier) {
370
+ return { Atom: lowerPredAtom(only.atom) };
371
+ }
372
+ const path = expr.atoms.map((atom) => ({
373
+ predicate: lowerPredAtom(atom.atom),
374
+ hops: atom.quantifier
375
+ ? { min: atom.quantifier.min, max: atom.quantifier.max ?? null }
376
+ : null
377
+ }));
378
+ return { Path: path };
379
+ }
380
+ function lowerPredAtom(atom) {
381
+ switch (atom.kind) {
382
+ case 'StringLiteral':
383
+ return { Literal: atom.parsed };
384
+ case 'ParameterRef':
385
+ return { Param: paramName(atom.name) };
303
386
  case 'VariableRef':
304
- return { Variable: varName(endpoint.name, endpoint.range) };
305
- case 'ConceptPattern':
306
- // `?s {type: "T"}` binds *and* constrains, which the executable form has
307
- // no term for: an endpoint is either a reference or an inline matcher.
308
- if (endpoint.variable) {
309
- throw invalidSyntax('a proposition endpoint is either a variable or an inline matcher, not both', endpoint.range);
310
- }
311
- return { Concept: lowerConceptMatcher(endpoint.matcher) };
312
- case 'PropositionPattern':
313
- if (endpoint.variable) {
314
- throw invalidSyntax('a nested proposition endpoint cannot bind a variable', endpoint.range);
315
- }
316
- return { Proposition: lowerPropositionMatcher(endpoint) };
317
- }
318
- }
319
- function lowerPredicate(predicate) {
320
- switch (predicate.kind) {
321
- case 'PredicateVariable':
322
- return { Variable: varName(predicate.name, predicate.range) };
323
- case 'PredicateAlternation':
324
- return { Alternative: predicate.predicates.map((p) => p.value) };
325
- case 'PredicateLiteral': {
326
- const hop = predicate.hopRange;
327
- if (!hop)
328
- return { Literal: predicate.value };
329
- const max = hop.max ?? null;
330
- if (max !== null && max < hop.min) {
331
- throw invalidSyntax(`invalid multi-hop predicate: min ${hop.min} cannot be greater than max ${max}`, hop.range);
332
- }
333
- return { MultiHop: { predicate: predicate.value, min: hop.min, max } };
387
+ return { Variable: varName(atom.name, atom.range) };
388
+ }
389
+ }
390
+ function lowerTerm(term) {
391
+ switch (term.kind) {
392
+ case 'VariableRef':
393
+ return { Variable: varName(term.name, term.range) };
394
+ case 'ParameterRef':
395
+ return { Param: paramName(term.name) };
396
+ case 'ObjectPattern':
397
+ return { Match: lowerObjectMatcher(term) };
398
+ case 'PropositionTuple':
399
+ return { Proposition: lowerPropositionMatcher(term) };
400
+ default:
401
+ return { Literal: lowerKipValue(term) };
402
+ }
403
+ }
404
+ /** A Proposition subject is always an Element reference, never a Literal. */
405
+ function lowerPropositionSubject(term) {
406
+ switch (term.kind) {
407
+ case 'StringLiteral':
408
+ case 'NumberLiteral':
409
+ case 'BooleanLiteral':
410
+ case 'NullLiteral':
411
+ throw invalidSyntax('a Proposition subject must be a local Element reference, never a Literal', term.range);
412
+ default:
413
+ return lowerTerm(term);
414
+ }
415
+ }
416
+ function lowerObjectMatcher(pattern) {
417
+ const matcher = {};
418
+ for (const member of pattern.members) {
419
+ if (Object.prototype.hasOwnProperty.call(matcher, member.key)) {
420
+ throw invalidSyntax(`duplicate match field ${member.key}`, member.range);
334
421
  }
422
+ matcher[member.key] = lowerMatchValue(member.value);
423
+ }
424
+ return matcher;
425
+ }
426
+ function lowerMatchValue(expr) {
427
+ switch (expr.kind) {
428
+ case 'VariableRef':
429
+ return { Variable: varName(expr.name, expr.range) };
430
+ case 'ParameterRef':
431
+ return { Param: paramName(expr.name) };
432
+ case 'ArrayLiteral':
433
+ return { Array: expr.elements.map(lowerMatchValue) };
434
+ case 'ObjectPattern':
435
+ return { Match: lowerObjectMatcher(expr) };
436
+ case 'PropositionTuple':
437
+ return { Proposition: lowerPropositionMatcher(expr) };
438
+ default:
439
+ return { Literal: lowerKipValue(expr) };
335
440
  }
336
441
  }
337
442
  // ---------------------------------------------------------------------------
338
- // FILTER
443
+ // Filters
339
444
  // ---------------------------------------------------------------------------
340
445
  function lowerFilter(expr) {
341
446
  switch (expr.kind) {
@@ -351,7 +456,7 @@ function lowerFilter(expr) {
351
456
  }
352
457
  const operator = COMPARISONS.get(expr.operator);
353
458
  if (!operator) {
354
- throw invalidSyntax(`unsupported operator ${expr.operator} in FILTER`, expr.range);
459
+ throw invalidSyntax(`unknown comparison operator ${expr.operator}`, expr.range);
355
460
  }
356
461
  return {
357
462
  Comparison: {
@@ -362,498 +467,1074 @@ function lowerFilter(expr) {
362
467
  };
363
468
  }
364
469
  case 'UnaryExpression':
365
- return { Not: lowerFilter(expr.operand) };
470
+ if (expr.operator === '!') {
471
+ return { Not: lowerFilter(expr.operand) };
472
+ }
473
+ throw invalidSyntax('a filter must be a comparison, a logical combination, a negation or a function call', expr.range);
366
474
  case 'FunctionCallExpr': {
367
- const func = FILTER_FUNCTIONS.get(expr.name);
475
+ const func = FILTER_FUNCTIONS.get(expr.name.toUpperCase());
368
476
  if (!func) {
369
- throw invalidSyntax(`unknown FILTER function ${expr.name}(...): expected CONTAINS, ` +
370
- `STARTS_WITH, ENDS_WITH, REGEX, IN, IS_NULL or IS_NOT_NULL`, expr.range);
477
+ throw invalidSyntax(`${expr.name} is not a KIP filter function`, expr.range);
371
478
  }
372
- const args = expr.args.map(lowerFilterOperand);
373
- checkFilterArity(func, args, expr.range);
374
- return { Function: { func, args } };
479
+ return {
480
+ Function: { func, args: expr.args.map(lowerFilterOperand) }
481
+ };
375
482
  }
483
+ case 'AggregateExpr':
484
+ // An aggregate summarizes a solution set; a filter runs per candidate
485
+ // row, so there is no set for it to summarize yet.
486
+ throw invalidSyntax(`${expr.name} is an aggregate and cannot appear inside FILTER`, expr.range);
376
487
  default:
377
- throw invalidSyntax('FILTER takes a comparison, a logical combination, or a filter function', expr.range);
488
+ throw invalidSyntax(`a filter must be a comparison, a logical combination, a negation or a function call, found ${describeExpression(expr)}`, expr.range);
378
489
  }
379
490
  }
380
- function checkFilterArity(func, args, range) {
381
- switch (func) {
382
- case 'Contains':
383
- case 'StartsWith':
384
- case 'EndsWith':
385
- case 'Regex':
386
- if (args.length !== 2) {
387
- throw invalidSyntax('string filter functions require exactly 2 arguments', range);
388
- }
389
- return;
390
- case 'In':
391
- if (args.length !== 2) {
392
- throw invalidSyntax('IN requires exactly 2 arguments: IN(?expr, [values])', range);
393
- }
394
- if (!(args[1] && 'List' in args[1])) {
395
- throw invalidSyntax('IN requires a literal list as its second argument', range);
491
+ function lowerFilterOperand(expr) {
492
+ switch (expr.kind) {
493
+ case 'VariableRef':
494
+ case 'FieldAccess':
495
+ return { Variable: lowerDotPath(expr) };
496
+ case 'ParameterRef':
497
+ return { Param: paramName(expr.name) };
498
+ case 'ArrayLiteral':
499
+ if (expr.trailingComma) {
500
+ throw invalidSyntax('a filter list does not allow a trailing comma', expr.range);
396
501
  }
397
- return;
398
- case 'IsNull':
399
- case 'IsNotNull':
400
- if (args.length !== 1) {
401
- throw invalidSyntax('IS_NULL and IS_NOT_NULL require exactly 1 argument', range);
502
+ return { List: expr.elements.map(lowerFilterOperand) };
503
+ case 'UnaryExpression':
504
+ if (expr.operator === '-') {
505
+ return { Negate: lowerFilterOperand(expr.operand) };
402
506
  }
507
+ throw invalidSyntax(`expected a filter operand, found ${describeExpression(expr)}`, expr.range);
508
+ case 'AggregateExpr':
509
+ // An aggregate summarizes a solution set; a filter runs per candidate
510
+ // row, so there is no set for it to summarize yet.
511
+ throw invalidSyntax(`${expr.name} is an aggregate and cannot appear inside FILTER`, expr.range);
512
+ case 'FunctionCallExpr':
513
+ case 'BinaryExpression':
514
+ throw invalidSyntax(`expected a filter operand, found ${describeExpression(expr)}`, expr.range);
515
+ default:
516
+ return { Literal: lowerKipValue(expr) };
403
517
  }
404
518
  }
405
- function lowerFilterOperand(expr) {
406
- if (expr.kind === 'VariableRef' || expr.kind === 'DotExpression') {
407
- return { Variable: dotPathVar(expr) };
519
+ // ---------------------------------------------------------------------------
520
+ // KML
521
+ // ---------------------------------------------------------------------------
522
+ function lowerMutate(stmt) {
523
+ if (stmt.clauses.length === 0) {
524
+ throw invalidSyntax('MUTATE requires at least one mutation', stmt.range);
408
525
  }
409
- if (expr.kind === 'ArrayLiteral') {
410
- if (expr.trailingComma) {
411
- throw invalidSyntax('a literal list takes no trailing comma', expr.range);
526
+ const clauses = stmt.clauses.flatMap((clause, i) => lowerMutationClause(clause, i));
527
+ assertUniqueHandles(clauses, stmt.range);
528
+ assertResolvedHandles(clauses, stmt.range);
529
+ return { explicit_transaction: true, clauses };
530
+ }
531
+ /**
532
+ * Handles are block-local names. Two clauses claiming the same handle make
533
+ * every forward reference to it ambiguous, so the whole plan is rejected
534
+ * rather than resolved by position.
535
+ */
536
+ function assertUniqueHandles(clauses, range) {
537
+ const seen = new Set();
538
+ for (const clause of clauses) {
539
+ const handle = handleOf(clause);
540
+ if (handle === null)
541
+ continue;
542
+ if (seen.has(handle)) {
543
+ throw invalidSyntax(`duplicate local handle ?${handle} in one mutation plan`, range);
412
544
  }
413
- return { List: expr.elements.map(lowerKipValue) };
545
+ seen.add(handle);
414
546
  }
415
- return { Literal: lowerKipValue(expr) };
416
547
  }
417
- // ---------------------------------------------------------------------------
418
- // KML
419
- // ---------------------------------------------------------------------------
420
- function lowerUpsert(stmt) {
421
- const items = stmt.blocks.map((block) => block.kind === 'ConceptBlock'
422
- ? { Concept: lowerConceptBlock(block) }
423
- : { Proposition: lowerPropositionBlock(block) });
424
- if (items.length === 0) {
425
- throw invalidSyntax('UPSERT must contain at least one CONCEPT or PROPOSITION block', stmt.range);
426
- }
427
- return { items, metadata: lowerMetadata(stmt.metadata) };
428
- }
429
- function lowerConceptBlock(block) {
430
- const out = {
431
- handle: block.handle ? varName(block.handle, block.range) : null,
432
- concept: requireUniqueConcept(lowerConceptMatcher(block.matcher), block.matcher.range),
433
- set_attributes: block.setAttributes
434
- ? lowerJsonEntries(block.setAttributes.entries)
548
+ function handleOf(clause) {
549
+ if ('CreateConcept' in clause)
550
+ return clause.CreateConcept.handle;
551
+ if ('UpsertConcept' in clause)
552
+ return clause.UpsertConcept.handle;
553
+ if ('CreateEvidence' in clause)
554
+ return clause.CreateEvidence.handle;
555
+ if ('CreateAssertion' in clause)
556
+ return clause.CreateAssertion.handle;
557
+ if ('CreateActivity' in clause)
558
+ return clause.CreateActivity.handle;
559
+ if ('EnsureProposition' in clause)
560
+ return clause.EnsureProposition.handle;
561
+ return null;
562
+ }
563
+ /**
564
+ * Every executable `Handle` must be created by this mutation plan or bound by
565
+ * that clause's WHERE. Parameters remain runtime bindings and are unaffected.
566
+ */
567
+ function assertResolvedHandles(clauses, range) {
568
+ const planHandles = new Set();
569
+ for (const clause of clauses) {
570
+ const handle = handleOf(clause);
571
+ if (handle !== null)
572
+ planHandles.add(handle);
573
+ }
574
+ for (const clause of clauses) {
575
+ const allowed = new Set(planHandles);
576
+ const body = Object.values(clause)[0];
577
+ collectWhereVariables(body.where_clauses, allowed);
578
+ const referenced = new Set();
579
+ collectTaggedHandles(body, referenced);
580
+ for (const handle of referenced) {
581
+ if (!allowed.has(handle)) {
582
+ throw invalidSyntax(`?${handle} is not bound by this command's mutation outputs or WHERE clause`, range);
583
+ }
584
+ }
585
+ }
586
+ }
587
+ function collectWhereVariables(value, out) {
588
+ if (Array.isArray(value)) {
589
+ for (const item of value)
590
+ collectWhereVariables(item, out);
591
+ return;
592
+ }
593
+ if (!value || typeof value !== 'object')
594
+ return;
595
+ const record = value;
596
+ if (typeof record.variable === 'string')
597
+ out.add(record.variable);
598
+ // Pattern terms and predicate atoms use the tagged `{ Variable: name }`
599
+ // shape. Filter operands also use `Variable`, but carry a DotPathVar object
600
+ // rather than a string, so they cannot accidentally introduce a binding.
601
+ if (typeof record.Variable === 'string')
602
+ out.add(record.Variable);
603
+ for (const child of Object.values(record))
604
+ collectWhereVariables(child, out);
605
+ }
606
+ function collectTaggedHandles(value, out) {
607
+ if (Array.isArray(value)) {
608
+ for (const item of value)
609
+ collectTaggedHandles(item, out);
610
+ return;
611
+ }
612
+ if (!value || typeof value !== 'object')
613
+ return;
614
+ const record = value;
615
+ if (typeof record.Handle === 'string')
616
+ out.add(record.Handle);
617
+ for (const child of Object.values(record))
618
+ collectTaggedHandles(child, out);
619
+ }
620
+ /**
621
+ * One source statement may lower to several clauses; `ASSERT` is the case.
622
+ *
623
+ * `seq` is the clause's position in its plan, used only to keep synthetic
624
+ * handles distinct between two handle-less ASSERTs in the same transaction.
625
+ */
626
+ function lowerMutationClause(stmt, seq) {
627
+ switch (stmt.kind) {
628
+ case 'CreateConceptStatement':
629
+ return [{ CreateConcept: lowerCreateConcept(stmt) }];
630
+ case 'UpsertConceptStatement':
631
+ return [{ UpsertConcept: lowerUpsertConcept(stmt) }];
632
+ case 'EnsurePropositionStatement':
633
+ return [{ EnsureProposition: lowerEnsureProposition(stmt) }];
634
+ case 'AssertStatement':
635
+ return lowerAssertSugar(stmt, seq);
636
+ case 'CreateEvidenceStatement':
637
+ return [{ CreateEvidence: lowerRecordCreate(stmt) }];
638
+ case 'CreateAssertionStatement':
639
+ return [{ CreateAssertion: lowerRecordCreate(stmt) }];
640
+ case 'CreateActivityStatement':
641
+ return [{ CreateActivity: lowerRecordCreate(stmt) }];
642
+ case 'UpdateStatement':
643
+ return [{ Update: lowerUpdate(stmt) }];
644
+ case 'RetractAssertionStatement':
645
+ return [
646
+ {
647
+ RetractAssertion: {
648
+ target: lowerElementRef(stmt.target),
649
+ where_clauses: stmt.where ? lowerWhere(stmt.where) : null,
650
+ limit: stmt.limit ? lowerScalar(stmt.limit.value) : null,
651
+ expect_state: stmt.expectState
652
+ ? lowerScalar(stmt.expectState.value)
653
+ : null
654
+ }
655
+ }
656
+ ];
657
+ case 'SupersedeAssertionStatement':
658
+ return [
659
+ {
660
+ SupersedeAssertion: {
661
+ target: lowerElementRef(stmt.target),
662
+ by: lowerElementRef(stmt.by),
663
+ expect_state: stmt.expectState
664
+ ? lowerScalar(stmt.expectState.value)
665
+ : null
666
+ }
667
+ }
668
+ ];
669
+ case 'CorrectEvidenceStatement':
670
+ return [
671
+ {
672
+ CorrectEvidence: {
673
+ target: lowerElementRef(stmt.target),
674
+ by: lowerElementRef(stmt.by),
675
+ expect_state: stmt.expectState
676
+ ? lowerScalar(stmt.expectState.value)
677
+ : null
678
+ }
679
+ }
680
+ ];
681
+ case 'TransitionActivityStatement':
682
+ return [{ TransitionActivity: lowerTransition(stmt) }];
683
+ case 'SetRetentionStatement':
684
+ return [
685
+ {
686
+ SetRetention: {
687
+ target: lowerElementRef(stmt.target),
688
+ values: lowerAssignments(stmt.assignments, null),
689
+ where_clauses: stmt.where ? lowerWhere(stmt.where) : null,
690
+ limit: stmt.limit ? lowerScalar(stmt.limit.value) : null,
691
+ expect_version: stmt.expectVersion
692
+ ? lowerScalar(stmt.expectVersion.value)
693
+ : null
694
+ }
695
+ }
696
+ ];
697
+ case 'ArchiveStatement':
698
+ return [{ Archive: lowerRemoval(stmt) }];
699
+ case 'TombstoneStatement':
700
+ return [{ Tombstone: lowerRemoval(stmt) }];
701
+ case 'PurgeStatement':
702
+ return [{ Purge: lowerPurge(stmt) }];
703
+ case 'MergeConceptStatement':
704
+ return [
705
+ {
706
+ MergeConcept: {
707
+ source: lowerElementRef(stmt.source),
708
+ into: lowerElementRef(stmt.into),
709
+ where_clauses: stmt.where ? lowerWhere(stmt.where) : null,
710
+ expect_version: stmt.expectVersion
711
+ ? lowerScalar(stmt.expectVersion.value)
712
+ : null
713
+ }
714
+ }
715
+ ];
716
+ }
717
+ }
718
+ function lowerCreateConcept(stmt) {
719
+ return {
720
+ handle: varName(stmt.handle.name, stmt.handle.range),
721
+ type: stmt.type ? lowerSymbol(stmt.type.value) : null,
722
+ client_key: stmt.clientKey ? lowerScalar(stmt.clientKey.value) : null,
723
+ name: stmt.name ? lowerScalar(stmt.name.value) : null,
724
+ set_fields: stmt.setFields
725
+ ? lowerAssignments(stmt.setFields.assignments, null)
435
726
  : null,
436
- set_propositions: block.setPropositions
437
- ? block.setPropositions.items.map((item) => ({
438
- predicate: item.predicate,
439
- object: requireIdentityTarget(lowerEndpoint(item.target), item.range),
440
- metadata: lowerMetadata(item.metadata)
441
- }))
727
+ set_attributes: stmt.setAttributes
728
+ ? lowerAssignments(stmt.setAttributes.assignments, null)
442
729
  : null,
443
- metadata: lowerMetadata(block.metadata)
730
+ set_facets: stmt.setFacets.map((f) => lowerFacet(f, null)),
731
+ set_structural: stmt.setStructural
732
+ ? lowerStructural(stmt.setStructural, null)
733
+ : null
444
734
  };
445
- const version = lowerExpectVersion(block);
446
- if (version !== undefined)
447
- out.expect_version = version;
448
- return out;
449
735
  }
450
- function lowerPropositionBlock(block) {
451
- const matcher = lowerPropositionMatcher(block);
452
- requireIdentityProposition(matcher, block.range);
453
- const out = {
454
- handle: block.handle ? varName(block.handle, block.range) : null,
455
- proposition: matcher,
456
- set_attributes: block.setAttributes
457
- ? lowerJsonEntries(block.setAttributes.entries)
736
+ function lowerUpsertConcept(stmt) {
737
+ const match = stmt.match ? lowerObjectMatcher(stmt.match.pattern) : null;
738
+ // Identity for an upsert is `id` or `key`; a name-only match is forbidden
739
+ // because names are mutable grounding state with duplicates allowed, so
740
+ // "the Concept named X" can silently address a different node over time.
741
+ if (match) {
742
+ const fields = Object.keys(match);
743
+ const hasIdentity = fields.includes('id') || fields.includes('key');
744
+ if (!hasIdentity) {
745
+ throw invalidSyntax('UPSERT CONCEPT must match on a stable identity: add {id: ...} or {key: ...} — ' +
746
+ 'name is mutable grounding state and never identifies a Concept', stmt.match.range);
747
+ }
748
+ }
749
+ return {
750
+ handle: varName(stmt.handle.name, stmt.handle.range),
751
+ match,
752
+ expect_version: stmt.expectVersion
753
+ ? lowerScalar(stmt.expectVersion.value)
754
+ : null,
755
+ set_fields: stmt.setFields
756
+ ? lowerAssignments(stmt.setFields.assignments, null)
757
+ : null,
758
+ set_attributes: stmt.setAttributes
759
+ ? lowerAssignments(stmt.setAttributes.assignments, null)
458
760
  : null,
459
- metadata: lowerMetadata(block.metadata)
761
+ set_facets: stmt.setFacets.map((f) => lowerFacet(f, null)),
762
+ unset_attributes: stmt.unsetAttributes
763
+ ? lowerUnsetFields(stmt.unsetAttributes.fields)
764
+ : null,
765
+ unset_facets: stmt.unsetFacets.map((f) => ({
766
+ facet: lowerSymbol(f.facet),
767
+ fields: lowerUnsetFields(f.fields)
768
+ })),
769
+ set_structural: stmt.setStructural
770
+ ? lowerStructural(stmt.setStructural, null)
771
+ : null,
772
+ unset_structural: stmt.unsetStructural
773
+ ? lowerStructuralRemovals(stmt.unsetStructural, null)
774
+ : null
460
775
  };
461
- const version = lowerExpectVersion(block);
462
- if (version !== undefined)
463
- out.expect_version = version;
464
- return out;
465
776
  }
466
- function lowerExpectVersion(block) {
467
- if (!block.expectVersion)
468
- return undefined;
469
- const value = block.expectVersion.value;
470
- if (value.kind !== 'NumberLiteral' || !isIntegerLiteral(value.raw)) {
471
- throw invalidSyntax('EXPECT VERSION takes a non-negative integer', value.range);
777
+ function lowerEnsureProposition(stmt) {
778
+ const triple = requireStructuralTuple(stmt.tuple, 'ENSURE PROPOSITION');
779
+ return {
780
+ handle: stmt.handle ? varName(stmt.handle.name, stmt.handle.range) : null,
781
+ ...triple,
782
+ expect_version: stmt.expectVersion
783
+ ? lowerScalar(stmt.expectVersion.value)
784
+ : null
785
+ };
786
+ }
787
+ function lowerRecordCreate(stmt) {
788
+ const fields = stmt.setFields
789
+ ? lowerAssignments(stmt.setFields.assignments, null)
790
+ : null;
791
+ return {
792
+ handle: varName(stmt.handle.name, stmt.handle.range),
793
+ client_key: stmt.clientKey ? lowerScalar(stmt.clientKey.value) : null,
794
+ set_fields: fields,
795
+ set_facets: stmt.setFacets.map((f) => lowerFacet(f, null)),
796
+ set_structural: stmt.setStructural
797
+ ? lowerStructural(stmt.setStructural, null)
798
+ : null
799
+ };
800
+ }
801
+ /**
802
+ * Desugars `ASSERT` into exactly what the Spec defines it as (§55.1):
803
+ * `ENSURE PROPOSITION` + `CREATE ASSERTION`, plus `SUPERSEDE` when written.
804
+ *
805
+ * Nothing else is fabricated. The sugar exists because recording an
806
+ * attributed claim is the hot path, not because it means anything new.
807
+ */
808
+ function lowerAssertSugar(stmt, seq) {
809
+ const members = new Map();
810
+ for (const entry of stmt.assignments.entries) {
811
+ if (members.has(entry.key)) {
812
+ throw invalidSyntax(`duplicate ASSERT member ${entry.key}`, entry.range);
813
+ }
814
+ members.set(entry.key, entry.value);
815
+ }
816
+ const known = new Set([
817
+ 'by',
818
+ 'mode',
819
+ 'stance',
820
+ 'confidence',
821
+ 'at',
822
+ 'valid',
823
+ 'evidence',
824
+ 'key'
825
+ ]);
826
+ for (const [key, value] of members) {
827
+ if (!known.has(key)) {
828
+ throw invalidSyntax(`${key} is not an ASSERT member; expected one of ${[...known].join(', ')}`, value.range);
829
+ }
472
830
  }
473
- const n = BigInt(value.raw);
474
- if (n < 0n || n > U64_MAX) {
475
- throw invalidSyntax('EXPECT VERSION takes a non-negative integer', value.range);
831
+ // `by` names whose stance this is, and `mode` says how it was arrived at.
832
+ // Neither has a safe default: guessing the actor would forge attribution,
833
+ // and guessing the mode would turn hearsay into observation.
834
+ const by = members.get('by');
835
+ if (!by) {
836
+ throw invalidSyntax('ASSERT requires by: <semantic actor> — an Assertion without an assertor has no epistemic owner', stmt.assignments.range);
476
837
  }
477
- return Number(n);
838
+ const mode = members.get('mode');
839
+ if (!mode) {
840
+ throw invalidSyntax('ASSERT requires mode: one of observed, stated, inferred, predicted, hypothetical, imported', stmt.assignments.range);
841
+ }
842
+ // The Proposition handle is synthesized, so it must collide with neither a
843
+ // user handle nor another ASSERT in the same plan. `#` cannot occur in a KIP
844
+ // identifier, which rules out the first; `seq` is the clause position, which
845
+ // rules out the second — two handle-less ASSERTs in one MUTATE are ordinary
846
+ // input, not a name clash.
847
+ const assertionHandle = stmt.handle
848
+ ? varName(stmt.handle.name, stmt.handle.range)
849
+ : `#assert${seq}`;
850
+ const propositionHandle = `${assertionHandle}#proposition`;
851
+ const triple = requireStructuralTuple(stmt.tuple, 'ASSERT');
852
+ const clauses = [
853
+ {
854
+ EnsureProposition: {
855
+ handle: propositionHandle,
856
+ ...triple,
857
+ expect_version: null
858
+ }
859
+ }
860
+ ];
861
+ const fields = [
862
+ ['proposition', { Handle: propositionHandle }],
863
+ ['asserted_by', lowerMutationValue(by, null)],
864
+ ['mode', lowerMutationValue(mode, null)],
865
+ // The normative expansion carries a stance even when the source omitted
866
+ // one, so the default is materialized here rather than left for the
867
+ // engine to re-derive.
868
+ [
869
+ 'stance',
870
+ members.has('stance')
871
+ ? lowerMutationValue(members.get('stance'), null)
872
+ : { Value: { String: 'support' } }
873
+ ]
874
+ ];
875
+ const optional = [
876
+ ['confidence', 'confidence'],
877
+ ['at', 'asserted_at'],
878
+ ['valid', 'valid_time']
879
+ ];
880
+ for (const [member, field] of optional) {
881
+ const value = members.get(member);
882
+ if (value)
883
+ fields.push([field, lowerMutationValue(value, null)]);
884
+ }
885
+ // `evidence` is a reserved Core *structural* field, not a plain one: the
886
+ // normative desugaring emits `("evidence", ref) {role: "support"}`. An array
887
+ // cites several artifacts, so it becomes one role-qualified edge each.
888
+ const evidenceExpr = members.get('evidence');
889
+ const evidenceEdges = evidenceExpr === undefined
890
+ ? []
891
+ : (evidenceExpr.kind === 'ArrayLiteral'
892
+ ? evidenceExpr.elements
893
+ : [evidenceExpr]).map((ref) => ({
894
+ field: { Name: 'evidence' },
895
+ value: lowerMutationValue(ref, null),
896
+ options: { role: { Value: { String: 'support' } } }
897
+ }));
898
+ const clientKeyExpr = members.get('key');
899
+ clauses.push({
900
+ CreateAssertion: {
901
+ handle: assertionHandle,
902
+ client_key: clientKeyExpr ? lowerScalarExpression(clientKeyExpr) : null,
903
+ set_fields: fields,
904
+ set_facets: [],
905
+ set_structural: evidenceEdges.length > 0 ? evidenceEdges : null
906
+ }
907
+ });
908
+ if (stmt.superseding) {
909
+ clauses.push({
910
+ SupersedeAssertion: {
911
+ target: lowerElementRef(stmt.superseding),
912
+ by: { Handle: assertionHandle },
913
+ expect_state: null
914
+ }
915
+ });
916
+ }
917
+ return clauses;
478
918
  }
479
- /** A KML target must address exactly one existing element. */
480
- function requireIdentityTarget(term, range) {
481
- if ('Variable' in term)
482
- return term;
483
- if ('Concept' in term) {
484
- requireUniqueConcept(term.Concept, range);
485
- return term;
919
+ function lowerTransition(stmt) {
920
+ let setFields = null;
921
+ let setStructural = null;
922
+ for (const clause of stmt.finalize) {
923
+ if (clause.kind === 'SetFieldsClause') {
924
+ if (setFields) {
925
+ throw invalidSyntax('duplicate SET FIELDS clause', clause.range);
926
+ }
927
+ setFields = lowerAssignments(clause.assignments, null);
928
+ }
929
+ else {
930
+ if (setStructural) {
931
+ throw invalidSyntax('duplicate SET STRUCTURAL clause', clause.range);
932
+ }
933
+ setStructural = lowerStructural(clause, null);
934
+ }
486
935
  }
487
- requireIdentityProposition(term.Proposition, range);
488
- return term;
936
+ return {
937
+ target: lowerElementRef(stmt.target),
938
+ to: lowerScalar(stmt.to),
939
+ set_fields: setFields,
940
+ set_structural: setStructural,
941
+ expect_state: stmt.expectState ? lowerScalar(stmt.expectState.value) : null
942
+ };
489
943
  }
490
- function requireIdentityProposition(matcher, range) {
491
- if ('ID' in matcher)
492
- return;
493
- if (!('Literal' in matcher.Object.predicate)) {
494
- throw invalidSyntax(PROPOSITION_IDENTITY_HELP, range);
944
+ function lowerRemoval(stmt) {
945
+ return {
946
+ target: lowerElementRef(stmt.target),
947
+ where_clauses: stmt.where ? lowerWhere(stmt.where) : null,
948
+ limit: stmt.limit ? lowerScalar(stmt.limit.value) : null,
949
+ expect_state: stmt.expectState ? lowerScalar(stmt.expectState.value) : null
950
+ };
951
+ }
952
+ function lowerPurge(stmt) {
953
+ if (stmt.confirm.parsed !== 'PURGE') {
954
+ throw invalidSyntax('PURGE must be confirmed with the exact literal "PURGE"', stmt.confirm.range);
495
955
  }
496
- requireIdentityTarget(matcher.Object.subject, range);
497
- requireIdentityTarget(matcher.Object.object, range);
956
+ return {
957
+ target: lowerElementRef(stmt.target),
958
+ where_clauses: stmt.where ? lowerWhere(stmt.where) : null,
959
+ limit: stmt.limit ? lowerScalar(stmt.limit.value) : null,
960
+ reference_policy: stmt.referencePolicy
961
+ ? lowerScalar(stmt.referencePolicy)
962
+ : null,
963
+ confirm: 'PURGE'
964
+ };
498
965
  }
966
+ // ---------------------------------------------------------------------------
967
+ // UPDATE
968
+ // ---------------------------------------------------------------------------
499
969
  function lowerUpdate(stmt) {
500
- const target = varName(stmt.target, stmt.range);
501
- const set_attributes = lowerUpdateEntries(stmt.setAttributes, target);
502
- const set_metadata = lowerUpdateEntries(stmt.setMetadata, target);
503
- if (!set_attributes && !set_metadata) {
504
- throw invalidSyntax('UPDATE needs at least one SET ATTRIBUTES or SET METADATA block', stmt.range);
970
+ if (stmt.actions.length === 0) {
971
+ throw invalidSyntax('UPDATE requires at least one SET or UNSET action', stmt.range);
505
972
  }
973
+ const target = lowerElementRef(stmt.target);
974
+ const targetVar = 'Handle' in target ? target.Handle : null;
975
+ const kind = targetVar && stmt.where ? boundKindOf(targetVar, stmt.where.patterns) : null;
976
+ const actions = stmt.actions.map((action) => lowerUpdateAction(action, targetVar, kind));
506
977
  return {
507
978
  target,
508
- set_attributes,
509
- set_metadata,
510
- where_clauses: lowerWhere(stmt.where),
511
- limit: lowerLimit(stmt.limit)
979
+ expect_version: stmt.expectVersion
980
+ ? lowerScalar(stmt.expectVersion.value)
981
+ : null,
982
+ actions,
983
+ where_clauses: stmt.where ? lowerWhere(stmt.where) : null,
984
+ limit: stmt.limit ? lowerScalar(stmt.limit.value) : null
512
985
  };
513
986
  }
514
- function lowerUpdateEntries(block, target) {
515
- if (!block)
516
- return null;
517
- if (block.entries.length === 0) {
518
- throw invalidSyntax('an UPDATE SET block must contain at least one `key: value` pair', block.range);
987
+ function boundKindOf(variable, patterns) {
988
+ for (const pattern of patterns) {
989
+ switch (pattern.kind) {
990
+ case 'AssertionPattern':
991
+ if (varName(pattern.variable.name, pattern.variable.range) === variable) {
992
+ return 'assertion';
993
+ }
994
+ break;
995
+ case 'EvidencePattern':
996
+ if (varName(pattern.variable.name, pattern.variable.range) === variable) {
997
+ return 'evidence';
998
+ }
999
+ break;
1000
+ case 'ActivityPattern':
1001
+ if (varName(pattern.variable.name, pattern.variable.range) === variable) {
1002
+ return 'activity';
1003
+ }
1004
+ break;
1005
+ case 'ConceptPattern':
1006
+ if (varName(pattern.variable.name, pattern.variable.range) === variable) {
1007
+ return 'concept';
1008
+ }
1009
+ break;
1010
+ case 'PropositionPattern':
1011
+ if (pattern.variable &&
1012
+ varName(pattern.variable.name, pattern.variable.range) === variable) {
1013
+ return 'proposition';
1014
+ }
1015
+ break;
1016
+ case 'NotClause':
1017
+ case 'OptionalClause':
1018
+ case 'UnionClause': {
1019
+ const nested = boundKindOf(variable, pattern.patterns);
1020
+ if (nested)
1021
+ return nested;
1022
+ break;
1023
+ }
1024
+ }
519
1025
  }
520
- const out = [];
1026
+ return null;
1027
+ }
1028
+ function lowerUpdateAction(action, targetVar, kind) {
1029
+ switch (action.kind) {
1030
+ case 'SetFieldsClause': {
1031
+ const assignments = lowerAssignments(action.assignments, targetVar);
1032
+ for (const entry of action.assignments.entries) {
1033
+ guardImmutableField(entry.key, kind, entry.range);
1034
+ }
1035
+ return { SetFields: assignments };
1036
+ }
1037
+ case 'SetAttributesClause': {
1038
+ const assignments = lowerAssignments(action.assignments, targetVar);
1039
+ for (const entry of action.assignments.entries) {
1040
+ guardProtectedField(entry.key, entry.range);
1041
+ }
1042
+ return { SetAttributes: assignments };
1043
+ }
1044
+ case 'SetFacetClause':
1045
+ return { SetFacet: lowerFacet(action, targetVar) };
1046
+ case 'UnsetAttributesClause':
1047
+ return { UnsetAttributes: lowerUnsetFields(action.fields) };
1048
+ case 'UnsetFacetClause':
1049
+ return {
1050
+ UnsetFacet: {
1051
+ facet: lowerSymbol(action.facet),
1052
+ fields: lowerUnsetFields(action.fields)
1053
+ }
1054
+ };
1055
+ case 'SetStructuralClause':
1056
+ guardStructuralMutation('SET STRUCTURAL', kind, action.range);
1057
+ return { SetStructural: lowerStructural(action, targetVar) };
1058
+ case 'UnsetStructuralClause':
1059
+ guardStructuralMutation('UNSET STRUCTURAL', kind, action.range);
1060
+ return { UnsetStructural: lowerStructuralRemovals(action, targetVar) };
1061
+ }
1062
+ }
1063
+ /**
1064
+ * Structural mutation reaches mutable Concept topology only (Spec §17.5).
1065
+ * Record kinds keep their topology: an Assertion's citations and an
1066
+ * Evidence's lineage are immutable payload, a Proposition has no structural
1067
+ * fields, and a pending Activity finalizes through TRANSITION ACTIVITY.
1068
+ */
1069
+ function guardStructuralMutation(verb, kind, range) {
1070
+ switch (kind) {
1071
+ case 'assertion':
1072
+ throw invalidSyntax(`${verb} cannot change an Assertion's citations: they are immutable payload — record a new Assertion with SUPERSEDING`, range);
1073
+ case 'evidence':
1074
+ throw invalidSyntax(`${verb} cannot change Evidence topology: correct it with CORRECT EVIDENCE :old BY :new`, range);
1075
+ case 'proposition':
1076
+ throw invalidSyntax(`${verb} has no target on a Proposition: a Proposition is its tuple and carries no structural fields`, range);
1077
+ case 'activity':
1078
+ throw invalidSyntax(`${verb} cannot change Activity topology: finalize a pending Activity with TRANSITION ACTIVITY ... SET STRUCTURAL; a terminal Activity is immutable`, range);
1079
+ default:
1080
+ return;
1081
+ }
1082
+ }
1083
+ /** Engine-owned state is never author-writable, whatever the element kind. */
1084
+ function guardProtectedField(field, range) {
1085
+ if (PROTECTED_FIELDS.has(field)) {
1086
+ throw invalidSyntax(`${field} is engine-maintained state and cannot be written by a mutation`, range);
1087
+ }
1088
+ }
1089
+ function guardImmutableField(field, kind, range) {
1090
+ guardProtectedField(field, range);
1091
+ if (kind === 'assertion' && ASSERTION_IMMUTABLE.has(field)) {
1092
+ throw invalidSyntax(`${field} is immutable Assertion payload: record the change as a new Assertion with SUPERSEDING, ` +
1093
+ 'never by rewriting the old one', range);
1094
+ }
1095
+ if (kind === 'evidence' && EVIDENCE_IMMUTABLE.has(field)) {
1096
+ throw invalidSyntax(`${field} is immutable Evidence payload: correct it with CORRECT EVIDENCE :old BY :new`, range);
1097
+ }
1098
+ if (kind === 'proposition' && PROPOSITION_IMMUTABLE.has(field)) {
1099
+ throw invalidSyntax(`${field} is part of the immutable Proposition tuple: a different tuple is a different Proposition`, range);
1100
+ }
1101
+ }
1102
+ // ---------------------------------------------------------------------------
1103
+ // Assignments, facets, structural edges
1104
+ // ---------------------------------------------------------------------------
1105
+ function lowerAssignments(object, targetVar) {
521
1106
  const seen = new Set();
522
- for (const entry of block.entries) {
1107
+ const out = [];
1108
+ for (const entry of object.entries) {
1109
+ guardProtectedField(entry.key, entry.range);
523
1110
  if (seen.has(entry.key)) {
524
- throw invalidSyntax(`duplicate key in object (keys must be unique): ${entry.key}`, entry.range);
1111
+ throw invalidSyntax(`duplicate assignment for ${entry.key}`, entry.range);
525
1112
  }
526
1113
  seen.add(entry.key);
527
- out.push([entry.key, lowerUpdateValue(entry.value, target, entry.key)]);
1114
+ out.push([entry.key, lowerMutationValue(entry.value, targetVar)]);
528
1115
  }
529
1116
  return out;
530
1117
  }
531
- function lowerUpdateValue(expr, target, key) {
532
- if (expr.kind === 'FunctionCallExpr' && UPDATE_FUNCTIONS.has(expr.name)) {
533
- const value = lowerUpdateExpr(expr);
534
- checkUpdateExprTargets(value, target, key);
535
- return { Expr: value };
1118
+ function lowerFacet(clause, targetVar) {
1119
+ return {
1120
+ facet: lowerSymbol(clause.facet),
1121
+ values: lowerAssignments(clause.assignments, targetVar)
1122
+ };
1123
+ }
1124
+ function lowerStructural(clause, targetVar) {
1125
+ return clause.assignments.map((assignment) => ({
1126
+ field: lowerSymbol(assignment.field),
1127
+ value: lowerMutationValue(assignment.value, targetVar),
1128
+ options: assignment.options ? lowerBoundObject(assignment.options) : null
1129
+ }));
1130
+ }
1131
+ function lowerStructuralRemovals(clause, targetVar) {
1132
+ if (clause.removals.length === 0) {
1133
+ throw invalidSyntax('UNSET STRUCTURAL removes named references; list at least one (field, target)', clause.range);
536
1134
  }
537
- return { Json: lowerJson(expr) };
1135
+ return clause.removals.map((removal) => ({
1136
+ field: lowerSymbol(removal.field),
1137
+ value: lowerMutationValue(removal.value, targetVar)
1138
+ }));
538
1139
  }
539
- function lowerUpdateExpr(expr) {
540
- if (expr.kind === 'FunctionCallExpr') {
541
- const func = UPDATE_FUNCTIONS.get(expr.name);
542
- if (!func) {
543
- throw invalidSyntax(`unknown UPDATE function ${expr.name}(...): expected ADD, MUL, CLAMP or COALESCE`, expr.range);
544
- }
545
- const expected = UPDATE_ARITY[func];
546
- if (expr.args.length !== expected) {
547
- throw invalidSyntax(`${expr.name} requires exactly ${expected} arguments, got ${expr.args.length}`, expr.range);
1140
+ function lowerUnsetFields(fields) {
1141
+ const seen = new Set();
1142
+ for (const field of fields) {
1143
+ if (seen.has(field.name)) {
1144
+ throw invalidSyntax(`duplicate field ${field.name}`, field.range);
548
1145
  }
549
- return { Function: { func, args: expr.args.map(lowerUpdateExpr) } };
1146
+ guardProtectedField(field.name, field.range);
1147
+ seen.add(field.name);
550
1148
  }
551
- if (expr.kind === 'VariableRef' || expr.kind === 'DotExpression') {
552
- return { Variable: dotPathVar(expr) };
1149
+ return [...seen];
1150
+ }
1151
+ function lowerMutationValue(expr, targetVar) {
1152
+ if (expr.kind === 'FunctionCallExpr') {
1153
+ return { Expr: lowerUpdateExpr(expr, targetVar) };
553
1154
  }
554
- if (expr.kind === 'NumberLiteral') {
555
- return { Number: numberValue(expr.value, expr.raw, expr.range) };
1155
+ if (expr.kind === 'AggregateExpr') {
1156
+ throw invalidSyntax(`${expr.name} is an aggregate and cannot appear in an assignment`, expr.range);
556
1157
  }
557
- throw invalidSyntax('an UPDATE expression operand is a number, a ?target dot-path, or a nested expression', expr.range);
1158
+ return lowerBoundValue(expr, targetVar);
558
1159
  }
559
1160
  /**
560
- * An UPDATE expression may only read the element it is updating.
561
- *
562
- * Reading another variable would make the new value depend on which row of the
563
- * join the engine happened to visit, so a bulk UPDATE would stop being
564
- * deterministic and order-independent.
1161
+ * Lowers a `data_value`, keeping structure only where something still needs
1162
+ * binding. A wholly literal subtree collapses to one `Value`, so an engine
1163
+ * that has nothing to substitute never walks a binding tree.
565
1164
  */
566
- function checkUpdateExprTargets(expr, target, key) {
567
- if ('Variable' in expr) {
568
- if (expr.Variable.var !== target) {
569
- throw invalidSyntax(`UPDATE expression for \`${key}\` reads ?${expr.Variable.var}, but ` +
570
- `operands may only use dot-notation paths on the UPDATE target ?${target}`);
571
- }
572
- return;
573
- }
574
- if ('Function' in expr) {
575
- for (const arg of expr.Function.args) {
576
- checkUpdateExprTargets(arg, target, key);
1165
+ function lowerBoundValue(expr, targetVar) {
1166
+ switch (expr.kind) {
1167
+ case 'ParameterRef':
1168
+ return { Param: paramName(expr.name) };
1169
+ case 'VariableRef':
1170
+ return { Handle: varName(expr.name, expr.range) };
1171
+ case 'FieldAccess': {
1172
+ const path = lowerDotPath(expr);
1173
+ guardOwnField(path, targetVar, expr.range);
1174
+ return { Variable: path };
577
1175
  }
1176
+ case 'ArrayLiteral':
1177
+ return isFullyLiteral(expr)
1178
+ ? { Value: lowerKipValue(expr) }
1179
+ : { Array: expr.elements.map((e) => lowerBoundValue(e, targetVar)) };
1180
+ case 'ObjectLiteral':
1181
+ return isFullyLiteral(expr)
1182
+ ? { Value: lowerKipValue(expr) }
1183
+ : {
1184
+ Object: expr.entries.map((e) => [e.key, lowerBoundValue(e.value, targetVar)])
1185
+ };
1186
+ default:
1187
+ return { Value: lowerKipValue(expr) };
578
1188
  }
579
1189
  }
580
- function lowerMerge(stmt) {
581
- return {
582
- source: varName(stmt.source, stmt.range),
583
- target: varName(stmt.target, stmt.range),
584
- where_clauses: lowerWhere(stmt.where)
585
- };
1190
+ /** True when nothing in the subtree needs binding at execution time. */
1191
+ function isFullyLiteral(expr) {
1192
+ switch (expr.kind) {
1193
+ case 'StringLiteral':
1194
+ case 'NumberLiteral':
1195
+ case 'BooleanLiteral':
1196
+ case 'NullLiteral':
1197
+ return true;
1198
+ case 'ArrayLiteral':
1199
+ return expr.elements.every(isFullyLiteral);
1200
+ case 'ObjectLiteral':
1201
+ return expr.entries.every((e) => isFullyLiteral(e.value));
1202
+ case 'UnaryExpression':
1203
+ return expr.operator === '-' && isFullyLiteral(expr.operand);
1204
+ default:
1205
+ return false;
1206
+ }
586
1207
  }
587
- function lowerDelete(stmt) {
588
- const target = varName(stmt.target, stmt.range);
589
- const where_clauses = lowerWhere(stmt.where);
590
- switch (stmt.deleteType) {
591
- case 'ATTRIBUTES':
592
- return {
593
- DeleteAttributes: {
594
- attributes: requireKeys(stmt.keys, 'ATTRIBUTES', stmt.range),
595
- target,
596
- where_clauses
597
- }
598
- };
599
- case 'METADATA':
1208
+ function lowerUpdateExpr(expr, targetVar) {
1209
+ switch (expr.kind) {
1210
+ case 'FunctionCallExpr': {
1211
+ const func = UPDATE_FUNCTIONS.get(expr.name.toUpperCase());
1212
+ if (!func) {
1213
+ throw invalidSyntax(`${expr.name} is not a KIP update function; expected ADD, MUL, CLAMP or COALESCE`, expr.range);
1214
+ }
1215
+ const arity = UPDATE_ARITY[func];
1216
+ if (expr.args.length !== arity) {
1217
+ throw invalidSyntax(`${expr.name} takes ${arity} arguments, found ${expr.args.length}`, expr.range);
1218
+ }
600
1219
  return {
601
- DeleteMetadata: {
602
- keys: requireKeys(stmt.keys, 'METADATA', stmt.range),
603
- target,
604
- where_clauses
1220
+ Function: {
1221
+ func,
1222
+ args: expr.args.map((arg) => lowerUpdateExpr(arg, targetVar))
605
1223
  }
606
1224
  };
607
- case 'PROPOSITIONS':
608
- return { DeletePropositions: { target, where_clauses } };
609
- case 'CONCEPT':
610
- if (!stmt.detach) {
611
- throw invalidSyntax('DELETE CONCEPT requires DETACH: removing a concept also removes ' +
612
- 'every proposition attached to it', stmt.range);
1225
+ }
1226
+ case 'ParameterRef':
1227
+ return { Param: paramName(expr.name) };
1228
+ case 'NumberLiteral':
1229
+ return { Number: expr.value };
1230
+ case 'UnaryExpression':
1231
+ if (expr.operator === '-' && expr.operand.kind === 'NumberLiteral') {
1232
+ return { Number: -expr.operand.value };
613
1233
  }
614
- return { DeleteConcept: { target, where_clauses } };
1234
+ throw invalidSyntax(`expected a number, a parameter, the target's own field or a registered function, found ${describeExpression(expr)}`, expr.range);
1235
+ case 'VariableRef':
1236
+ case 'FieldAccess': {
1237
+ const path = lowerDotPath(expr);
1238
+ guardOwnField(path, targetVar, expr.range);
1239
+ return { Variable: path };
1240
+ }
1241
+ default:
1242
+ throw invalidSyntax(`expected a number, a parameter, the target's own field or a registered function, found ${describeExpression(expr)}`, expr.range);
615
1243
  }
616
1244
  }
617
- function requireKeys(keys, what, range) {
618
- if (!keys || keys.length === 0) {
619
- throw invalidSyntax(`DELETE ${what} needs at least one key`, range);
1245
+ /**
1246
+ * An update expression may read only the element being updated.
1247
+ *
1248
+ * Reading another variable would make the result depend on a join the
1249
+ * statement never declared, so each matched element must be computable from
1250
+ * its own row.
1251
+ */
1252
+ function guardOwnField(path, targetVar, range) {
1253
+ if (targetVar !== null && path.var !== targetVar) {
1254
+ throw invalidSyntax(`an update expression may read only the target ?${targetVar}, found ?${path.var}`, range);
620
1255
  }
621
- return keys;
622
1256
  }
623
1257
  // ---------------------------------------------------------------------------
624
1258
  // META
625
1259
  // ---------------------------------------------------------------------------
626
- function lowerDescribe(stmt) {
627
- switch (stmt.describeType) {
628
- case 'PRIMER':
629
- return 'Primer';
630
- case 'DOMAINS':
631
- return 'Domains';
632
- case 'CONCEPT_TYPES':
1260
+ function lowerMeta(stmt) {
1261
+ switch (stmt.kind) {
1262
+ case 'DescribeStatement':
1263
+ return { Describe: lowerDescribe(stmt) };
1264
+ case 'ListStatement':
1265
+ return { List: lowerList(stmt) };
1266
+ case 'SearchStatement':
1267
+ return { Search: lowerSearch(stmt) };
1268
+ case 'VerifyStatement':
633
1269
  return {
634
- ConceptTypes: {
635
- limit: lowerLimit(stmt.limit),
636
- cursor: lowerCursor(stmt.cursor)
1270
+ Verify: {
1271
+ target: VERIFY_TARGETS[stmt.target],
1272
+ value: lowerScalar(stmt.value)
637
1273
  }
638
1274
  };
639
- case 'PROPOSITION_TYPES':
1275
+ case 'ValidateStatement':
640
1276
  return {
641
- PropositionTypes: {
642
- limit: lowerLimit(stmt.limit),
643
- cursor: lowerCursor(stmt.cursor)
1277
+ Validate: {
1278
+ target: VALIDATE_TARGETS[stmt.target],
1279
+ value: lowerScalar(stmt.value),
1280
+ options: stmt.options ? lowerBoundObject(stmt.options) : null
644
1281
  }
645
1282
  };
646
- case 'CONCEPT_TYPE':
647
- return { ConceptType: describeName(stmt) };
648
- case 'PROPOSITION_TYPE':
649
- return { PropositionType: describeName(stmt) };
1283
+ case 'PreviewStatement':
1284
+ return {
1285
+ Preview: stmt.target === 'KML'
1286
+ ? { Kml: lowerScalar(stmt.value) }
1287
+ : {
1288
+ ImportCapsule: {
1289
+ capsule: lowerScalar(stmt.value),
1290
+ into: lowerScalar(stmt.into)
1291
+ }
1292
+ }
1293
+ };
1294
+ case 'HistoryStatement':
1295
+ return { History: lowerHistory(stmt) };
1296
+ case 'ChangesStatement':
1297
+ return {
1298
+ Changes: stmt.mode === 'SINCE'
1299
+ ? {
1300
+ Since: {
1301
+ cursor: lowerScalar(stmt.value),
1302
+ limit: stmt.limit ? lowerScalar(stmt.limit.value) : null
1303
+ }
1304
+ }
1305
+ : {
1306
+ AfterSeq: {
1307
+ seq: lowerScalar(stmt.value),
1308
+ limit: stmt.limit ? lowerScalar(stmt.limit.value) : null
1309
+ }
1310
+ }
1311
+ };
1312
+ case 'SnapshotStatement':
1313
+ return { Snapshot: { as_of: stmt.asOf ? lowerAsOf(stmt.asOf) : null } };
1314
+ case 'ExportCapsuleStatement':
1315
+ return { ExportCapsule: lowerExport(stmt) };
1316
+ default:
1317
+ throw invalidSyntax(`${stmt.kind} is not an executable KIP command`, stmt.range);
650
1318
  }
651
1319
  }
652
- function describeName(stmt) {
653
- const value = stmt.typeNameValue;
654
- if (value && value.kind !== 'StringLiteral') {
655
- throw invalidSyntax('DESCRIBE takes a quoted type name', value.range);
656
- }
657
- if (!stmt.typeName) {
658
- throw invalidSyntax('DESCRIBE takes a quoted type name', stmt.range);
1320
+ const VERIFY_TARGETS = {
1321
+ CAPSULE: 'Capsule',
1322
+ SCHEMA_PACKAGE: 'SchemaPackage',
1323
+ RECEIPT: 'Receipt',
1324
+ BLOB: 'Blob',
1325
+ CHECKPOINT: 'Checkpoint'
1326
+ };
1327
+ const VALIDATE_TARGETS = {
1328
+ KQL: 'Kql',
1329
+ KML: 'Kml',
1330
+ CAPSULE: 'Capsule',
1331
+ SCHEMA_PACKAGE: 'SchemaPackage',
1332
+ IMPORT_PLAN: 'ImportPlan'
1333
+ };
1334
+ const LIST_TARGETS = {
1335
+ SPACES: 'Spaces',
1336
+ SCHEMA_PACKAGES: 'SchemaPackages',
1337
+ TYPES: 'Types',
1338
+ PREDICATES: 'Predicates',
1339
+ FACETS: 'Facets',
1340
+ STRUCTURAL_FIELDS: 'StructuralFields',
1341
+ EPISTEMIC_POLICIES: 'EpistemicPolicies'
1342
+ };
1343
+ const SEARCH_TARGETS = {
1344
+ CONCEPT: 'Concept',
1345
+ PROPOSITION: 'Proposition',
1346
+ ASSERTION: 'Assertion',
1347
+ EVIDENCE: 'Evidence',
1348
+ ACTIVITY: 'Activity',
1349
+ COGNITION: 'Cognition'
1350
+ };
1351
+ function lowerDescribe(stmt) {
1352
+ const value = () => {
1353
+ if (!stmt.value) {
1354
+ throw invalidSyntax(`DESCRIBE ${stmt.target} requires an operand`, stmt.range);
1355
+ }
1356
+ return lowerScalar(stmt.value);
1357
+ };
1358
+ switch (stmt.target) {
1359
+ case 'PRIMER':
1360
+ return { Primer: { mode: stmt.mode ? lowerScalar(stmt.mode) : null } };
1361
+ case 'PROTOCOL':
1362
+ return 'Protocol';
1363
+ case 'EXECUTION_CONTEXT':
1364
+ return 'ExecutionContext';
1365
+ case 'CAPABILITIES':
1366
+ return 'Capabilities';
1367
+ case 'SPACE':
1368
+ return { Space: { value: stmt.value ? lowerScalar(stmt.value) : null } };
1369
+ case 'SCHEMA_ENVIRONMENT':
1370
+ return {
1371
+ SchemaEnvironment: { as_of: stmt.asOf ? lowerAsOf(stmt.asOf) : null }
1372
+ };
1373
+ case 'PACKAGE':
1374
+ return { Package: value() };
1375
+ case 'TYPE':
1376
+ return { Type: value() };
1377
+ case 'PREDICATE':
1378
+ return { Predicate: value() };
1379
+ case 'FACET':
1380
+ return { Facet: value() };
1381
+ case 'STRUCTURAL_FIELD':
1382
+ return { StructuralField: value() };
1383
+ case 'COMPATIBILITY':
1384
+ if (!stmt.from || !stmt.to) {
1385
+ throw invalidSyntax('DESCRIBE COMPATIBILITY requires FROM and TO', stmt.range);
1386
+ }
1387
+ return {
1388
+ Compatibility: { from: lowerScalar(stmt.from), to: lowerScalar(stmt.to) }
1389
+ };
1390
+ case 'ERROR':
1391
+ return { Error: value() };
1392
+ case 'TRANSACTION':
1393
+ return { Transaction: value() };
1394
+ case 'TRANSACTION_BY_IDEMPOTENCY_KEY':
1395
+ return { TransactionByIdempotencyKey: value() };
1396
+ case 'SNAPSHOT':
1397
+ return { Snapshot: { as_of: stmt.asOf ? lowerAsOf(stmt.asOf) : null } };
1398
+ case 'CAPSULE':
1399
+ return { Capsule: value() };
1400
+ case 'EPISTEMIC_POLICY':
1401
+ return {
1402
+ EpistemicPolicy: { value: stmt.value ? lowerScalar(stmt.value) : null }
1403
+ };
1404
+ case 'PROJECTION_CAPABILITY':
1405
+ return 'ProjectionCapability';
1406
+ case 'TRUST':
1407
+ return { Trust: { value: stmt.value ? lowerScalar(stmt.value) : null } };
1408
+ case 'ACCESS':
1409
+ return {
1410
+ Access: { with: stmt.with ? lowerBoundObject(stmt.with) : null }
1411
+ };
659
1412
  }
660
- return stmt.typeName;
661
1413
  }
662
- function lowerSearch(stmt) {
663
- const out = {
664
- target: stmt.searchTarget === 'PROPOSITION' ? 'Proposition' : 'Concept',
665
- term: requireLiteralString(stmt.termValue, stmt.term, 'SEARCH term', stmt.range),
666
- in_type: stmt.withType === undefined
667
- ? null
668
- : requireLiteralString(stmt.withTypeValue, stmt.withType, 'WITH TYPE', stmt.range),
669
- limit: lowerLimit(stmt.limit)
1414
+ function lowerList(stmt) {
1415
+ return {
1416
+ target: LIST_TARGETS[stmt.target],
1417
+ status: stmt.status ? lowerScalar(stmt.status) : null,
1418
+ limit: stmt.limit ? lowerScalar(stmt.limit.value) : null,
1419
+ cursor: stmt.cursor ? lowerScalar(stmt.cursor.value) : null
670
1420
  };
671
- if (stmt.mode !== undefined) {
672
- const raw = requireLiteralString(stmt.modeValue, stmt.mode, 'MODE', stmt.range);
673
- const mode = searchMode(raw);
674
- if (!mode) {
675
- throw invalidSyntax(`invalid SEARCH mode: ${JSON.stringify(raw)}, expected "keyword", "semantic", or "hybrid"`, stmt.modeValue?.range ?? stmt.range);
676
- }
677
- out.mode = mode;
678
- }
679
- if (stmt.threshold) {
680
- const value = stmt.threshold.value;
681
- if (value.kind !== 'NumberLiteral') {
682
- throw invalidSyntax('THRESHOLD takes a number', value.range);
683
- }
684
- if (value.value < 0 || value.value > 1) {
685
- throw invalidSyntax(`THRESHOLD must be between 0.0 and 1.0, got ${value.value}`, value.range);
686
- }
687
- // `-0` and `0` are the same threshold; the wire form carries only `0`.
688
- out.threshold = value.value === 0 ? 0 : value.value;
689
- }
690
- return out;
691
1421
  }
692
- function searchMode(raw) {
693
- switch (raw.toLowerCase()) {
694
- case 'keyword':
695
- return 'Keyword';
696
- case 'semantic':
697
- return 'Semantic';
698
- case 'hybrid':
699
- return 'Hybrid';
700
- default:
701
- return undefined;
702
- }
1422
+ function lowerSearch(stmt) {
1423
+ return {
1424
+ target: SEARCH_TARGETS[stmt.searchKind],
1425
+ term: lowerScalar(stmt.term),
1426
+ with_type: stmt.withType ? lowerScalar(stmt.withType) : null,
1427
+ with_predicate: stmt.withPredicate ? lowerScalar(stmt.withPredicate) : null,
1428
+ mode: stmt.mode ? lowerScalar(stmt.mode) : null,
1429
+ threshold: stmt.threshold ? lowerScalar(stmt.threshold) : null,
1430
+ as_of_seq: stmt.asOfSeq ? lowerScalar(stmt.asOfSeq) : null,
1431
+ limit: stmt.limit ? lowerScalar(stmt.limit.value) : null,
1432
+ cursor: stmt.cursor ? lowerScalar(stmt.cursor.value) : null
1433
+ };
703
1434
  }
704
- function requireLiteralString(node, value, what, range) {
705
- if (node && node.kind !== 'StringLiteral') {
706
- throw invalidSyntax(`${what} must be a quoted string`, node.range);
1435
+ function lowerHistory(stmt) {
1436
+ const paging = {
1437
+ from_seq: stmt.fromSeq ? lowerScalar(stmt.fromSeq) : null,
1438
+ to_seq: stmt.toSeq ? lowerScalar(stmt.toSeq) : null,
1439
+ limit: stmt.limit ? lowerScalar(stmt.limit.value) : null,
1440
+ cursor: stmt.cursor ? lowerScalar(stmt.cursor.value) : null
1441
+ };
1442
+ if (stmt.target === 'SPACE') {
1443
+ return { Space: paging };
707
1444
  }
708
- if (!node) {
709
- throw invalidSyntax(`${what} must be a quoted string`, range);
1445
+ if (!stmt.value) {
1446
+ throw invalidSyntax('HISTORY ELEMENT requires an element id', stmt.range);
710
1447
  }
711
- return value;
1448
+ return { Element: { value: lowerScalar(stmt.value), ...paging } };
1449
+ }
1450
+ function lowerExport(stmt) {
1451
+ return {
1452
+ target: lowerElementRef(stmt.target),
1453
+ where_clauses: lowerWhere(stmt.where),
1454
+ options: stmt.options ? lowerBoundObject(stmt.options) : null,
1455
+ as_of: stmt.asOf ? lowerAsOf(stmt.asOf) : null
1456
+ };
712
1457
  }
713
1458
  // ---------------------------------------------------------------------------
714
- // Shared leaves
1459
+ // Leaf conversions
715
1460
  // ---------------------------------------------------------------------------
716
- function lowerLimit(limit) {
717
- if (!limit)
718
- return null;
719
- const value = limit.value;
720
- if (value.kind !== 'NumberLiteral' || !isIntegerLiteral(value.raw)) {
721
- throw invalidSyntax('LIMIT takes a positive integer', value.range);
722
- }
723
- const n = BigInt(value.raw);
724
- if (n <= 0n) {
725
- throw invalidSyntax('LIMIT must be a positive integer (LIMIT 0 is not allowed; omit LIMIT for the engine default)', value.range);
726
- }
727
- if (n > BigInt(MAX_LIMIT)) {
728
- throw invalidSyntax(`LIMIT ${value.raw} is out of range`, value.range);
1461
+ function lowerScalar(value) {
1462
+ if (value.kind === 'ParameterRef') {
1463
+ return { Param: paramName(value.name) };
729
1464
  }
730
- return Number(n);
1465
+ return { Literal: lowerKipValue(value) };
731
1466
  }
732
- function lowerCursor(cursor) {
733
- if (!cursor)
734
- return null;
735
- const value = cursor.value;
736
- if (value.kind !== 'StringLiteral') {
737
- throw invalidSyntax('CURSOR takes a quoted pagination token', value.range);
1467
+ /** An ASSERT member used where the grammar needs a scalar, e.g. `key:`. */
1468
+ function lowerScalarExpression(expr) {
1469
+ if (expr.kind === 'ParameterRef') {
1470
+ return { Param: paramName(expr.name) };
738
1471
  }
739
- if (value.parsed.length === 0) {
740
- throw invalidSyntax('CURSOR must be a non-empty quoted pagination token', value.range);
1472
+ if (expr.kind === 'StringLiteral' ||
1473
+ expr.kind === 'NumberLiteral' ||
1474
+ expr.kind === 'BooleanLiteral' ||
1475
+ expr.kind === 'NullLiteral') {
1476
+ return { Literal: lowerKipValue(expr) };
741
1477
  }
742
- return value.parsed;
1478
+ throw invalidSyntax(`expected a literal or :parameter, found ${describeExpression(expr)}`, expr.range);
743
1479
  }
744
- function lowerMetadata(block) {
745
- return block ? lowerJsonEntries(block.entries) : null;
746
- }
747
- function lowerJsonEntries(entries) {
748
- const out = {};
749
- for (const entry of entries) {
750
- if (Object.prototype.hasOwnProperty.call(out, entry.key)) {
751
- throw invalidSyntax(`duplicate key in object (keys must be unique): ${entry.key}`, entry.range);
752
- }
753
- out[entry.key] = lowerJson(entry.value);
754
- }
755
- return out;
1480
+ function lowerSymbol(symbol) {
1481
+ return symbol.kind === 'ParameterRef'
1482
+ ? { Param: paramName(symbol.name) }
1483
+ : { Name: symbol.parsed };
756
1484
  }
757
- function lowerJson(expr) {
758
- switch (expr.kind) {
1485
+ function lowerElementRef(ref) {
1486
+ switch (ref.kind) {
1487
+ case 'VariableRef':
1488
+ return { Handle: varName(ref.name, ref.range) };
1489
+ case 'ParameterRef':
1490
+ return { Param: paramName(ref.name) };
759
1491
  case 'StringLiteral':
760
- return expr.parsed;
761
- case 'NumberLiteral':
762
- return numberValue(expr.value, expr.raw, expr.range);
763
- case 'BooleanLiteral':
764
- return expr.value;
765
- case 'NullLiteral':
766
- return null;
767
- case 'ArrayLiteral':
768
- return expr.elements.map(lowerJson);
769
- case 'ObjectLiteral':
770
- return lowerJsonEntries(expr.entries);
771
- default:
772
- throw invalidSyntax(`expected a JSON value, found ${describeExpression(expr)}`, expr.range);
1492
+ return { Id: ref.parsed };
773
1493
  }
774
1494
  }
775
- /**
776
- * Reads a literal in matcher or FILTER position.
777
- *
778
- * Stricter than {@link lowerJson}: these positions are not JSON-value
779
- * positions in the grammar, and a trailing comma that an attribute block
780
- * tolerates is a syntax error inside `IN [...]`.
781
- */
782
1495
  function lowerKipValue(expr) {
783
- if ((expr.kind === 'ArrayLiteral' || expr.kind === 'ObjectLiteral') &&
784
- expr.trailingComma) {
785
- throw invalidSyntax('a literal list takes no trailing comma', expr.range);
786
- }
787
1496
  switch (expr.kind) {
788
1497
  case 'StringLiteral':
789
1498
  return { String: expr.parsed };
790
1499
  case 'NumberLiteral':
791
- return { Number: numberValue(expr.value, expr.raw, expr.range) };
1500
+ if (!Number.isFinite(expr.value)) {
1501
+ throw invalidSyntax(`only finite numbers are valid KIP literals, found ${expr.raw}`, expr.range);
1502
+ }
1503
+ return { Number: expr.value };
792
1504
  case 'BooleanLiteral':
793
1505
  return { Bool: expr.value };
794
1506
  case 'NullLiteral':
795
1507
  return 'Null';
796
1508
  case 'ArrayLiteral':
797
1509
  return { Array: expr.elements.map(lowerKipValue) };
798
- case 'ObjectLiteral': {
1510
+ case 'ObjectLiteral':
1511
+ case 'ObjectPattern': {
1512
+ const entries = expr.kind === 'ObjectLiteral' ? expr.entries : expr.members;
799
1513
  const out = {};
800
- for (const entry of expr.entries) {
801
- if (Object.prototype.hasOwnProperty.call(out, entry.key)) {
802
- throw invalidSyntax(`duplicate key in object (keys must be unique): ${entry.key}`, entry.range);
803
- }
1514
+ for (const entry of entries) {
804
1515
  out[entry.key] = lowerKipValue(entry.value);
805
1516
  }
806
1517
  return { Object: out };
807
1518
  }
1519
+ case 'UnaryExpression':
1520
+ if (expr.operator === '-' && expr.operand.kind === 'NumberLiteral') {
1521
+ return { Number: -expr.operand.value };
1522
+ }
1523
+ throw invalidSyntax(`expected a value, found ${describeExpression(expr)}`, expr.range);
808
1524
  default:
809
- throw invalidSyntax(`expected a literal value, found ${describeExpression(expr)}`, expr.range);
1525
+ throw invalidSyntax(`expected a value, found ${describeExpression(expr)}`, expr.range);
810
1526
  }
811
1527
  }
812
1528
  /**
813
- * Reads a numeric literal, rejecting integers no KIP engine can carry:
814
- * `18446744073709551617` is past u64 and would silently widen to
815
- * `1.8446744073709552e19`.
816
- *
817
- * Known limit: this tree carries numbers as JavaScript `number`, so integers
818
- * above 2^53 still lose precision here even though an i64/u64 engine keeps
819
- * them — `9007199254740993` lowers to `9007199254740992`. Closing that gap
820
- * needs a bigint-carrying wire type, not a wider bound.
1529
+ * Option and epistemic blocks are `data_value`s, not plain JSON: the grammar
1530
+ * lets a parameter stand anywhere inside them.
821
1531
  */
822
- function numberValue(value, raw, range) {
823
- // KIP values are JSON values, and JSON has no `02`. `LIMIT 02` and
824
- // `EXPECT VERSION 007` are a different production and stay permissive.
825
- if (!JSON_NUMBER.test(raw)) {
826
- throw invalidSyntax(`invalid number literal ${raw}`, range);
827
- }
828
- if (!isIntegerLiteral(raw)) {
829
- if (!Number.isFinite(value)) {
830
- throw invalidSyntax(`number literal ${raw} is out of range`, range);
831
- }
832
- return value;
833
- }
834
- const n = BigInt(raw);
835
- if (n < I64_MIN || n > U64_MAX) {
836
- throw invalidSyntax(`integer literal ${raw} is out of range: KIP integers must be representable as i64 or u64`, range);
837
- }
838
- // `-0` is an integer literal, and the wire format carries it as `0`.
839
- return n === 0n ? 0 : Number(n);
840
- }
841
- const JSON_NUMBER = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/;
842
- function isIntegerLiteral(raw) {
843
- return !/[.eE]/.test(raw);
844
- }
845
- /** Reads `?var`, `?var.field` or `?var.attributes.key` as a name plus a path. */
846
- function dotPathVar(expr) {
847
- const path = [];
848
- let node = expr;
849
- while (node.kind === 'DotExpression') {
850
- path.unshift(node.property);
851
- node = node.object;
852
- }
853
- if (node.kind !== 'VariableRef') {
854
- throw invalidSyntax(`expected a variable, found ${describeExpression(node)}`, node.range);
1532
+ function lowerBoundObject(object) {
1533
+ const out = {};
1534
+ for (const entry of object.entries) {
1535
+ out[entry.key] = lowerBoundValue(entry.value, null);
855
1536
  }
856
- return { var: varName(node.name, node.range), path };
1537
+ return out;
857
1538
  }
858
1539
  /** Strips the `?` sigil; the executable form carries bare names. */
859
1540
  function varName(name, range) {
@@ -862,14 +1543,22 @@ function varName(name, range) {
862
1543
  }
863
1544
  return name.slice(1);
864
1545
  }
1546
+ /** Strips the `:` sigil; the executable form carries bare names. */
1547
+ function paramName(name) {
1548
+ return name.startsWith(':') ? name.slice(1) : name;
1549
+ }
865
1550
  function describeExpression(expr) {
866
1551
  switch (expr.kind) {
867
1552
  case 'ParameterRef':
868
- return `the parameter ${expr.name} (this engine does not substitute parameters)`;
1553
+ return `the parameter ${expr.name}`;
869
1554
  case 'VariableRef':
870
1555
  return `the variable ${expr.name}`;
871
1556
  case 'FunctionCallExpr':
872
1557
  return `a call to ${expr.name}`;
1558
+ case 'AggregateExpr':
1559
+ return `the aggregate ${expr.name}`;
1560
+ case 'BinaryExpression':
1561
+ return `the operator ${expr.operator}`;
873
1562
  default:
874
1563
  return expr.kind;
875
1564
  }