@ldclabs/kip-lang 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,117 @@
1
+ /** Portable KIP JSON: RFC 8785 serialization with safe integral values. */
2
+ export function portableNumber(value: number, raw?: string): number {
3
+ if (!Number.isFinite(value)) throw new Error('only finite numbers are valid KIP values')
4
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
5
+ throw new Error('number is outside the range of portable exact integers; use a schema-defined string value')
6
+ }
7
+ // Inspect the significand, not the exponent: 0e-400 is still exactly zero.
8
+ if (value === 0 && raw && /[1-9]/.test(raw.split(/[eE]/)[0])) {
9
+ throw new Error('nonzero number underflows to zero')
10
+ }
11
+ return Object.is(value, -0) ? 0 : value
12
+ }
13
+
14
+ function scalarString(value: string): string {
15
+ for (let i = 0; i < value.length; i++) {
16
+ const c = value.charCodeAt(i)
17
+ if (c >= 0xd800 && c <= 0xdbff) {
18
+ const next = value.charCodeAt(++i)
19
+ if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Error('unpaired Unicode surrogate')
20
+ } else if (c >= 0xdc00 && c <= 0xdfff) throw new Error('unpaired Unicode surrogate')
21
+ }
22
+ return JSON.stringify(value)
23
+ }
24
+
25
+ /**
26
+ * Serialize JSON values using JCS (UTF-16 key order, ECMAScript numbers,
27
+ * no Unicode normalization). KIP narrows integral values to safe integers.
28
+ * Semantic NFC normalization belongs to Literal construction, not hashing.
29
+ */
30
+ export function canonicalize(value: unknown): string {
31
+ const ancestors = new Set<object>()
32
+ function emit(item: unknown, depth: number): string {
33
+ if (depth > 128) throw new Error('JSON nesting limit exceeded')
34
+ if (item === null) return 'null'
35
+ if (typeof item === 'string') return scalarString(item)
36
+ if (typeof item === 'number') return JSON.stringify(portableNumber(item))
37
+ if (typeof item === 'boolean') return item ? 'true' : 'false'
38
+ if (typeof item !== 'object') throw new Error('not a JSON value')
39
+ if (ancestors.has(item)) throw new Error('cyclic JSON value')
40
+ ancestors.add(item)
41
+ let result: string
42
+ if (Array.isArray(item)) {
43
+ result = '[' + Array.from(item, v => emit(v, depth + 1)).join(',') + ']'
44
+ } else {
45
+ const proto = Object.getPrototypeOf(item)
46
+ if (proto !== Object.prototype && proto !== null) throw new Error('not a plain JSON object')
47
+ const object = item as Record<string, unknown>
48
+ result = '{' + Object.keys(object).sort().map(key =>
49
+ scalarString(key) + ':' + emit(object[key], depth + 1)
50
+ ).join(',') + '}'
51
+ }
52
+ ancestors.delete(item)
53
+ return result
54
+ }
55
+ return emit(value, 0)
56
+ }
57
+
58
+ /** Decode before hashing/binding, rejecting duplicate decoded keys and loss. */
59
+ export function parseCanonicalJson(source: string): unknown {
60
+ let pos = 0
61
+ function whitespace() { while (/[\x20\t\n\r]/.test(source[pos] ?? '\0')) pos++ }
62
+ function string(): string {
63
+ const start = pos++
64
+ while (pos < source.length) {
65
+ const c = source[pos++]
66
+ if (c === '\\') { pos++; continue }
67
+ if (c === '"') {
68
+ const result: string = JSON.parse(source.slice(start, pos))
69
+ scalarString(result)
70
+ return result
71
+ }
72
+ }
73
+ throw new Error('unterminated JSON string')
74
+ }
75
+ function value(depth: number): unknown {
76
+ if (depth > 128) throw new Error('JSON nesting limit exceeded')
77
+ whitespace()
78
+ if (source[pos] === '"') return string()
79
+ if (source[pos] === '{') {
80
+ pos++; whitespace()
81
+ const result: Record<string, unknown> = Object.create(null)
82
+ if (source[pos] === '}') { pos++; return result }
83
+ while (true) {
84
+ whitespace()
85
+ if (source[pos] !== '"') throw new Error('expected JSON property name')
86
+ const key = string(); whitespace()
87
+ if (Object.hasOwn(result, key)) throw new Error('duplicate JSON property: ' + key)
88
+ if (source[pos++] !== ':') throw new Error('expected colon')
89
+ result[key] = value(depth + 1); whitespace()
90
+ const end = source[pos++]
91
+ if (end === '}') return result
92
+ if (end !== ',') throw new Error('expected comma or closing brace')
93
+ }
94
+ }
95
+ if (source[pos] === '[') {
96
+ pos++; whitespace()
97
+ const result: unknown[] = []
98
+ if (source[pos] === ']') { pos++; return result }
99
+ while (true) {
100
+ result.push(value(depth + 1)); whitespace()
101
+ const end = source[pos++]
102
+ if (end === ']') return result
103
+ if (end !== ',') throw new Error('expected comma or closing bracket')
104
+ }
105
+ }
106
+ for (const [token, result] of [['true', true], ['false', false], ['null', null]] as const) {
107
+ if (source.startsWith(token, pos)) { pos += token.length; return result }
108
+ }
109
+ const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(source.slice(pos))
110
+ if (!match) throw new Error('invalid JSON value')
111
+ pos += match[0].length
112
+ return portableNumber(Number(match[0]), match[0])
113
+ }
114
+ const result = value(0); whitespace()
115
+ if (pos !== source.length) throw new Error('trailing JSON content')
116
+ return result
117
+ }
package/src/exec-ast.ts CHANGED
@@ -574,8 +574,6 @@ export type VerifyTarget =
574
574
  | 'Capsule'
575
575
  | 'SchemaPackage'
576
576
  | 'Receipt'
577
- | 'Blob'
578
- | 'Checkpoint'
579
577
 
580
578
  export interface ValidateCommand {
581
579
  target: ValidateTarget
package/src/formatter.ts CHANGED
@@ -1000,9 +1000,7 @@ class Formatter {
1000
1000
  const words: Record<VerifyStatement['target'], string> = {
1001
1001
  CAPSULE: 'CAPSULE',
1002
1002
  SCHEMA_PACKAGE: 'SCHEMA PACKAGE',
1003
- RECEIPT: 'RECEIPT',
1004
- BLOB: 'BLOB',
1005
- CHECKPOINT: 'CHECKPOINT'
1003
+ RECEIPT: 'RECEIPT'
1006
1004
  }
1007
1005
  this.writeIndent()
1008
1006
  this.write(`VERIFY ${words[stmt.target]} ${this.scalar(stmt.value)}`)
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { tokenize } from './lexer.js'
2
+ export { canonicalize, parseCanonicalJson } from './canonical.js'
2
3
  export { parse } from './parser.js'
3
4
  export type { ParseResult } from './parser.js'
4
5
  export { format } from './formatter.js'
package/src/lower.ts CHANGED
@@ -50,6 +50,7 @@ import type {
50
50
  NumberLiteral
51
51
  } from './ast.js'
52
52
  import { invalidSyntax } from './errors.js'
53
+ import { portableNumber } from './canonical.js'
53
54
  import type {
54
55
  AggregationFunction,
55
56
  Assignments,
@@ -161,7 +162,8 @@ const PROTECTED_FIELDS = new Set([
161
162
  '_system',
162
163
  'governance',
163
164
  'space_id',
164
- 'space_seq'
165
+ 'space_seq',
166
+ 'merged_into'
165
167
  ])
166
168
 
167
169
  /**
@@ -1657,7 +1659,7 @@ function lowerUpdateExpr(
1657
1659
 
1658
1660
  case 'UnaryExpression':
1659
1661
  if (expr.operator === '-' && expr.operand.kind === 'NumberLiteral') {
1660
- return { Number: -numberValue(expr.operand) }
1662
+ return { Number: portableNumber(-numberValue(expr.operand)) }
1661
1663
  }
1662
1664
  throw invalidSyntax(
1663
1665
  `expected a number, a parameter, the target's own field or a registered function, found ${describeExpression(expr)}`,
@@ -1770,9 +1772,7 @@ function lowerMeta(stmt: Statement): MetaCommand {
1770
1772
  const VERIFY_TARGETS: Record<VerifyStatement['target'], VerifyTarget> = {
1771
1773
  CAPSULE: 'Capsule',
1772
1774
  SCHEMA_PACKAGE: 'SchemaPackage',
1773
- RECEIPT: 'Receipt',
1774
- BLOB: 'Blob',
1775
- CHECKPOINT: 'Checkpoint'
1775
+ RECEIPT: 'Receipt'
1776
1776
  }
1777
1777
 
1778
1778
  const VALIDATE_TARGETS: Record<ValidateStatement['target'], ValidateTarget> = {
@@ -1989,56 +1989,13 @@ function lowerElementRef(ref: TargetRef): ElementRef {
1989
1989
  // Numeric literals
1990
1990
  // ---------------------------------------------------------------------------
1991
1991
 
1992
- /** `i64::MIN` the most negative integer a KIP number literal may spell. */
1993
- const INT_MIN = -(2n ** 63n)
1994
- /** `u64::MAX` — the largest. */
1995
- const INT_MAX = 2n ** 64n - 1n
1996
-
1997
- /** An integer literal: no fraction, no exponent, so it is read as an integer. */
1998
- const INTEGER_FORM = /^-?\d+$/
1999
-
2000
- /**
2001
- * The value of a number literal, refusing the ones that cannot survive being
2002
- * one.
2003
- *
2004
- * A JavaScript number is a double, so `18446744073709551617` silently becomes
2005
- * `18446744073709551616` on the way in. Accepting that would be the worst
2006
- * possible outcome: the command does not fail, it *executes with a different
2007
- * number than it says*, and no engine downstream can detect it — by the time
2008
- * an executable AST exists the digits are gone. So the check happens here,
2009
- * against the raw text, which is the only place the original is still around.
2010
- *
2011
- * The bounds are the reference grammar's: an integer literal is read as an
2012
- * `i64` or a `u64` and must fit one of them, and any other form must parse to a
2013
- * finite double. `18446744073709551616.0` is therefore accepted where
2014
- * `18446744073709551616` is not — the float form is claiming an approximation,
2015
- * and the integer form is claiming an exact value it cannot deliver.
2016
- *
2017
- * Integers above 2^53 still lose precision in this implementation's `value`
2018
- * even though they are accepted, because a double cannot hold them. That is a
2019
- * property of the host, not a disagreement about the language: both engines
2020
- * agree the command is legal, and a runtime that needs the exact digits has
2021
- * `raw`.
2022
- */
1992
+ /** Never discard exact integer digits or silently turn a nonzero value into zero. */
2023
1993
  function numberValue(node: NumberLiteral): number {
2024
- if (INTEGER_FORM.test(node.raw)) {
2025
- const exact = BigInt(node.raw)
2026
- if (exact < INT_MIN || exact > INT_MAX) {
2027
- throw invalidSyntax(
2028
- `${node.raw} is outside the range a KIP integer literal can represent ` +
2029
- `(${INT_MIN} to ${INT_MAX})`,
2030
- node.range
2031
- )
2032
- }
2033
- return node.value
2034
- }
2035
- if (!Number.isFinite(node.value)) {
2036
- throw invalidSyntax(
2037
- `only finite numbers are valid KIP literals, found ${node.raw}`,
2038
- node.range
2039
- )
1994
+ try {
1995
+ return portableNumber(node.value, node.raw)
1996
+ } catch (error) {
1997
+ throw invalidSyntax(`${(error as Error).message}: ${node.raw}`, node.range)
2040
1998
  }
2041
- return node.value
2042
1999
  }
2043
2000
 
2044
2001
  function lowerKipValue(expr: Expression): KipValue {
@@ -2057,11 +2014,10 @@ function lowerKipValue(expr: Expression): KipValue {
2057
2014
  case 'ObjectPattern': {
2058
2015
  const entries =
2059
2016
  expr.kind === 'ObjectLiteral' ? expr.entries : expr.members
2060
- const out: Record<string, KipValue> = {}
2061
- for (const entry of entries) {
2062
- out[entry.key] = lowerKipValue(entry.value)
2063
- }
2064
- return { Object: out }
2017
+ // Create data properties even for names such as __proto__.
2018
+ return { Object: Object.fromEntries(entries.map(entry =>
2019
+ [entry.key, lowerKipValue(entry.value)]
2020
+ )) }
2065
2021
  }
2066
2022
  case 'UnaryExpression':
2067
2023
  if (expr.operator === '-' && expr.operand.kind === 'NumberLiteral') {
@@ -2084,11 +2040,9 @@ function lowerKipValue(expr: Expression): KipValue {
2084
2040
  * lets a parameter stand anywhere inside them.
2085
2041
  */
2086
2042
  function lowerBoundObject(object: ObjectLiteral): Record<string, BoundValue> {
2087
- const out: Record<string, BoundValue> = {}
2088
- for (const entry of object.entries) {
2089
- out[entry.key] = lowerBoundValue(entry.value, null)
2090
- }
2091
- return out
2043
+ return Object.fromEntries(object.entries.map(entry =>
2044
+ [entry.key, lowerBoundValue(entry.value, null)]
2045
+ ))
2092
2046
  }
2093
2047
 
2094
2048
  /** Strips the `?` sigil; the executable form carries bare names. */
package/src/parser.ts CHANGED
@@ -2235,14 +2235,6 @@ class Parser {
2235
2235
  this.expectSecondWord(TokenType.Receipt, verify)
2236
2236
  target = 'RECEIPT'
2237
2237
  break
2238
- case TokenType.Blob:
2239
- this.expectSecondWord(TokenType.Blob, verify)
2240
- target = 'BLOB'
2241
- break
2242
- case TokenType.Checkpoint:
2243
- this.expectSecondWord(TokenType.Checkpoint, verify)
2244
- target = 'CHECKPOINT'
2245
- break
2246
2238
  default:
2247
2239
  this.error(`Unknown VERIFY target '${tok.value}'`, tok)
2248
2240
  throw new ParseAbort()
package/src/token.ts CHANGED
@@ -120,8 +120,6 @@ export enum TokenType {
120
120
  Cognition = 'COGNITION',
121
121
  Threshold = 'THRESHOLD',
122
122
  Receipt = 'RECEIPT',
123
- Blob = 'BLOB',
124
- Checkpoint = 'CHECKPOINT',
125
123
  Kql = 'KQL',
126
124
  Kml = 'KML',
127
125
  Import = 'IMPORT',
@@ -293,8 +291,6 @@ export const KEYWORDS: ReadonlyMap<string, TokenType> = new Map([
293
291
  ['COGNITION', TokenType.Cognition],
294
292
  ['THRESHOLD', TokenType.Threshold],
295
293
  ['RECEIPT', TokenType.Receipt],
296
- ['BLOB', TokenType.Blob],
297
- ['CHECKPOINT', TokenType.Checkpoint],
298
294
  ['KQL', TokenType.Kql],
299
295
  ['KML', TokenType.Kml],
300
296
  ['IMPORT', TokenType.Import],
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.2.0'
10
+ export const PARSER_VERSION = '2.3.1'
11
11
 
12
12
  /** The KIP specification revision this grammar targets. */
13
13
  export const KIP_SPEC_REVISION = '2.0-draft'