Compare commits

...

2 Commits

Author SHA1 Message Date
Chris Wanstrath
43842adc87 TRY_CALL 2025-10-10 14:21:43 -07:00
Chris Wanstrath
8975bb91bd fix validator 2025-10-10 14:02:49 -07:00
8 changed files with 407 additions and 50 deletions

View File

@ -219,6 +219,7 @@ CALL
- `CALL` - Call function (see calling convention above) - `CALL` - Call function (see calling convention above)
- `TAIL_CALL` - Tail-recursive call (no stack growth) - `TAIL_CALL` - Tail-recursive call (no stack growth)
- `RETURN` - Return from function (pops return value) - `RETURN` - Return from function (pops return value)
- `TRY_CALL <name>` - Call function (if found), push value (if exists), or push name as string (if not found)
- `BREAK` - Exit iterator/loop (unwinds to break target) - `BREAK` - Exit iterator/loop (unwinds to break target)
### Arrays ### Arrays
@ -404,6 +405,32 @@ STORE factorial
TAIL_CALL ; Reuses stack frame TAIL_CALL ; Reuses stack frame
``` ```
### Optional Function Calls (TRY_CALL)
Call function if defined, otherwise use value or name as string:
```
; Define optional hook
MAKE_FUNCTION () .onInit
STORE onInit
; Later: call if defined, skip if not
TRY_CALL onInit ; Calls onInit() if it's a function
; Pushes value if it exists but isn't a function
; Pushes "onInit" as string if undefined
; Use with values
PUSH 42
STORE answer
TRY_CALL answer ; Pushes 42 (not a function)
; Use with undefined
TRY_CALL unknown ; Pushes "unknown" as string
```
**Use Cases**:
- Optional hooks/callbacks in DSLs
- Shell-like languages where unknown identifiers become strings
- Templating systems with optional transformers
## Key Concepts ## Key Concepts
### Truthiness ### Truthiness

127
SPEC.md
View File

@ -46,7 +46,7 @@ type Value =
**toNumber**: number → identity, string → parseFloat (or 0), boolean → 1/0, others → 0 **toNumber**: number → identity, string → parseFloat (or 0), boolean → 1/0, others → 0
**toString**: string → identity, number → string, boolean → string, null → "null", **toString**: string → identity, number → string, boolean → string, null → "null",
function → "<function>", array → "[item, item]", dict → "{key: value, ...}" function → "<function>", array → "[item, item]", dict → "{key: value, ...}"
**isTrue**: Only `null` and `false` are falsy. Everything else (including `0`, `""`, empty arrays, empty dicts) is truthy. **isTrue**: Only `null` and `false` are falsy. Everything else (including `0`, `""`, empty arrays, empty dicts) is truthy.
@ -124,26 +124,26 @@ type ExceptionHandler = {
### Stack Operations ### Stack Operations
#### PUSH #### PUSH
**Operand**: Index into constants pool (number) **Operand**: Index into constants pool (number)
**Effect**: Push constant onto stack **Effect**: Push constant onto stack
**Stack**: [] → [value] **Stack**: [] → [value]
#### POP #### POP
**Operand**: None **Operand**: None
**Effect**: Discard top of stack **Effect**: Discard top of stack
**Stack**: [value] → [] **Stack**: [value] → []
#### DUP #### DUP
**Operand**: None **Operand**: None
**Effect**: Duplicate top of stack **Effect**: Duplicate top of stack
**Stack**: [value] → [value, value] **Stack**: [value] → [value, value]
### Variable Operations ### Variable Operations
#### LOAD #### LOAD
**Operand**: Variable name (string) **Operand**: Variable name (string)
**Effect**: Push variable value onto stack **Effect**: Push variable value onto stack
**Stack**: [] → [value] **Stack**: [] → [value]
**Errors**: Throws if variable not found in scope chain **Errors**: Throws if variable not found in scope chain
#### STORE #### STORE
@ -178,7 +178,7 @@ TRY_LOAD y ; Pushes "y" (variable doesn't exist)
All arithmetic operations pop two values, perform operation, push result as number. All arithmetic operations pop two values, perform operation, push result as number.
#### ADD #### ADD
**Stack**: [a, b] → [a + b] **Stack**: [a, b] → [a + b]
**Note**: Only for numbers (use separate string concat if needed) **Note**: Only for numbers (use separate string concat if needed)
#### SUB #### SUB
@ -265,9 +265,9 @@ end:
**Stack**: [condition] → [] **Stack**: [condition] → []
#### BREAK #### BREAK
**Operand**: None **Operand**: None
**Effect**: Unwind call stack until frame with `isBreakTarget = true`, resume there **Effect**: Unwind call stack until frame with `isBreakTarget = true`, resume there
**Stack**: No change **Stack**: No change
**Errors**: Throws if no break target found **Errors**: Throws if no break target found
**Behavior**: **Behavior**:
@ -281,16 +281,16 @@ end:
### Exception Handling ### Exception Handling
#### PUSH_TRY #### PUSH_TRY
**Operand**: Catch block offset (number) **Operand**: Catch block offset (number)
**Effect**: Push exception handler **Effect**: Push exception handler
**Stack**: No change **Stack**: No change
Registers a try block. If THROW occurs before POP_TRY, execution jumps to catch address. Registers a try block. If THROW occurs before POP_TRY, execution jumps to catch address.
#### PUSH_FINALLY #### PUSH_FINALLY
**Operand**: Finally block offset (number) **Operand**: Finally block offset (number)
**Effect**: Add finally address to most recent exception handler **Effect**: Add finally address to most recent exception handler
**Stack**: No change **Stack**: No change
**Errors**: Throws if no exception handler to modify **Errors**: Throws if no exception handler to modify
Adds a finally block to the current try/catch. The finally block will execute whether an exception is thrown or not. Adds a finally block to the current try/catch. The finally block will execute whether an exception is thrown or not.
@ -312,8 +312,8 @@ Adds a finally block to the current try/catch. The finally block will execute wh
- Finally blocks should end with normal control flow (no special terminator needed) - Finally blocks should end with normal control flow (no special terminator needed)
#### THROW #### THROW
**Operand**: None **Operand**: None
**Effect**: Throw exception with error value from stack **Effect**: Throw exception with error value from stack
**Stack**: [errorValue] → (unwound) **Stack**: [errorValue] → (unwound)
**Behavior**: **Behavior**:
@ -333,8 +333,8 @@ Adds a finally block to the current try/catch. The finally block will execute wh
### Function Operations ### Function Operations
#### MAKE_FUNCTION #### MAKE_FUNCTION
**Operand**: Index into constants pool (number) **Operand**: Index into constants pool (number)
**Effect**: Create function value, capturing current scope **Effect**: Create function value, capturing current scope
**Stack**: [] → [function] **Stack**: [] → [function]
The constant must be a `function_def` with: The constant must be a `function_def` with:
@ -391,8 +391,8 @@ The created function captures `currentScope` as its `parentScope`.
- Enables unbounded tail recursion without stack overflow - Enables unbounded tail recursion without stack overflow
#### RETURN #### RETURN
**Operand**: None **Operand**: None
**Effect**: Return from function **Effect**: Return from function
**Stack**: [returnValue] → (restored stack with returnValue on top) **Stack**: [returnValue] → (restored stack with returnValue on top)
**Behavior**: **Behavior**:
@ -404,19 +404,56 @@ The created function captures `currentScope` as its `parentScope`.
**Errors**: Throws if no call frame to return from **Errors**: Throws if no call frame to return from
#### TRY_CALL
**Operand**: Variable name (string)
**Effect**: Conditionally call function or push value/string onto stack
**Stack**: [] → [returnValue | value | name]
**Errors**: Never throws (unlike CALL)
**Behavior**:
1. Look up variable by name in scope chain
2. **If variable is a function**: Call it with 0 arguments (no positional, no named) and push the returned value onto the stack.
3. **If variable exists but is not a function**: Push the variable's value onto stack
4. **If variable doesn't exist**: Push the variable name as a string onto stack
**Use Cases**:
- DSL/templating languages with "call if callable, otherwise use as literal" semantics
- Shell-like behavior where unknown identifiers become strings
- Optional function hooks (call if defined, silently skip if not)
**Implementation Note**:
- Uses intentional fall-through in VM switch statement from TRY_CALL to CALL case
- When function is found, stacks are set up to match CALL's expectations exactly
- No break target marking or frame pushing occurs when non-function value is found
**Example**:
```
MAKE_FUNCTION () .body
STORE greet
PUSH 42
STORE answer
TRY_CALL greet ; Calls function greet(), returns its value
TRY_CALL answer ; Pushes 42 (number value)
TRY_CALL unknown ; Pushes "unknown" (string)
.body:
PUSH "Hello!"
RETURN
```
### Array Operations ### Array Operations
#### MAKE_ARRAY #### MAKE_ARRAY
**Operand**: Number of items (number) **Operand**: Number of items (number)
**Effect**: Create array from N stack items **Effect**: Create array from N stack items
**Stack**: [item1, item2, ..., itemN] → [array] **Stack**: [item1, item2, ..., itemN] → [array]
Items are popped in reverse order (item1 is array[0]). Items are popped in reverse order (item1 is array[0]).
#### ARRAY_GET #### ARRAY_GET
**Operand**: None **Operand**: None
**Effect**: Get array element at index **Effect**: Get array element at index
**Stack**: [array, index] → [value] **Stack**: [array, index] → [value]
**Errors**: Throws if not array or index out of bounds **Errors**: Throws if not array or index out of bounds
Index is coerced to number and floored. Index is coerced to number and floored.
@ -442,41 +479,41 @@ Index is coerced to number and floored.
### Dictionary Operations ### Dictionary Operations
#### MAKE_DICT #### MAKE_DICT
**Operand**: Number of key-value pairs (number) **Operand**: Number of key-value pairs (number)
**Effect**: Create dict from N key-value pairs **Effect**: Create dict from N key-value pairs
**Stack**: [key1, val1, key2, val2, ...] → [dict] **Stack**: [key1, val1, key2, val2, ...] → [dict]
Keys are coerced to strings. Keys are coerced to strings.
#### DICT_GET #### DICT_GET
**Operand**: None **Operand**: None
**Effect**: Get dict value for key **Effect**: Get dict value for key
**Stack**: [dict, key] → [value] **Stack**: [dict, key] → [value]
Returns null if key not found. Key is coerced to string. Returns null if key not found. Key is coerced to string.
**Errors**: Throws if not dict **Errors**: Throws if not dict
#### DICT_SET #### DICT_SET
**Operand**: None **Operand**: None
**Effect**: Set dict value for key (mutates dict) **Effect**: Set dict value for key (mutates dict)
**Stack**: [dict, key, value] → [] **Stack**: [dict, key, value] → []
Key is coerced to string. Key is coerced to string.
**Errors**: Throws if not dict **Errors**: Throws if not dict
#### DICT_HAS #### DICT_HAS
**Operand**: None **Operand**: None
**Effect**: Check if key exists in dict **Effect**: Check if key exists in dict
**Stack**: [dict, key] → [boolean] **Stack**: [dict, key] → [boolean]
Key is coerced to string. Key is coerced to string.
**Errors**: Throws if not dict **Errors**: Throws if not dict
### TypeScript Interop ### TypeScript Interop
#### CALL_NATIVE #### CALL_NATIVE
**Operand**: Function name (string) **Operand**: Function name (string)
**Effect**: Call registered TypeScript function **Effect**: Call registered TypeScript function
**Stack**: [...args] → [returnValue] **Stack**: [...args] → [returnValue]
**Behavior**: **Behavior**:
@ -501,8 +538,8 @@ type TypeScriptFunction = (...args: Value[]) => Promise<Value> | Value;
### Special ### Special
#### HALT #### HALT
**Operand**: None **Operand**: None
**Effect**: Stop execution **Effect**: Stop execution
**Stack**: No change **Stack**: No change
## Label Syntax ## Label Syntax

View File

@ -27,6 +27,7 @@ type InstructionTuple =
// Variables // Variables
| ["LOAD", string] | ["LOAD", string]
| ["STORE", string] | ["STORE", string]
| ["TRY_LOAD", string]
// Arithmetic // Arithmetic
| ["ADD"] | ["SUB"] | ["MUL"] | ["DIV"] | ["MOD"] | ["ADD"] | ["SUB"] | ["MUL"] | ["DIV"] | ["MOD"]
@ -54,6 +55,7 @@ type InstructionTuple =
| ["CALL"] | ["CALL"]
| ["TAIL_CALL"] | ["TAIL_CALL"]
| ["RETURN"] | ["RETURN"]
| ["TRY_CALL", string]
// Arrays // Arrays
| ["MAKE_ARRAY", number] | ["MAKE_ARRAY", number]
@ -329,6 +331,8 @@ function toBytecodeFromArray(program: ProgramItem[]): Bytecode /* throws */ {
case "LOAD": case "LOAD":
case "STORE": case "STORE":
case "TRY_LOAD":
case "TRY_CALL":
case "CALL_NATIVE": case "CALL_NATIVE":
operandValue = operand as string operandValue = operand as string
break break

View File

@ -44,6 +44,7 @@ export enum OpCode {
CALL, // operand: none | stack: [fn, ...args, posCount, namedCount] → [result] | marks break target CALL, // operand: none | stack: [fn, ...args, posCount, namedCount] → [result] | marks break target
TAIL_CALL, // operand: none | stack: [fn, ...args, posCount, namedCount] → [result] | reuses frame TAIL_CALL, // operand: none | stack: [fn, ...args, posCount, namedCount] → [result] | reuses frame
RETURN, // operand: none | stack: [value] → (restored with value) | return from function RETURN, // operand: none | stack: [value] → (restored with value) | return from function
TRY_CALL, /// operand: variable name (identifier) | stack: [] → [value] | call a function, load a variable, or load a string
// arrays // arrays
MAKE_ARRAY, // operand: item count (number) | stack: [item1, ..., itemN] → [array] MAKE_ARRAY, // operand: item count (number) | stack: [item1, ..., itemN] → [array]

View File

@ -30,11 +30,12 @@ function isValidIdentifier(name: string): boolean {
return !/[\s;()[\]{}='"#@.]/.test(name) return !/[\s;()[\]{}='"#@.]/.test(name)
} }
// Opcodes that require operands
const OPCODES_WITH_OPERANDS = new Set([ const OPCODES_WITH_OPERANDS = new Set([
OpCode.PUSH, OpCode.PUSH,
OpCode.LOAD, OpCode.LOAD,
OpCode.STORE, OpCode.STORE,
OpCode.TRY_LOAD,
OpCode.TRY_CALL,
OpCode.JUMP, OpCode.JUMP,
OpCode.JUMP_IF_FALSE, OpCode.JUMP_IF_FALSE,
OpCode.JUMP_IF_TRUE, OpCode.JUMP_IF_TRUE,
@ -46,7 +47,6 @@ const OPCODES_WITH_OPERANDS = new Set([
OpCode.CALL_NATIVE, OpCode.CALL_NATIVE,
]) ])
// Opcodes that should NOT have operands
const OPCODES_WITHOUT_OPERANDS = new Set([ const OPCODES_WITHOUT_OPERANDS = new Set([
OpCode.POP, OpCode.POP,
OpCode.DUP, OpCode.DUP,
@ -78,6 +78,21 @@ const OPCODES_WITHOUT_OPERANDS = new Set([
OpCode.DICT_HAS, OpCode.DICT_HAS,
]) ])
// immediate = immediate number, eg #5
const OPCODES_REQUIRING_IMMEDIATE_OR_LABEL = new Set([
OpCode.JUMP,
OpCode.JUMP_IF_FALSE,
OpCode.JUMP_IF_TRUE,
OpCode.PUSH_TRY,
OpCode.PUSH_FINALLY,
])
// immediate = immediate number, eg #5
const OPCODES_REQUIRING_IMMEDIATE = new Set([
OpCode.MAKE_ARRAY,
OpCode.MAKE_DICT,
])
export function validateBytecode(source: string): ValidationResult { export function validateBytecode(source: string): ValidationResult {
const errors: ValidationError[] = [] const errors: ValidationError[] = []
const lines = source.split("\n") const lines = source.split("\n")
@ -172,6 +187,26 @@ export function validateBytecode(source: string): ValidationResult {
// Validate specific operand formats // Validate specific operand formats
if (operand) { if (operand) {
if (OPCODES_REQUIRING_IMMEDIATE_OR_LABEL.has(opCode)) {
if (!operand.startsWith('#') && !operand.startsWith('.')) {
errors.push({
line: lineNum,
message: `${opName} requires immediate (#number) or label (.label), got: ${operand}`,
})
continue
}
}
if (OPCODES_REQUIRING_IMMEDIATE.has(opCode)) {
if (!operand.startsWith('#')) {
errors.push({
line: lineNum,
message: `${opName} requires immediate number (#count), got: ${operand}`,
})
continue
}
}
// Check for label references // Check for label references
if (operand.startsWith('.') && !operand.includes('(')) { if (operand.startsWith('.') && !operand.includes('(')) {
const labelName = operand.slice(1) const labelName = operand.slice(1)
@ -246,7 +281,7 @@ export function validateBytecode(source: string): ValidationResult {
} }
} else if (param.includes('=')) { } else if (param.includes('=')) {
// Default parameter // Default parameter
const [name, defaultValue] = param.split('=') const [name] = param.split('=')
if (!isValidIdentifier(name!.trim())) { if (!isValidIdentifier(name!.trim())) {
errors.push({ errors.push({
line: lineNum, line: lineNum,
@ -294,8 +329,9 @@ export function validateBytecode(source: string): ValidationResult {
} }
} }
// Validate variable names for LOAD/STORE // Validate variable names for LOAD/STORE/TRY_LOAD/TRY_CALL
if ((opCode === OpCode.LOAD || opCode === OpCode.STORE) && if ((opCode === OpCode.LOAD || opCode === OpCode.STORE ||
opCode === OpCode.TRY_LOAD || opCode === OpCode.TRY_CALL) &&
!isValidIdentifier(operand)) { !isValidIdentifier(operand)) {
errors.push({ errors.push({
line: lineNum, line: lineNum,

View File

@ -353,6 +353,27 @@ export class VM {
}) })
break break
// @ts-ignore
case OpCode.TRY_CALL: {
const varName = instruction.operand as string
const value = this.scope.get(varName)
if (value?.type === 'function') {
this.stack.push(value)
this.stack.push(toValue(0))
this.stack.push(toValue(0))
// No `break` here -- we want to fall through to OpCode.CALL!
} else if (value) {
this.stack.push(value)
break
} else {
this.stack.push(toValue(varName))
break
}
}
// don't put any `case` statement here - `TRY_CALL` MUST go before `CALL!`
case OpCode.CALL: { case OpCode.CALL: {
// Pop named count from stack (top) // Pop named count from stack (top)
const namedCount = toNumber(this.stack.pop()!) const namedCount = toNumber(this.stack.pop()!)

View File

@ -375,3 +375,124 @@ test("CALL - named args with defaults on fixed params", async () => {
// x should use default value 5 // x should use default value 5
expect(result).toEqual({ type: 'number', value: 5 }) expect(result).toEqual({ type: 'number', value: 5 })
}) })
test("TRY_CALL - calls function if found", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", [], ".body"],
["STORE", "myFunc"],
["TRY_CALL", "myFunc"],
["HALT"],
[".body:"],
["PUSH", 42],
["RETURN"]
])
const result = await new VM(bytecode).run()
expect(result).toEqual({ type: 'number', value: 42 })
})
test("TRY_CALL - pushes value if variable exists but is not a function", async () => {
const bytecode = toBytecode([
["PUSH", 99],
["STORE", "myVar"],
["TRY_CALL", "myVar"],
["HALT"]
])
const result = await new VM(bytecode).run()
expect(result).toEqual({ type: 'number', value: 99 })
})
test("TRY_CALL - pushes string if variable not found", async () => {
const bytecode = toBytecode([
["TRY_CALL", "unknownVar"],
["HALT"]
])
const result = await new VM(bytecode).run()
expect(result).toEqual({ type: 'string', value: 'unknownVar' })
})
test("TRY_CALL - handles arrays", async () => {
const bytecode = toBytecode([
["PUSH", 1],
["PUSH", 2],
["MAKE_ARRAY", 2],
["STORE", "myArray"],
["TRY_CALL", "myArray"],
["HALT"]
])
const result = await new VM(bytecode).run()
expect(result.type).toBe('array')
if (result.type === 'array') {
expect(result.value).toEqual([
{ type: 'number', value: 1 },
{ type: 'number', value: 2 }
])
}
})
test("TRY_CALL - handles dicts", async () => {
const bytecode = toBytecode([
["PUSH", "key"],
["PUSH", "value"],
["MAKE_DICT", 1],
["STORE", "myDict"],
["TRY_CALL", "myDict"],
["HALT"]
])
const result = await new VM(bytecode).run()
expect(result.type).toBe('dict')
if (result.type === 'dict') {
expect(result.value.get('key')).toEqual({ type: 'string', value: 'value' })
}
})
test("TRY_CALL - handles null values", async () => {
const bytecode = toBytecode([
["PUSH", null],
["STORE", "myNull"],
["TRY_CALL", "myNull"],
["HALT"]
])
const result = await new VM(bytecode).run()
expect(result).toEqual({ type: 'null', value: null })
})
test("TRY_CALL - function can access its parameters", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", ["x"], ".body"],
["STORE", "addFive"],
["PUSH", 10],
["STORE", "x"],
["TRY_CALL", "addFive"],
["HALT"],
[".body:"],
["LOAD", "x"],
["PUSH", 5],
["ADD"],
["RETURN"]
])
const result = await new VM(bytecode).run()
// Function is called with 0 args, so x inside function should be null
// Then we add 5 to null (which coerces to 0)
expect(result).toEqual({ type: 'number', value: 5 })
})
test("TRY_CALL - with string format", async () => {
const bytecode = toBytecode(`
MAKE_FUNCTION () #4
STORE myFunc
TRY_CALL myFunc
HALT
PUSH 100
RETURN
`)
const result = await new VM(bytecode).run()
expect(result).toEqual({ type: 'number', value: 100 })
})

View File

@ -200,3 +200,113 @@ test("formatValidationErrors produces readable output", () => {
expect(formatted).toContain("Line") expect(formatted).toContain("Line")
expect(formatted).toContain("UNKNOWN") expect(formatted).toContain("UNKNOWN")
}) })
test("detects JUMP without # or .label", () => {
const source = `
JUMP 5
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(false)
expect(result.errors[0]!.message).toContain("JUMP requires immediate (#number) or label (.label)")
})
test("detects JUMP_IF_TRUE without # or .label", () => {
const source = `
PUSH true
JUMP_IF_TRUE 2
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(false)
expect(result.errors[0]!.message).toContain("JUMP_IF_TRUE requires immediate (#number) or label (.label)")
})
test("detects JUMP_IF_FALSE without # or .label", () => {
const source = `
PUSH false
JUMP_IF_FALSE 2
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(false)
expect(result.errors[0]!.message).toContain("JUMP_IF_FALSE requires immediate (#number) or label (.label)")
})
test("allows JUMP with immediate number", () => {
const source = `
JUMP #2
PUSH 999
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(true)
})
test("detects MAKE_ARRAY without #", () => {
const source = `
PUSH 1
PUSH 2
MAKE_ARRAY 2
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(false)
expect(result.errors[0]!.message).toContain("MAKE_ARRAY requires immediate number (#count)")
})
test("detects MAKE_DICT without #", () => {
const source = `
PUSH "key"
PUSH "value"
MAKE_DICT 1
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(false)
expect(result.errors[0]!.message).toContain("MAKE_DICT requires immediate number (#count)")
})
test("allows MAKE_ARRAY with immediate number", () => {
const source = `
PUSH 1
PUSH 2
MAKE_ARRAY #2
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(true)
})
test("detects PUSH_TRY without # or .label", () => {
const source = `
PUSH_TRY 5
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(false)
expect(result.errors[0]!.message).toContain("PUSH_TRY requires immediate (#number) or label (.label)")
})
test("detects PUSH_FINALLY without # or .label", () => {
const source = `
PUSH_FINALLY 5
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(false)
expect(result.errors[0]!.message).toContain("PUSH_FINALLY requires immediate (#number) or label (.label)")
})
test("allows PUSH_TRY with label", () => {
const source = `
PUSH_TRY .catch
PUSH 42
HALT
.catch:
PUSH null
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(true)
})