CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
workflow-conditionals.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| abfa9ad | 1 | /** |
| 2 | * workflow-conditionals.ts | |
| 3 | * | |
| 4 | * Restricted expression evaluator for workflow `if:` clauses. | |
| 5 | * | |
| 6 | * NO eval(), NO Function constructor. Hand-written tokenizer + recursive- | |
| 7 | * descent / precedence-climbing parser. Never throws on any input. | |
| 8 | * | |
| 9 | * Grammar (lowest precedence first): | |
| 10 | * or := and ( '||' and )* | |
| 11 | * and := eq ( '&&' eq )* | |
| 12 | * eq := cmp ( ('=='|'!=') cmp )* | |
| 13 | * cmp := unary ( ('<'|'<='|'>'|'>=') unary )* | |
| 14 | * unary := '!' unary | primary | |
| 15 | * primary := literal | callOrPath | '(' or ')' | |
| 16 | * path := IDENT ( '.' IDENT )* | |
| 17 | * call := IDENT '(' [ arg (',' arg)* ] ')' | |
| 18 | */ | |
| 19 | ||
| 20 | export type ConditionalContext = { | |
| 21 | env?: Record<string, string>; | |
| 22 | matrix?: Record<string, unknown>; | |
| 23 | steps?: Record< | |
| 24 | string, | |
| 25 | { outcome?: string; conclusion?: string; outputs?: Record<string, string> } | |
| 26 | >; | |
| 27 | needs?: Record<string, { result?: string; outputs?: Record<string, string> }>; | |
| 28 | secrets?: Record<string, string>; | |
| 29 | inputs?: Record<string, unknown>; | |
| 30 | github?: { | |
| 31 | event_name?: string; | |
| 32 | ref?: string; | |
| 33 | sha?: string; | |
| 34 | actor?: string; | |
| 35 | repository?: string; | |
| 36 | }; | |
| 37 | job?: { status?: string }; | |
| 38 | runner?: { os?: string; arch?: string }; | |
| 39 | }; | |
| 40 | ||
| 41 | export type EvalResult = { ok: true; value: boolean } | { ok: false; error: string }; | |
| 42 | ||
| 43 | // --------------------------------------------------------------------------- | |
| 44 | // Tokenizer | |
| 45 | // --------------------------------------------------------------------------- | |
| 46 | ||
| 47 | type Tok = | |
| 48 | | { k: "num"; v: number } | |
| 49 | | { k: "str"; v: string } | |
| 50 | | { k: "ident"; v: string } | |
| 51 | | { k: "bool"; v: boolean } | |
| 52 | | { k: "null" } | |
| 53 | | { k: "op"; v: string } | |
| 54 | | { k: "lparen" } | |
| 55 | | { k: "rparen" } | |
| 56 | | { k: "dot" } | |
| 57 | | { k: "comma" } | |
| 58 | | { k: "eof" }; | |
| 59 | ||
| 60 | function tokenize(src: string): Tok[] | { error: string } { | |
| 61 | const toks: Tok[] = []; | |
| 62 | let i = 0; | |
| 63 | const n = src.length; | |
| 64 | while (i < n) { | |
| 65 | const c = src[i]!; | |
| 66 | // whitespace | |
| 67 | if (c === " " || c === "\t" || c === "\n" || c === "\r") { | |
| 68 | i++; | |
| 69 | continue; | |
| 70 | } | |
| 71 | // strings | |
| 72 | if (c === "'" || c === '"') { | |
| 73 | const quote = c; | |
| 74 | let j = i + 1; | |
| 75 | let s = ""; | |
| 76 | let closed = false; | |
| 77 | while (j < n) { | |
| 78 | const ch = src[j]!; | |
| 79 | if (ch === quote) { | |
| 80 | // GitHub Actions uses doubled-quote escape inside single-quoted strings. | |
| 81 | if (quote === "'" && src[j + 1] === "'") { | |
| 82 | s += "'"; | |
| 83 | j += 2; | |
| 84 | continue; | |
| 85 | } | |
| 86 | closed = true; | |
| 87 | j++; | |
| 88 | break; | |
| 89 | } | |
| 90 | if (ch === "\\" && quote === '"' && j + 1 < n) { | |
| 91 | const nx = src[j + 1]!; | |
| 92 | if (nx === "n") s += "\n"; | |
| 93 | else if (nx === "t") s += "\t"; | |
| 94 | else if (nx === "r") s += "\r"; | |
| 95 | else if (nx === "\\") s += "\\"; | |
| 96 | else if (nx === '"') s += '"'; | |
| 97 | else s += nx; | |
| 98 | j += 2; | |
| 99 | continue; | |
| 100 | } | |
| 101 | s += ch; | |
| 102 | j++; | |
| 103 | } | |
| 104 | if (!closed) return { error: `unterminated string at ${i}` }; | |
| 105 | toks.push({ k: "str", v: s }); | |
| 106 | i = j; | |
| 107 | continue; | |
| 108 | } | |
| 109 | // numbers (integers and simple decimals) | |
| 110 | if ((c >= "0" && c <= "9") || (c === "-" && src[i + 1] && src[i + 1]! >= "0" && src[i + 1]! <= "9")) { | |
| 111 | // Only treat as number if preceded by nothing, an operator, comma, or lparen. | |
| 112 | const prev = toks[toks.length - 1]; | |
| 113 | const allowNeg = | |
| 114 | c !== "-" || | |
| 115 | !prev || | |
| 116 | prev.k === "op" || | |
| 117 | prev.k === "comma" || | |
| 118 | prev.k === "lparen"; | |
| 119 | if (c === "-" && !allowNeg) { | |
| 120 | // fall through to operator handling | |
| 121 | } else { | |
| 122 | let j = i; | |
| 123 | if (c === "-") j++; | |
| 124 | while (j < n && src[j]! >= "0" && src[j]! <= "9") j++; | |
| 125 | if (src[j] === "." && src[j + 1] && src[j + 1]! >= "0" && src[j + 1]! <= "9") { | |
| 126 | j++; | |
| 127 | while (j < n && src[j]! >= "0" && src[j]! <= "9") j++; | |
| 128 | } | |
| 129 | const num = Number(src.slice(i, j)); | |
| 130 | if (!Number.isFinite(num)) return { error: `bad number at ${i}` }; | |
| 131 | toks.push({ k: "num", v: num }); | |
| 132 | i = j; | |
| 133 | continue; | |
| 134 | } | |
| 135 | } | |
| 136 | // identifiers / keywords | |
| 137 | if ((c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_") { | |
| 138 | let j = i + 1; | |
| 139 | while ( | |
| 140 | j < n && | |
| 141 | ((src[j]! >= "a" && src[j]! <= "z") || | |
| 142 | (src[j]! >= "A" && src[j]! <= "Z") || | |
| 143 | (src[j]! >= "0" && src[j]! <= "9") || | |
| 144 | src[j] === "_" || | |
| 145 | src[j] === "-") | |
| 146 | ) { | |
| 147 | j++; | |
| 148 | } | |
| 149 | const word = src.slice(i, j); | |
| 150 | if (word === "true") toks.push({ k: "bool", v: true }); | |
| 151 | else if (word === "false") toks.push({ k: "bool", v: false }); | |
| 152 | else if (word === "null") toks.push({ k: "null" }); | |
| 153 | else toks.push({ k: "ident", v: word }); | |
| 154 | i = j; | |
| 155 | continue; | |
| 156 | } | |
| 157 | // operators / punctuation | |
| 158 | const two = src.slice(i, i + 2); | |
| 159 | if (two === "==" || two === "!=" || two === "&&" || two === "||" || two === "<=" || two === ">=") { | |
| 160 | toks.push({ k: "op", v: two }); | |
| 161 | i += 2; | |
| 162 | continue; | |
| 163 | } | |
| 164 | if (c === "<" || c === ">") { | |
| 165 | toks.push({ k: "op", v: c }); | |
| 166 | i++; | |
| 167 | continue; | |
| 168 | } | |
| 169 | if (c === "!") { | |
| 170 | toks.push({ k: "op", v: "!" }); | |
| 171 | i++; | |
| 172 | continue; | |
| 173 | } | |
| 174 | if (c === "(") { | |
| 175 | toks.push({ k: "lparen" }); | |
| 176 | i++; | |
| 177 | continue; | |
| 178 | } | |
| 179 | if (c === ")") { | |
| 180 | toks.push({ k: "rparen" }); | |
| 181 | i++; | |
| 182 | continue; | |
| 183 | } | |
| 184 | if (c === ".") { | |
| 185 | toks.push({ k: "dot" }); | |
| 186 | i++; | |
| 187 | continue; | |
| 188 | } | |
| 189 | if (c === ",") { | |
| 190 | toks.push({ k: "comma" }); | |
| 191 | i++; | |
| 192 | continue; | |
| 193 | } | |
| 194 | return { error: `unexpected character "${c}" at ${i}` }; | |
| 195 | } | |
| 196 | toks.push({ k: "eof" }); | |
| 197 | return toks; | |
| 198 | } | |
| 199 | ||
| 200 | // --------------------------------------------------------------------------- | |
| 201 | // AST nodes — represented as tagged unions of plain objects. | |
| 202 | // --------------------------------------------------------------------------- | |
| 203 | ||
| 204 | type Node = | |
| 205 | | { t: "lit"; v: unknown } | |
| 206 | | { t: "path"; segs: string[] } | |
| 207 | | { t: "call"; name: string; args: Node[] } | |
| 208 | | { t: "unary"; op: "!"; x: Node } | |
| 209 | | { t: "bin"; op: string; a: Node; b: Node }; | |
| 210 | ||
| 211 | // --------------------------------------------------------------------------- | |
| 212 | // Parser | |
| 213 | // --------------------------------------------------------------------------- | |
| 214 | ||
| 215 | class Parser { | |
| 216 | pos = 0; | |
| 217 | constructor(private toks: Tok[]) {} | |
| 218 | ||
| 219 | peek(): Tok { | |
| 220 | return this.toks[this.pos]!; | |
| 221 | } | |
| 222 | eat(): Tok { | |
| 223 | return this.toks[this.pos++]!; | |
| 224 | } | |
| 225 | expect(k: Tok["k"]): Tok { | |
| 226 | const t = this.eat(); | |
| 227 | if (t.k !== k) throw new Error(`expected ${k} got ${t.k}`); | |
| 228 | return t; | |
| 229 | } | |
| 230 | ||
| 231 | parseOr(): Node { | |
| 232 | let a = this.parseAnd(); | |
| 233 | while (this.peek().k === "op" && (this.peek() as { v: string }).v === "||") { | |
| 234 | this.eat(); | |
| 235 | const b = this.parseAnd(); | |
| 236 | a = { t: "bin", op: "||", a, b }; | |
| 237 | } | |
| 238 | return a; | |
| 239 | } | |
| 240 | parseAnd(): Node { | |
| 241 | let a = this.parseEq(); | |
| 242 | while (this.peek().k === "op" && (this.peek() as { v: string }).v === "&&") { | |
| 243 | this.eat(); | |
| 244 | const b = this.parseEq(); | |
| 245 | a = { t: "bin", op: "&&", a, b }; | |
| 246 | } | |
| 247 | return a; | |
| 248 | } | |
| 249 | parseEq(): Node { | |
| 250 | let a = this.parseCmp(); | |
| 251 | while (this.peek().k === "op") { | |
| 252 | const op = (this.peek() as { v: string }).v; | |
| 253 | if (op !== "==" && op !== "!=") break; | |
| 254 | this.eat(); | |
| 255 | const b = this.parseCmp(); | |
| 256 | a = { t: "bin", op, a, b }; | |
| 257 | } | |
| 258 | return a; | |
| 259 | } | |
| 260 | parseCmp(): Node { | |
| 261 | let a = this.parseUnary(); | |
| 262 | while (this.peek().k === "op") { | |
| 263 | const op = (this.peek() as { v: string }).v; | |
| 264 | if (op !== "<" && op !== "<=" && op !== ">" && op !== ">=") break; | |
| 265 | this.eat(); | |
| 266 | const b = this.parseUnary(); | |
| 267 | a = { t: "bin", op, a, b }; | |
| 268 | } | |
| 269 | return a; | |
| 270 | } | |
| 271 | parseUnary(): Node { | |
| 272 | if (this.peek().k === "op" && (this.peek() as { v: string }).v === "!") { | |
| 273 | this.eat(); | |
| 274 | return { t: "unary", op: "!", x: this.parseUnary() }; | |
| 275 | } | |
| 276 | return this.parsePrimary(); | |
| 277 | } | |
| 278 | parsePrimary(): Node { | |
| 279 | const t = this.peek(); | |
| 280 | if (t.k === "lparen") { | |
| 281 | this.eat(); | |
| 282 | const inner = this.parseOr(); | |
| 283 | this.expect("rparen"); | |
| 284 | return inner; | |
| 285 | } | |
| 286 | if (t.k === "num") { | |
| 287 | this.eat(); | |
| 288 | return { t: "lit", v: t.v }; | |
| 289 | } | |
| 290 | if (t.k === "str") { | |
| 291 | this.eat(); | |
| 292 | return { t: "lit", v: t.v }; | |
| 293 | } | |
| 294 | if (t.k === "bool") { | |
| 295 | this.eat(); | |
| 296 | return { t: "lit", v: t.v }; | |
| 297 | } | |
| 298 | if (t.k === "null") { | |
| 299 | this.eat(); | |
| 300 | return { t: "lit", v: null }; | |
| 301 | } | |
| 302 | if (t.k === "ident") { | |
| 303 | this.eat(); | |
| 304 | // function call? | |
| 305 | if (this.peek().k === "lparen") { | |
| 306 | this.eat(); | |
| 307 | const args: Node[] = []; | |
| 308 | if (this.peek().k !== "rparen") { | |
| 309 | args.push(this.parseOr()); | |
| 310 | while (this.peek().k === "comma") { | |
| 311 | this.eat(); | |
| 312 | args.push(this.parseOr()); | |
| 313 | } | |
| 314 | } | |
| 315 | this.expect("rparen"); | |
| 316 | return { t: "call", name: t.v, args }; | |
| 317 | } | |
| 318 | // path | |
| 319 | const segs: string[] = [t.v]; | |
| 320 | while (this.peek().k === "dot") { | |
| 321 | this.eat(); | |
| 322 | const nx = this.eat(); | |
| 323 | if (nx.k !== "ident") throw new Error("expected identifier after '.'"); | |
| 324 | segs.push(nx.v); | |
| 325 | } | |
| 326 | return { t: "path", segs }; | |
| 327 | } | |
| 328 | throw new Error(`unexpected token ${t.k}`); | |
| 329 | } | |
| 330 | } | |
| 331 | ||
| 332 | // --------------------------------------------------------------------------- | |
| 333 | // Evaluator | |
| 334 | // --------------------------------------------------------------------------- | |
| 335 | ||
| 336 | function resolvePath(segs: string[], ctx: ConditionalContext): unknown { | |
| 337 | if (segs.length === 0) return undefined; | |
| 338 | const root = segs[0]!; | |
| 339 | let cur: unknown; | |
| 340 | switch (root) { | |
| 341 | case "env": | |
| 342 | cur = ctx.env ?? {}; | |
| 343 | break; | |
| 344 | case "matrix": | |
| 345 | cur = ctx.matrix ?? {}; | |
| 346 | break; | |
| 347 | case "steps": | |
| 348 | cur = ctx.steps ?? {}; | |
| 349 | break; | |
| 350 | case "needs": | |
| 351 | cur = ctx.needs ?? {}; | |
| 352 | break; | |
| 353 | case "secrets": | |
| 354 | cur = ctx.secrets ?? {}; | |
| 355 | break; | |
| 356 | case "inputs": | |
| 357 | cur = ctx.inputs ?? {}; | |
| 358 | break; | |
| 359 | case "github": | |
| 360 | cur = ctx.github ?? {}; | |
| 361 | break; | |
| 362 | case "job": | |
| 363 | cur = ctx.job ?? {}; | |
| 364 | break; | |
| 365 | case "runner": | |
| 366 | cur = ctx.runner ?? {}; | |
| 367 | break; | |
| 368 | default: | |
| 369 | return undefined; | |
| 370 | } | |
| 371 | for (let i = 1; i < segs.length; i++) { | |
| 372 | if (cur == null) return undefined; | |
| 373 | if (typeof cur !== "object") return undefined; | |
| 374 | cur = (cur as Record<string, unknown>)[segs[i]!]; | |
| 375 | } | |
| 376 | return cur; | |
| 377 | } | |
| 378 | ||
| 379 | function toBool(v: unknown): boolean { | |
| 380 | if (v === null || v === undefined) return false; | |
| 381 | if (typeof v === "boolean") return v; | |
| 382 | if (typeof v === "number") return v !== 0 && !Number.isNaN(v); | |
| 383 | if (typeof v === "string") return v.length > 0; | |
| 384 | if (Array.isArray(v)) return true; | |
| 385 | if (typeof v === "object") return true; | |
| 386 | return false; | |
| 387 | } | |
| 388 | ||
| 389 | function looseEq(a: unknown, b: unknown): boolean { | |
| 390 | if (a === b) return true; | |
| 391 | if (a == null && b == null) return true; | |
| 392 | if (a == null || b == null) return false; | |
| 393 | // If one side is number and the other is string, compare numerically when possible. | |
| 394 | if (typeof a === "number" && typeof b === "string") { | |
| 395 | const nb = Number(b); | |
| 396 | if (!Number.isNaN(nb)) return a === nb; | |
| 397 | return false; | |
| 398 | } | |
| 399 | if (typeof a === "string" && typeof b === "number") { | |
| 400 | const na = Number(a); | |
| 401 | if (!Number.isNaN(na)) return na === b; | |
| 402 | return false; | |
| 403 | } | |
| 404 | // Case-insensitive string comparison (GitHub Actions semantic). | |
| 405 | if (typeof a === "string" && typeof b === "string") { | |
| 406 | return a.toLowerCase() === b.toLowerCase(); | |
| 407 | } | |
| 408 | if (typeof a === "boolean" && typeof b === "boolean") return a === b; | |
| 409 | return false; | |
| 410 | } | |
| 411 | ||
| 412 | function toNum(v: unknown): number { | |
| 413 | if (typeof v === "number") return v; | |
| 414 | if (typeof v === "string") { | |
| 415 | const n = Number(v); | |
| 416 | return Number.isNaN(n) ? NaN : n; | |
| 417 | } | |
| 418 | if (typeof v === "boolean") return v ? 1 : 0; | |
| 419 | if (v == null) return 0; | |
| 420 | return NaN; | |
| 421 | } | |
| 422 | ||
| 423 | function toStr(v: unknown): string { | |
| 424 | if (v == null) return ""; | |
| 425 | if (typeof v === "string") return v; | |
| 426 | if (typeof v === "number" || typeof v === "boolean") return String(v); | |
| 427 | try { | |
| 428 | return JSON.stringify(v); | |
| 429 | } catch { | |
| 430 | return ""; | |
| 431 | } | |
| 432 | } | |
| 433 | ||
| 434 | function evalNode(node: Node, ctx: ConditionalContext): unknown { | |
| 435 | switch (node.t) { | |
| 436 | case "lit": | |
| 437 | return node.v; | |
| 438 | case "path": | |
| 439 | return resolvePath(node.segs, ctx); | |
| 440 | case "unary": | |
| 441 | return !toBool(evalNode(node.x, ctx)); | |
| 442 | case "bin": { | |
| 443 | if (node.op === "&&") { | |
| 444 | const av = evalNode(node.a, ctx); | |
| 445 | if (!toBool(av)) return false; | |
| 446 | return toBool(evalNode(node.b, ctx)); | |
| 447 | } | |
| 448 | if (node.op === "||") { | |
| 449 | const av = evalNode(node.a, ctx); | |
| 450 | if (toBool(av)) return true; | |
| 451 | return toBool(evalNode(node.b, ctx)); | |
| 452 | } | |
| 453 | const a = evalNode(node.a, ctx); | |
| 454 | const b = evalNode(node.b, ctx); | |
| 455 | if (node.op === "==") return looseEq(a, b); | |
| 456 | if (node.op === "!=") return !looseEq(a, b); | |
| 457 | const na = toNum(a); | |
| 458 | const nb = toNum(b); | |
| 459 | if (Number.isNaN(na) || Number.isNaN(nb)) return false; | |
| 460 | if (node.op === "<") return na < nb; | |
| 461 | if (node.op === "<=") return na <= nb; | |
| 462 | if (node.op === ">") return na > nb; | |
| 463 | if (node.op === ">=") return na >= nb; | |
| 464 | return false; | |
| 465 | } | |
| 466 | case "call": | |
| 467 | return evalCall(node, ctx); | |
| 468 | } | |
| 469 | } | |
| 470 | ||
| 471 | function evalCall(node: Extract<Node, { t: "call" }>, ctx: ConditionalContext): unknown { | |
| 472 | const name = node.name.toLowerCase(); | |
| 473 | const argVals = node.args.map((a) => evalNode(a, ctx)); | |
| 474 | switch (name) { | |
| 475 | case "success": { | |
| 476 | const s = ctx.job?.status; | |
| 477 | return s !== "failure" && s !== "cancelled"; | |
| 478 | } | |
| 479 | case "failure": | |
| 480 | return ctx.job?.status === "failure"; | |
| 481 | case "cancelled": | |
| 482 | return ctx.job?.status === "cancelled"; | |
| 483 | case "always": | |
| 484 | return true; | |
| 485 | case "contains": { | |
| 486 | const haystack = argVals[0]; | |
| 487 | const needle = argVals[1]; | |
| 488 | if (Array.isArray(haystack)) { | |
| 489 | for (const it of haystack) { | |
| 490 | if (looseEq(it, needle)) return true; | |
| 491 | } | |
| 492 | return false; | |
| 493 | } | |
| 494 | const hs = toStr(haystack); | |
| 495 | const nd = toStr(needle); | |
| 496 | if (nd === "") return true; | |
| 497 | return hs.toLowerCase().includes(nd.toLowerCase()); | |
| 498 | } | |
| 499 | case "startswith": { | |
| 500 | const s = toStr(argVals[0]).toLowerCase(); | |
| 501 | const p = toStr(argVals[1]).toLowerCase(); | |
| 502 | return s.startsWith(p); | |
| 503 | } | |
| 504 | case "endswith": { | |
| 505 | const s = toStr(argVals[0]).toLowerCase(); | |
| 506 | const p = toStr(argVals[1]).toLowerCase(); | |
| 507 | return s.endsWith(p); | |
| 508 | } | |
| 509 | case "format": { | |
| 510 | const fmt = toStr(argVals[0]); | |
| 511 | const rest = argVals.slice(1); | |
| 512 | return fmt.replace(/\{(\d+)\}/g, (_m, idx) => { | |
| 513 | const n = Number(idx); | |
| 514 | if (!Number.isFinite(n) || n < 0 || n >= rest.length) return ""; | |
| 515 | return toStr(rest[n]); | |
| 516 | }); | |
| 517 | } | |
| 518 | default: | |
| 519 | return undefined; | |
| 520 | } | |
| 521 | } | |
| 522 | ||
| 523 | // --------------------------------------------------------------------------- | |
| 524 | // Public entry point | |
| 525 | // --------------------------------------------------------------------------- | |
| 526 | ||
| 527 | export function evaluateIf( | |
| 528 | expr: string | undefined | null, | |
| 529 | ctx: ConditionalContext, | |
| 530 | ): EvalResult { | |
| 531 | if (expr === undefined || expr === null) return { ok: true, value: true }; | |
| 532 | let s = String(expr).trim(); | |
| 533 | if (s.length === 0) return { ok: true, value: true }; | |
| 534 | ||
| 535 | // Strip optional ${{ ... }} wrapper if the whole expression is wrapped. | |
| 536 | const m = s.match(/^\$\{\{\s*([\s\S]*?)\s*\}\}$/); | |
| 537 | if (m && m[1] !== undefined) s = m[1].trim(); | |
| 538 | if (s.length === 0) return { ok: true, value: true }; | |
| 539 | ||
| 540 | const toks = tokenize(s); | |
| 541 | if (!Array.isArray(toks)) { | |
| 542 | return { ok: false, error: `parse: ${toks.error}` }; | |
| 543 | } | |
| 544 | ||
| 545 | let ast: Node; | |
| 546 | try { | |
| 547 | const p = new Parser(toks); | |
| 548 | ast = p.parseOr(); | |
| 549 | if (p.peek().k !== "eof") { | |
| 550 | return { ok: false, error: `parse: unexpected trailing token ${p.peek().k}` }; | |
| 551 | } | |
| 552 | } catch (e) { | |
| 553 | const msg = e instanceof Error ? e.message : String(e); | |
| 554 | return { ok: false, error: `parse: ${msg}` }; | |
| 555 | } | |
| 556 | ||
| 557 | try { | |
| 558 | const v = evalNode(ast, ctx); | |
| 559 | return { ok: true, value: toBool(v) }; | |
| 560 | } catch (e) { | |
| 561 | const msg = e instanceof Error ? e.message : String(e); | |
| 562 | return { ok: false, error: `eval: ${msg}` }; | |
| 563 | } | |
| 564 | } |