@ldclabs/kip-lang 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lower.ts CHANGED
@@ -50,7 +50,8 @@ import type {
50
50
  HistoryStatement,
51
51
  ChangesStatement,
52
52
  SnapshotStatement,
53
- ExportCapsuleStatement
53
+ ExportCapsuleStatement,
54
+ NumberLiteral
54
55
  } from './ast.js'
55
56
  import { invalidSyntax } from './errors.js'
56
57
  import type {
@@ -955,22 +956,44 @@ function lowerCreateConcept(stmt: CreateConceptStatement): ConceptCreate {
955
956
  }
956
957
  }
957
958
 
959
+ /**
960
+ * Whether a matcher pins exactly one Concept.
961
+ *
962
+ * Only `id` and `key` identify, and only when they carry a value that is one
963
+ * value: a literal, or a parameter the runtime binds to one. Anything else —
964
+ * a variable, a nested pattern, a list — describes candidates.
965
+ */
966
+ function hasStableIdentity(match: ObjectMatcher): boolean {
967
+ for (const field of ['id', 'key']) {
968
+ const value = match[field]
969
+ if (value && ('Literal' in value || 'Param' in value)) return true
970
+ }
971
+ return false
972
+ }
973
+
958
974
  function lowerUpsertConcept(stmt: UpsertConceptStatement): ConceptUpsert {
959
975
  const match = stmt.match ? lowerObjectMatcher(stmt.match.pattern) : null
960
976
 
961
- // Identity for an upsert is `id` or `key`; a name-only match is forbidden
962
- // because names are mutable grounding state with duplicates allowed, so
963
- // "the Concept named X" can silently address a different node over time.
964
- if (match) {
965
- const fields = Object.keys(match)
966
- const hasIdentity = fields.includes('id') || fields.includes('key')
967
- if (!hasIdentity) {
968
- throw invalidSyntax(
969
- 'UPSERT CONCEPT must match on a stable identity: add {id: ...} or {key: ...} — ' +
970
- 'name is mutable grounding state and never identifies a Concept',
971
- stmt.match!.range
972
- )
973
- }
977
+ // Identity for an upsert is `id` or `key`, spelled as a literal or a
978
+ // parameter. Three things are refused here, and they are the same mistake at
979
+ // different depths:
980
+ //
981
+ // - no MATCH at all, which would make UPSERT mean "create, always";
982
+ // - a name-only match, because names are mutable grounding state with
983
+ // duplicates allowed, so "the Concept named X" can silently address a
984
+ // different node over time;
985
+ // - an identity whose value is a variable, which is a *set* of candidates
986
+ // rather than one element an upsert resolving it would pick a winner.
987
+ //
988
+ // A match may carry other fields beside the identity; they narrow, they do
989
+ // not identify.
990
+ if (!match || !hasStableIdentity(match)) {
991
+ throw invalidSyntax(
992
+ 'UPSERT CONCEPT requires a MATCH on a stable identity: {id: <literal-or-parameter>} ' +
993
+ 'or {key: <literal-or-parameter>} — name is mutable grounding state and never ' +
994
+ 'identifies a Concept, and a variable names a set rather than an element',
995
+ stmt.match ? stmt.match.range : stmt.range
996
+ )
974
997
  }
975
998
 
976
999
  return {
@@ -1607,11 +1630,11 @@ function lowerUpdateExpr(
1607
1630
  return { Param: paramName(expr.name) }
1608
1631
 
1609
1632
  case 'NumberLiteral':
1610
- return { Number: expr.value }
1633
+ return { Number: numberValue(expr) }
1611
1634
 
1612
1635
  case 'UnaryExpression':
1613
1636
  if (expr.operator === '-' && expr.operand.kind === 'NumberLiteral') {
1614
- return { Number: -expr.operand.value }
1637
+ return { Number: -numberValue(expr.operand) }
1615
1638
  }
1616
1639
  throw invalidSyntax(
1617
1640
  `expected a number, a parameter, the target's own field or a registered function, found ${describeExpression(expr)}`,
@@ -1869,9 +1892,19 @@ function lowerHistory(stmt: HistoryStatement): HistoryCommand {
1869
1892
  }
1870
1893
 
1871
1894
  function lowerExport(stmt: ExportCapsuleStatement) {
1895
+ const where_clauses = lowerWhere(stmt.where)
1896
+ // A Capsule is a bounded, self-contained excerpt. `WHERE { }` selects the
1897
+ // whole Space, which is not a smaller thing to hand somebody — it is the
1898
+ // Brain, exported by accident.
1899
+ if (where_clauses.length === 0) {
1900
+ throw invalidSyntax(
1901
+ 'expected at least one selection pattern: an unbounded EXPORT is not a Capsule',
1902
+ stmt.where.range
1903
+ )
1904
+ }
1872
1905
  return {
1873
1906
  target: lowerElementRef(stmt.target),
1874
- where_clauses: lowerWhere(stmt.where),
1907
+ where_clauses,
1875
1908
  options: stmt.options ? lowerBoundObject(stmt.options) : null,
1876
1909
  as_of: stmt.asOf ? lowerAsOf(stmt.asOf) : null
1877
1910
  }
@@ -1924,18 +1957,68 @@ function lowerElementRef(ref: TargetRef): ElementRef {
1924
1957
  }
1925
1958
  }
1926
1959
 
1960
+ // ---------------------------------------------------------------------------
1961
+ // Numeric literals
1962
+ // ---------------------------------------------------------------------------
1963
+
1964
+ /** `i64::MIN` — the most negative integer a KIP number literal may spell. */
1965
+ const INT_MIN = -(2n ** 63n)
1966
+ /** `u64::MAX` — the largest. */
1967
+ const INT_MAX = 2n ** 64n - 1n
1968
+
1969
+ /** An integer literal: no fraction, no exponent, so it is read as an integer. */
1970
+ const INTEGER_FORM = /^-?\d+$/
1971
+
1972
+ /**
1973
+ * The value of a number literal, refusing the ones that cannot survive being
1974
+ * one.
1975
+ *
1976
+ * A JavaScript number is a double, so `18446744073709551617` silently becomes
1977
+ * `18446744073709551616` on the way in. Accepting that would be the worst
1978
+ * possible outcome: the command does not fail, it *executes with a different
1979
+ * number than it says*, and no engine downstream can detect it — by the time
1980
+ * an executable AST exists the digits are gone. So the check happens here,
1981
+ * against the raw text, which is the only place the original is still around.
1982
+ *
1983
+ * The bounds are the reference grammar's: an integer literal is read as an
1984
+ * `i64` or a `u64` and must fit one of them, and any other form must parse to a
1985
+ * finite double. `18446744073709551616.0` is therefore accepted where
1986
+ * `18446744073709551616` is not — the float form is claiming an approximation,
1987
+ * and the integer form is claiming an exact value it cannot deliver.
1988
+ *
1989
+ * Integers above 2^53 still lose precision in this implementation's `value`
1990
+ * even though they are accepted, because a double cannot hold them. That is a
1991
+ * property of the host, not a disagreement about the language: both engines
1992
+ * agree the command is legal, and a runtime that needs the exact digits has
1993
+ * `raw`.
1994
+ */
1995
+ function numberValue(node: NumberLiteral): number {
1996
+ if (INTEGER_FORM.test(node.raw)) {
1997
+ const exact = BigInt(node.raw)
1998
+ if (exact < INT_MIN || exact > INT_MAX) {
1999
+ throw invalidSyntax(
2000
+ `${node.raw} is outside the range a KIP integer literal can represent ` +
2001
+ `(${INT_MIN} to ${INT_MAX})`,
2002
+ node.range
2003
+ )
2004
+ }
2005
+ return node.value
2006
+ }
2007
+ if (!Number.isFinite(node.value)) {
2008
+ throw invalidSyntax(
2009
+ `only finite numbers are valid KIP literals, found ${node.raw}`,
2010
+ node.range
2011
+ )
2012
+ }
2013
+ return node.value
2014
+ }
2015
+
1927
2016
  function lowerKipValue(expr: Expression): KipValue {
1928
2017
  switch (expr.kind) {
1929
2018
  case 'StringLiteral':
1930
2019
  return { String: expr.parsed }
1931
2020
  case 'NumberLiteral':
1932
- if (!Number.isFinite(expr.value)) {
1933
- throw invalidSyntax(
1934
- `only finite numbers are valid KIP literals, found ${expr.raw}`,
1935
- expr.range
1936
- )
1937
- }
1938
- return { Number: expr.value }
2021
+ return { Number: numberValue(expr) }
1939
2022
  case 'BooleanLiteral':
1940
2023
  return { Bool: expr.value }
1941
2024
  case 'NullLiteral':
@@ -1954,7 +2037,7 @@ function lowerKipValue(expr: Expression): KipValue {
1954
2037
  }
1955
2038
  case 'UnaryExpression':
1956
2039
  if (expr.operator === '-' && expr.operand.kind === 'NumberLiteral') {
1957
- return { Number: -expr.operand.value }
2040
+ return { Number: -numberValue(expr.operand) }
1958
2041
  }
1959
2042
  throw invalidSyntax(
1960
2043
  `expected a value, found ${describeExpression(expr)}`,
package/src/semantics.ts CHANGED
@@ -11,7 +11,8 @@ import type {
11
11
  ScalarValue,
12
12
  MutateStatement,
13
13
  AssertStatement,
14
- SetFacetClause
14
+ SetFacetClause,
15
+ SetStructuralClause
15
16
  } from './ast.js'
16
17
  import type { Range } from './token.js'
17
18
 
@@ -54,6 +55,7 @@ const ASSERTION_STATES = new Set([
54
55
  'expired'
55
56
  ])
56
57
  const ACTIVITY_TERMINAL = new Set(['completed', 'failed', 'cancelled'])
58
+ const EVIDENCE_ROLES = new Set(['support', 'challenge', 'context'])
57
59
 
58
60
  /** Signals the Profile fixes to `[0,1]`; none of them is truth. */
59
61
  const UNIT_INTERVAL_FIELDS = new Set([
@@ -96,11 +98,11 @@ function analyzeMutationClause(stmt: Statement, diags: Diagnostic[]): void {
96
98
  break
97
99
 
98
100
  case 'CreateConceptStatement':
99
- for (const facet of stmt.setFacets) checkFacet(facet, diags)
100
- break
101
-
102
101
  case 'UpsertConceptStatement':
102
+ case 'CreateEvidenceStatement':
103
+ case 'CreateActivityStatement':
103
104
  for (const facet of stmt.setFacets) checkFacet(facet, diags)
105
+ checkStructural(stmt.setStructural, diags)
104
106
  break
105
107
 
106
108
  case 'CreateAssertionStatement':
@@ -108,30 +110,61 @@ function analyzeMutationClause(stmt: Statement, diags: Diagnostic[]): void {
108
110
  checkAssignmentValues(stmt.setFields.assignments, diags)
109
111
  }
110
112
  for (const facet of stmt.setFacets) checkFacet(facet, diags)
111
- break
112
-
113
- case 'CreateEvidenceStatement':
114
- case 'CreateActivityStatement':
115
- for (const facet of stmt.setFacets) checkFacet(facet, diags)
113
+ checkStructural(stmt.setStructural, diags)
116
114
  break
117
115
 
118
116
  case 'UpdateStatement':
119
117
  for (const action of stmt.actions) {
120
118
  if (action.kind === 'SetFacetClause') checkFacet(action, diags)
119
+ // Core fields are Core-typed wherever they are written, so an UPDATE
120
+ // that sets one gets the same check `CREATE ASSERTION` already gets.
121
+ else if (action.kind === 'SetFieldsClause') {
122
+ checkAssignmentValues(action.assignments, diags)
123
+ } else if (action.kind === 'SetStructuralClause') {
124
+ checkStructural(action, diags)
125
+ }
121
126
  }
122
127
  if (stmt.where) checkWhere(stmt.where, !!stmt.limit, diags)
123
128
  break
124
129
 
130
+ // `EXPECT STATE` is checkable only where the target's kind is fixed by the
131
+ // statement itself. RETRACT and SUPERSEDE always name an Assertion, so the
132
+ // Assertion lifecycle registry applies. ARCHIVE and TOMBSTONE take any
133
+ // element — a Concept is not `retracted` and may well be `archived` — and
134
+ // Core registers no element-lifecycle vocabulary, so checking them here
135
+ // rejected commands the Specification admits.
125
136
  case 'RetractAssertionStatement':
126
- case 'ArchiveStatement':
127
- case 'TombstoneStatement':
137
+ if (stmt.expectState) {
138
+ checkEnum(stmt.expectState.value, ASSERTION_STATES, 'EXPECT STATE', diags)
139
+ }
140
+ if (stmt.where) checkWhere(stmt.where, !!stmt.limit, diags)
141
+ break
142
+
143
+ case 'SupersedeAssertionStatement':
128
144
  if (stmt.expectState) {
129
145
  checkEnum(stmt.expectState.value, ASSERTION_STATES, 'EXPECT STATE', diags)
130
146
  }
131
147
  break
132
148
 
149
+ case 'ArchiveStatement':
150
+ case 'TombstoneStatement':
151
+ if (stmt.where) checkWhere(stmt.where, !!stmt.limit, diags)
152
+ break
153
+
133
154
  case 'TransitionActivityStatement':
134
155
  checkEnum(stmt.to, ACTIVITY_TERMINAL, 'TRANSITION ACTIVITY TO', diags)
156
+ checkStructural(stmt.finalize.find(
157
+ (c): c is SetStructuralClause => c.kind === 'SetStructuralClause'
158
+ ), diags)
159
+ break
160
+
161
+ // Spec §52.7 names six statements whose WHERE can select an unbounded set
162
+ // and which SHOULD therefore carry a LIMIT. Warning on the read and on
163
+ // UPDATE while staying silent on the removal ladder had it backwards: an
164
+ // over-broad PURGE is the one that cannot be undone.
165
+ case 'PurgeStatement':
166
+ case 'SetRetentionStatement':
167
+ if (stmt.where) checkWhere(stmt.where, !!stmt.limit, diags)
135
168
  break
136
169
  }
137
170
  }
@@ -176,6 +209,26 @@ function checkFacet(clause: SetFacetClause, diags: Diagnostic[]): void {
176
209
  checkAssignmentValues(clause.assignments, diags)
177
210
  }
178
211
 
212
+ /**
213
+ * `role` on an `("evidence", ...)` citation comes from the Core registry
214
+ * (Spec §20.13, §56.2), so a misspelling is as checkable here as a bad
215
+ * `stance`. Other structural fields carry engine- or package-defined options,
216
+ * which only the Schema Environment can judge.
217
+ */
218
+ function checkStructural(
219
+ clause: SetStructuralClause | undefined,
220
+ diags: Diagnostic[]
221
+ ): void {
222
+ if (!clause) return
223
+ for (const edge of clause.assignments) {
224
+ if (edge.field.kind === 'ParameterRef' || edge.field.parsed !== 'evidence') {
225
+ continue
226
+ }
227
+ const role = edge.options?.entries.find((e) => e.key === 'role')
228
+ if (role) checkEnum(role.value, EVIDENCE_ROLES, 'Evidence role', diags)
229
+ }
230
+ }
231
+
179
232
  function checkAssignmentValues(
180
233
  object: ObjectLiteral,
181
234
  diags: Diagnostic[]
package/src/version.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * has to load in runtimes with no filesystem; `test/lower.test.mjs` asserts the
8
8
  * two stay in step.
9
9
  */
10
- export const PARSER_VERSION = '2.0.0'
10
+ export const PARSER_VERSION = '2.0.2'
11
11
 
12
12
  /** The KIP specification revision this grammar targets. */
13
13
  export const KIP_SPEC_REVISION = '2.0-draft'