@ldclabs/kip-lang 0.3.1 → 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.
- package/CHANGELOG.md +53 -0
- package/LICENSE +21 -0
- package/README.md +133 -44
- package/dist/ast.d.ts +519 -144
- package/dist/ast.d.ts.map +1 -1
- package/dist/budget.d.ts +37 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +105 -0
- package/dist/budget.js.map +1 -0
- package/dist/diagnostics.d.ts +8 -2
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +32 -3
- package/dist/diagnostics.js.map +1 -1
- package/dist/errors.d.ts +29 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +27 -0
- package/dist/errors.js.map +1 -0
- package/dist/exec-ast.d.ts +679 -0
- package/dist/exec-ast.d.ts.map +1 -0
- package/dist/exec-ast.js +24 -0
- package/dist/exec-ast.js.map +1 -0
- package/dist/formatter.d.ts.map +1 -1
- package/dist/formatter.js +870 -479
- package/dist/formatter.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/lexer.d.ts.map +1 -1
- package/dist/lexer.js +46 -33
- package/dist/lexer.js.map +1 -1
- package/dist/lower.d.ts +17 -0
- package/dist/lower.d.ts.map +1 -0
- package/dist/lower.js +1566 -0
- package/dist/lower.js.map +1 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +2476 -1152
- package/dist/parser.js.map +1 -1
- package/dist/semantics.d.ts +11 -7
- package/dist/semantics.d.ts.map +1 -1
- package/dist/semantics.js +295 -180
- package/dist/semantics.js.map +1 -1
- package/dist/token.d.ts +130 -40
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +264 -83
- package/dist/token.js.map +1 -1
- package/dist/version.d.ts +13 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +13 -0
- package/dist/version.js.map +1 -0
- package/package.json +36 -6
- package/src/ast.ts +914 -0
- package/src/budget.ts +108 -0
- package/src/diagnostics.ts +182 -0
- package/src/errors.ts +42 -0
- package/src/exec-ast.ts +614 -0
- package/src/formatter.ts +1339 -0
- package/src/index.ts +226 -0
- package/src/lexer.ts +459 -0
- package/src/lower.ts +2011 -0
- package/src/parser.ts +3506 -0
- package/src/semantics.ts +392 -0
- package/src/token.ts +408 -0
- package/src/version.ts +13 -0
package/src/budget.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { resourceExhausted } from './errors.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parser budgets.
|
|
5
|
+
*
|
|
6
|
+
* KIP source reaches a server as agent-generated text, so the parser is an
|
|
7
|
+
* attack surface before it is a convenience. `parse` is recursive descent:
|
|
8
|
+
* without a depth ceiling, `[[[[...` recurses until the JavaScript stack
|
|
9
|
+
* overflows, and in an environment that shares one stack across requests
|
|
10
|
+
* (a Cloudflare Worker isolate, say) that takes the whole runtime down rather
|
|
11
|
+
* than failing the one request. The check runs on raw text, before a single
|
|
12
|
+
* token is produced, so a hostile input costs one linear scan.
|
|
13
|
+
*
|
|
14
|
+
* The values mirror `MAX_KIP_*` in `anda_kip`'s parser: a command rejected by
|
|
15
|
+
* one KIP engine's budget must be rejected by every other engine's, or the
|
|
16
|
+
* same command succeeds on one deployment and fails on another.
|
|
17
|
+
*/
|
|
18
|
+
export const MAX_KIP_INPUT_LEN = 256 * 1024
|
|
19
|
+
export const MAX_KIP_NESTING_DEPTH = 64
|
|
20
|
+
export const MAX_KIP_BATCH_COMMANDS = 256
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Rejects source that exceeds a parser budget.
|
|
24
|
+
*
|
|
25
|
+
* The bracket scan must skip line comments exactly the way the lexer does.
|
|
26
|
+
* Counting a `"` inside a comment would latch the scanner into string mode
|
|
27
|
+
* for the rest of the input, after which every bracket goes uncounted and the
|
|
28
|
+
* depth ceiling silently stops existing — the failure mode is a guard that
|
|
29
|
+
* looks present and defends nothing.
|
|
30
|
+
*
|
|
31
|
+
* @throws {KipSyntaxError} `KIP_4002` when a budget is exceeded.
|
|
32
|
+
*/
|
|
33
|
+
export function checkBudget(source: string): void {
|
|
34
|
+
if (source.length > MAX_KIP_INPUT_LEN) {
|
|
35
|
+
throw resourceExhausted(
|
|
36
|
+
`KIP input length ${source.length} exceeds maximum ${MAX_KIP_INPUT_LEN}`
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let depth = 0
|
|
41
|
+
const stack: string[] = []
|
|
42
|
+
let inString = false
|
|
43
|
+
let escaped = false
|
|
44
|
+
let inLineComment = false
|
|
45
|
+
let prevSlash = false
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < source.length; i++) {
|
|
48
|
+
const ch = source[i]!
|
|
49
|
+
|
|
50
|
+
if (inLineComment) {
|
|
51
|
+
if (ch === '\n') inLineComment = false
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (inString) {
|
|
56
|
+
prevSlash = false
|
|
57
|
+
if (escaped) {
|
|
58
|
+
escaped = false
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (ch === '\\') escaped = true
|
|
62
|
+
else if (ch === '"') inString = false
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (ch === '/') {
|
|
67
|
+
if (prevSlash) {
|
|
68
|
+
inLineComment = true
|
|
69
|
+
prevSlash = false
|
|
70
|
+
} else {
|
|
71
|
+
prevSlash = true
|
|
72
|
+
}
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
prevSlash = false
|
|
76
|
+
|
|
77
|
+
if (ch === '"') {
|
|
78
|
+
inString = true
|
|
79
|
+
} else if (ch === '(' || ch === '[' || ch === '{') {
|
|
80
|
+
stack.push(ch)
|
|
81
|
+
depth = stack.length
|
|
82
|
+
if (depth > MAX_KIP_NESTING_DEPTH) {
|
|
83
|
+
throw resourceExhausted(
|
|
84
|
+
`KIP input nesting exceeds maximum ${MAX_KIP_NESTING_DEPTH}`
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
} else if (ch === ')') {
|
|
88
|
+
if (stack[stack.length - 1] === '(') stack.pop()
|
|
89
|
+
} else if (ch === ']') {
|
|
90
|
+
if (stack[stack.length - 1] === '[') stack.pop()
|
|
91
|
+
} else if (ch === '}') {
|
|
92
|
+
if (stack[stack.length - 1] === '{') stack.pop()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Rejects an over-long batch of commands.
|
|
99
|
+
*
|
|
100
|
+
* @throws {KipSyntaxError} `KIP_4002` when the batch is too large.
|
|
101
|
+
*/
|
|
102
|
+
export function checkBatchBudget(count: number): void {
|
|
103
|
+
if (count > MAX_KIP_BATCH_COMMANDS) {
|
|
104
|
+
throw resourceExhausted(
|
|
105
|
+
`KIP batch of ${count} commands exceeds maximum ${MAX_KIP_BATCH_COMMANDS}`
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Range } from './token.js'
|
|
2
|
+
import { tokenize } from './lexer.js'
|
|
3
|
+
import { parse } from './parser.js'
|
|
4
|
+
import { analyzeSemantics } from './semantics.js'
|
|
5
|
+
import { lowerStatement } from './lower.js'
|
|
6
|
+
import { KipSyntaxError } from './errors.js'
|
|
7
|
+
import type { Program } from './ast.js'
|
|
8
|
+
import { TokenType } from './token.js'
|
|
9
|
+
|
|
10
|
+
export interface Diagnostic {
|
|
11
|
+
range: Range
|
|
12
|
+
severity: 'error' | 'warning' | 'info'
|
|
13
|
+
message: string
|
|
14
|
+
code: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runs diagnostics on KIP source code.
|
|
19
|
+
* Combines lexer and parser errors with schema-independent semantic and
|
|
20
|
+
* executable-AST validation.
|
|
21
|
+
*/
|
|
22
|
+
export function diagnose(source: string): Diagnostic[] {
|
|
23
|
+
const diagnostics: Diagnostic[] = []
|
|
24
|
+
|
|
25
|
+
// Phase 1: Lexer-level diagnostics
|
|
26
|
+
const tokens = tokenize(source)
|
|
27
|
+
for (const tok of tokens) {
|
|
28
|
+
if (tok.type === TokenType.Unknown) {
|
|
29
|
+
diagnostics.push({
|
|
30
|
+
range: {
|
|
31
|
+
start: { line: tok.line, column: tok.column },
|
|
32
|
+
end: { line: tok.line, column: tok.column + tok.value.length }
|
|
33
|
+
},
|
|
34
|
+
severity: 'error',
|
|
35
|
+
message: `Unexpected character '${tok.value}'`,
|
|
36
|
+
code: 'KIP_LEX_UNKNOWN'
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check for unterminated strings
|
|
41
|
+
if (tok.type === TokenType.String) {
|
|
42
|
+
if (!tok.value.endsWith('"') || tok.value.length < 2) {
|
|
43
|
+
diagnostics.push({
|
|
44
|
+
range: {
|
|
45
|
+
start: { line: tok.line, column: tok.column },
|
|
46
|
+
end: { line: tok.line, column: tok.column + tok.value.length }
|
|
47
|
+
},
|
|
48
|
+
severity: 'error',
|
|
49
|
+
message: 'Unterminated string literal',
|
|
50
|
+
code: 'KIP_LEX_UNTERMINATED_STRING'
|
|
51
|
+
})
|
|
52
|
+
} else {
|
|
53
|
+
try {
|
|
54
|
+
JSON.parse(tok.value)
|
|
55
|
+
} catch {
|
|
56
|
+
diagnostics.push({
|
|
57
|
+
range: {
|
|
58
|
+
start: { line: tok.line, column: tok.column },
|
|
59
|
+
end: { line: tok.line, column: tok.column + tok.value.length }
|
|
60
|
+
},
|
|
61
|
+
severity: 'error',
|
|
62
|
+
message: 'Invalid JSON string literal escape sequence',
|
|
63
|
+
code: 'KIP_LEX_INVALID_STRING'
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (tok.type === TokenType.Number && !isJsonNumberLiteral(tok.value)) {
|
|
70
|
+
diagnostics.push({
|
|
71
|
+
range: {
|
|
72
|
+
start: { line: tok.line, column: tok.column },
|
|
73
|
+
end: { line: tok.line, column: tok.column + tok.value.length }
|
|
74
|
+
},
|
|
75
|
+
severity: 'error',
|
|
76
|
+
message: 'Invalid JSON number literal',
|
|
77
|
+
code: 'KIP_LEX_INVALID_NUMBER'
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Phase 2: Bracket/paren matching
|
|
83
|
+
const bracketStack: { type: string; line: number; column: number }[] = []
|
|
84
|
+
const openers: Record<string, string> = { '{': '}', '(': ')', '[': ']' }
|
|
85
|
+
const closers: Record<string, string> = { '}': '{', ')': '(', ']': '[' }
|
|
86
|
+
|
|
87
|
+
for (const tok of tokens) {
|
|
88
|
+
if (
|
|
89
|
+
tok.type === TokenType.LBrace ||
|
|
90
|
+
tok.type === TokenType.LParen ||
|
|
91
|
+
tok.type === TokenType.LBracket
|
|
92
|
+
) {
|
|
93
|
+
bracketStack.push({ type: tok.value, line: tok.line, column: tok.column })
|
|
94
|
+
} else if (
|
|
95
|
+
tok.type === TokenType.RBrace ||
|
|
96
|
+
tok.type === TokenType.RParen ||
|
|
97
|
+
tok.type === TokenType.RBracket
|
|
98
|
+
) {
|
|
99
|
+
const expected = closers[tok.value]
|
|
100
|
+
if (bracketStack.length === 0) {
|
|
101
|
+
diagnostics.push({
|
|
102
|
+
range: {
|
|
103
|
+
start: { line: tok.line, column: tok.column },
|
|
104
|
+
end: { line: tok.line, column: tok.column + 1 }
|
|
105
|
+
},
|
|
106
|
+
severity: 'error',
|
|
107
|
+
message: `Unmatched closing '${tok.value}'`,
|
|
108
|
+
code: 'KIP_LEX_UNMATCHED_BRACKET'
|
|
109
|
+
})
|
|
110
|
+
} else {
|
|
111
|
+
const top = bracketStack[bracketStack.length - 1]!
|
|
112
|
+
if (top.type !== expected) {
|
|
113
|
+
diagnostics.push({
|
|
114
|
+
range: {
|
|
115
|
+
start: { line: tok.line, column: tok.column },
|
|
116
|
+
end: { line: tok.line, column: tok.column + 1 }
|
|
117
|
+
},
|
|
118
|
+
severity: 'error',
|
|
119
|
+
message: `Mismatched bracket: expected '${openers[top.type]}' but got '${tok.value}'`,
|
|
120
|
+
code: 'KIP_LEX_MISMATCHED_BRACKET'
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
bracketStack.pop()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Report unclosed brackets
|
|
129
|
+
for (const unclosed of bracketStack) {
|
|
130
|
+
diagnostics.push({
|
|
131
|
+
range: {
|
|
132
|
+
start: { line: unclosed.line, column: unclosed.column },
|
|
133
|
+
end: { line: unclosed.line, column: unclosed.column + 1 }
|
|
134
|
+
},
|
|
135
|
+
severity: 'error',
|
|
136
|
+
message: `Unclosed '${unclosed.type}'`,
|
|
137
|
+
code: 'KIP_LEX_UNCLOSED_BRACKET'
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Phase 3: Parser diagnostics
|
|
142
|
+
const { ast, diagnostics: parseDiags } = parse(source)
|
|
143
|
+
diagnostics.push(...parseDiags)
|
|
144
|
+
|
|
145
|
+
// Phase 4: Semantic checks (only when the AST parsed cleanly, to avoid
|
|
146
|
+
// cascading false positives from error-recovery placeholders).
|
|
147
|
+
if (!parseDiags.some((d) => d.severity === 'error')) {
|
|
148
|
+
const semanticDiags = analyzeSemantics(ast)
|
|
149
|
+
diagnostics.push(...semanticDiags)
|
|
150
|
+
if (!semanticDiags.some((d) => d.severity === 'error')) {
|
|
151
|
+
diagnostics.push(...validateExecutable(ast))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return diagnostics
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Runs the schema-independent checks needed for every parsed statement to
|
|
160
|
+
* lower into the closed executable AST used by a KIP engine.
|
|
161
|
+
*/
|
|
162
|
+
export function validateExecutable(program: Program): Diagnostic[] {
|
|
163
|
+
const diagnostics: Diagnostic[] = []
|
|
164
|
+
for (const statement of program.statements) {
|
|
165
|
+
try {
|
|
166
|
+
lowerStatement(statement)
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (!(error instanceof KipSyntaxError)) throw error
|
|
169
|
+
diagnostics.push({
|
|
170
|
+
range: error.range ?? statement.range,
|
|
171
|
+
severity: 'error',
|
|
172
|
+
message: error.message,
|
|
173
|
+
code: error.code
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return diagnostics
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isJsonNumberLiteral(value: string): boolean {
|
|
181
|
+
return /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)
|
|
182
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Range } from './token.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The subset of the KIP error taxonomy this package can produce.
|
|
5
|
+
*
|
|
6
|
+
* A KIP engine owns the full taxonomy (schema, logic and execution codes);
|
|
7
|
+
* a language toolkit only ever reaches the parse-time codes. The `hint` that
|
|
8
|
+
* accompanies each code on the wire is deliberately *not* duplicated here —
|
|
9
|
+
* it is engine-side wire contract, and a second copy that drifts is worse
|
|
10
|
+
* than no copy at all.
|
|
11
|
+
*/
|
|
12
|
+
export type KipSyntaxCode = 'KIP_1001' | 'KIP_1002' | 'KIP_4002'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A fatal, single-error view of a KIP source problem.
|
|
16
|
+
*
|
|
17
|
+
* `parse` reports every problem it can recover from as a `Diagnostic`, which
|
|
18
|
+
* is what an editor wants. An engine wants the opposite: the first thing that
|
|
19
|
+
* makes the command unexecutable, thrown, with a code it can put on the wire.
|
|
20
|
+
* `lower` and `checkBudget` therefore throw this instead of accumulating.
|
|
21
|
+
*/
|
|
22
|
+
export class KipSyntaxError extends Error {
|
|
23
|
+
readonly code: KipSyntaxCode
|
|
24
|
+
readonly range: Range | undefined
|
|
25
|
+
|
|
26
|
+
constructor(code: KipSyntaxCode, message: string, range?: Range) {
|
|
27
|
+
super(message)
|
|
28
|
+
this.name = 'KipSyntaxError'
|
|
29
|
+
this.code = code
|
|
30
|
+
this.range = range
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `KIP_1001 InvalidSyntax` — the command does not parse, or violates a grammar rule. */
|
|
35
|
+
export function invalidSyntax(message: string, range?: Range): KipSyntaxError {
|
|
36
|
+
return new KipSyntaxError('KIP_1001', message, range)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `KIP_4002 ResourceExhausted` — the input exceeds a parser budget. */
|
|
40
|
+
export function resourceExhausted(message: string): KipSyntaxError {
|
|
41
|
+
return new KipSyntaxError('KIP_4002', message)
|
|
42
|
+
}
|