donobu
Version:
Create browser automations with an LLM agent and replay them as Playwright scripts.
622 lines • 20.8 kB
JavaScript
;
/**
* A minimal, safe JSONPath evaluator that replaces the `jsonpath` npm package.
*
* The `jsonpath` library (v1.2.1) has CVE GHSA-87r5-mp6g-5w5j due to its use
* of `eval()` for script expressions. This implementation covers the subset of
* JSONPath used by this project with NO dynamic code execution.
*
* Supported features:
* - Property access: $.user.name
* - Array indexing (positive & negative): $.calls[0], $.calls[-1]
* - Slice notation: $.calls[-2:], $.calls[1:], $.calls[0:2]
* - Filter expressions: $.books[?(@.price < 10)]
* - Logical AND in filters: [?(@.a < 5 && @.b == "x")]
* - Property existence filters: [?(@.isbn)]
* - Filter + index: $.books[?(@.price < 10)][0]
* - Recursive descent: $..author
* - Wildcards: $[*], $..books[*].title
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.queryJsonPath = queryJsonPath;
// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------
function tokenize(expression) {
const tokens = [];
let i = 0;
if (expression[i] !== '$') {
throw new Error(`JSONPath must start with $, got: ${expression}`);
}
tokens.push({ type: 'root' });
i++;
while (i < expression.length) {
if (expression[i] === '.') {
if (expression[i + 1] === '.') {
tokens.push({ type: 'dotdot' });
i += 2;
}
else {
tokens.push({ type: 'dot' });
i++;
}
}
else if (expression[i] === '[') {
i++; // skip '['
if (expression[i] === '?' && expression[i + 1] === '(') {
// Filter expression: find matching closing )]
i += 2; // skip '?('
let depth = 1;
let filterStart = i;
while (i < expression.length && depth > 0) {
if (expression[i] === '(') {
depth++;
}
else if (expression[i] === ')') {
depth--;
}
if (depth > 0) {
i++;
}
}
if (depth !== 0) {
throw new Error(`Unmatched filter parenthesis in: ${expression}`);
}
const raw = expression.substring(filterStart, i);
i++; // skip ')'
if (expression[i] !== ']') {
throw new Error(`Expected ] after filter expression in: ${expression}`);
}
i++; // skip ']'
tokens.push({ type: 'filter', raw });
}
else if (expression[i] === '*') {
i++; // skip '*'
if (expression[i] !== ']') {
throw new Error(`Expected ] after * in: ${expression}`);
}
i++; // skip ']'
tokens.push({ type: 'wildcard' });
}
else {
// Index or slice: read until ']'
const bracketStart = i;
while (i < expression.length && expression[i] !== ']') {
i++;
}
if (i >= expression.length) {
throw new Error(`Unmatched [ in: ${expression}`);
}
const content = expression.substring(bracketStart, i);
i++; // skip ']'
if (content.includes(':')) {
// Slice notation
const parts = content.split(':');
const start = parts[0] !== '' ? parseInt(parts[0], 10) : undefined;
const end = parts[1] !== '' ? parseInt(parts[1], 10) : undefined;
if ((parts[0] !== '' && isNaN(start)) ||
(parts[1] !== '' && isNaN(end))) {
throw new Error(`Invalid slice notation: [${content}]`);
}
tokens.push({ type: 'slice', start, end });
}
else {
// Integer index
const value = parseInt(content, 10);
if (isNaN(value)) {
throw new Error(`Invalid bracket expression: [${content}]`);
}
tokens.push({ type: 'index', value });
}
}
}
else if (expression[i] === '*') {
tokens.push({ type: 'wildcard' });
i++;
}
else if (isIdentStart(expression[i])) {
const start = i;
while (i < expression.length && isIdentChar(expression[i])) {
i++;
}
tokens.push({ type: 'property', name: expression.substring(start, i) });
}
else {
throw new Error(`Unexpected character '${expression[i]}' at position ${i} in: ${expression}`);
}
}
return tokens;
}
function isIdentStart(ch) {
return /[a-zA-Z_]/.test(ch);
}
function isIdentChar(ch) {
return /[a-zA-Z0-9_-]/.test(ch);
}
// ---------------------------------------------------------------------------
// Filter expression parser
// ---------------------------------------------------------------------------
const COMPARISON_OPS = [
'!==',
'===',
'!=',
'==',
'<=',
'>=',
'<',
'>',
];
function parseFilterCondition(raw) {
// Split on && (top-level only, respecting quotes)
const parts = splitOnLogicalAnd(raw);
if (parts.length > 1) {
return {
kind: 'and',
conditions: parts.map((p) => parseSingleCondition(p.trim())),
};
}
return parseSingleCondition(raw.trim());
}
function splitOnLogicalAnd(raw) {
const parts = [];
let current = '';
let inString = null;
let depth = 0;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (inString) {
current += ch;
if (ch === inString && raw[i - 1] !== '\\') {
inString = null;
}
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
current += ch;
continue;
}
if (ch === '(') {
depth++;
current += ch;
continue;
}
if (ch === ')') {
depth--;
current += ch;
continue;
}
if (depth === 0 && ch === '&' && raw[i + 1] === '&') {
parts.push(current);
current = '';
i++; // skip second &
continue;
}
current += ch;
}
parts.push(current);
return parts;
}
function parseSingleCondition(expr) {
// Try to find a comparison operator
for (const op of COMPARISON_OPS) {
const idx = findOperatorIndex(expr, op);
if (idx !== -1) {
const left = expr.substring(0, idx).trim();
const right = expr.substring(idx + op.length).trim();
return {
kind: 'comparison',
left: parseOperand(left),
op,
right: parseOperand(right),
};
}
}
// No operator found — existence check
if (expr.startsWith('@.')) {
return { kind: 'existence', segments: expr.substring(2).split('.') };
}
throw new Error(`Cannot parse filter condition: ${expr}`);
}
function findOperatorIndex(expr, op) {
let inString = null;
for (let i = 0; i < expr.length - op.length + 1; i++) {
const ch = expr[i];
if (inString) {
if (ch === inString && expr[i - 1] !== '\\') {
inString = null;
}
continue;
}
if (ch === '"' || ch === "'") {
inString = ch;
continue;
}
if (expr.substring(i, i + op.length) === op) {
// For single-char ops (< > = !), make sure we're not part of a multi-char op
if (op.length === 1) {
const next = expr[i + 1];
if (next === '=' || (op === '!' && next === '=')) {
continue;
}
}
if (op.length === 2 && (op === '==' || op === '!=')) {
const next = expr[i + 2];
if (next === '=') {
continue;
}
}
return i;
}
}
return -1;
}
function parseOperand(raw) {
const trimmed = raw.trim();
// @.path
if (trimmed.startsWith('@.')) {
return { kind: 'path', segments: trimmed.substring(2).split('.') };
}
// String literal
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return { kind: 'literal', value: trimmed.slice(1, -1) };
}
// Boolean
if (trimmed === 'true') {
return { kind: 'literal', value: true };
}
if (trimmed === 'false') {
return { kind: 'literal', value: false };
}
// Null
if (trimmed === 'null') {
return { kind: 'literal', value: null };
}
// Number
const num = Number(trimmed);
if (!isNaN(num) && trimmed !== '') {
return { kind: 'literal', value: num };
}
throw new Error(`Cannot parse filter operand: ${raw}`);
}
// ---------------------------------------------------------------------------
// Parser: tokens → path segments
// ---------------------------------------------------------------------------
function parse(tokens) {
const segments = [];
let i = 0;
while (i < tokens.length) {
const tok = tokens[i];
switch (tok.type) {
case 'root':
segments.push({ type: 'root' });
i++;
break;
case 'dot':
// Next token should be a property or wildcard
i++;
break;
case 'dotdot': {
// Recursive descent — wrap the next meaningful segment
i++;
const next = tokens[i];
if (!next) {
throw new Error('Unexpected end after ..');
}
if (next.type === 'property') {
segments.push({
type: 'recursiveDescent',
target: { type: 'property', name: next.name },
});
}
else if (next.type === 'wildcard') {
segments.push({
type: 'recursiveDescent',
target: { type: 'wildcard' },
});
}
else if (next.type === 'filter') {
segments.push({
type: 'recursiveDescent',
target: {
type: 'filter',
condition: parseFilterCondition(next.raw),
},
});
}
else {
throw new Error(`Unexpected token after ..: ${next.type}`);
}
i++;
break;
}
case 'property':
segments.push({ type: 'property', name: tok.name });
i++;
break;
case 'index':
segments.push({ type: 'index', value: tok.value });
i++;
break;
case 'slice':
segments.push({ type: 'slice', start: tok.start, end: tok.end });
i++;
break;
case 'wildcard':
segments.push({ type: 'wildcard' });
i++;
break;
case 'filter': {
const condition = parseFilterCondition(tok.raw);
// Check if next token is an index — combine into filterThenIndex
if (i + 1 < tokens.length && tokens[i + 1].type === 'index') {
const idxTok = tokens[i + 1];
segments.push({
type: 'filterThenIndex',
condition,
index: idxTok.value,
});
i += 2;
}
else {
segments.push({ type: 'filter', condition });
i++;
}
break;
}
}
}
return segments;
}
// ---------------------------------------------------------------------------
// Evaluator
// ---------------------------------------------------------------------------
function evaluate(data, segments) {
let current = [];
for (const seg of segments) {
switch (seg.type) {
case 'root':
current = [data];
break;
case 'property':
current = applyProperty(current, seg.name);
break;
case 'index':
current = applyIndex(current, seg.value);
break;
case 'slice':
current = applySlice(current, seg.start, seg.end);
break;
case 'wildcard':
current = applyWildcard(current);
break;
case 'filter':
current = applyFilter(current, seg.condition);
break;
case 'filterThenIndex':
current = applyFilterThenIndex(current, seg.condition, seg.index);
break;
case 'recursiveDescent':
current = applyRecursiveDescent(current, seg.target);
break;
}
}
return current;
}
function applyProperty(nodes, name) {
const results = [];
for (const node of nodes) {
if (node !== null && typeof node === 'object' && !Array.isArray(node)) {
const val = node[name];
if (val !== undefined) {
results.push(val);
}
}
}
return results;
}
function applyIndex(nodes, index) {
const results = [];
for (const node of nodes) {
if (Array.isArray(node)) {
const actual = index < 0 ? node.length + index : index;
if (actual >= 0 && actual < node.length) {
results.push(node[actual]);
}
}
}
return results;
}
function applySlice(nodes, start, end) {
const results = [];
for (const node of nodes) {
if (Array.isArray(node)) {
const s = start !== undefined ? (start < 0 ? node.length + start : start) : 0;
const e = end !== undefined ? (end < 0 ? node.length + end : end) : node.length;
const sliced = node.slice(s, e);
for (const item of sliced) {
results.push(item);
}
}
}
return results;
}
function applyWildcard(nodes) {
const results = [];
for (const node of nodes) {
if (Array.isArray(node)) {
for (const item of node) {
results.push(item);
}
}
else if (node !== null && typeof node === 'object') {
for (const val of Object.values(node)) {
results.push(val);
}
}
}
return results;
}
function applyFilter(nodes, condition) {
const results = [];
for (const node of nodes) {
if (Array.isArray(node)) {
for (const item of node) {
if (evaluateCondition(item, condition)) {
results.push(item);
}
}
}
}
return results;
}
function applyFilterThenIndex(nodes, condition, index) {
const results = [];
for (const node of nodes) {
if (Array.isArray(node)) {
const filtered = node.filter((item) => evaluateCondition(item, condition));
const actual = index < 0 ? filtered.length + index : index;
if (actual >= 0 && actual < filtered.length) {
results.push(filtered[actual]);
}
}
}
return results;
}
function applyRecursiveDescent(nodes, target) {
// Collect all descendants from every current node
const allDescendants = [];
for (const node of nodes) {
collectDescendants(node, allDescendants);
}
// Apply the target segment to each descendant
switch (target.type) {
case 'property': {
const results = [];
for (const d of allDescendants) {
if (d !== null && typeof d === 'object' && !Array.isArray(d)) {
const val = d[target.name];
if (val !== undefined) {
results.push(val);
}
}
}
return results;
}
case 'wildcard': {
const results = [];
for (const d of allDescendants) {
if (Array.isArray(d)) {
for (const item of d) {
results.push(item);
}
}
}
return results;
}
case 'filter': {
const results = [];
for (const d of allDescendants) {
if (Array.isArray(d)) {
for (const item of d) {
if (evaluateCondition(item, target.condition)) {
results.push(item);
}
}
}
}
return results;
}
default:
throw new Error(`Unsupported recursive descent target: ${target.type}`);
}
}
function collectDescendants(node, out) {
out.push(node);
if (node !== null && typeof node === 'object') {
const values = Array.isArray(node)
? node
: Object.values(node);
for (const val of values) {
collectDescendants(val, out);
}
}
}
// ---------------------------------------------------------------------------
// Filter condition evaluator
// ---------------------------------------------------------------------------
function evaluateCondition(node, condition) {
switch (condition.kind) {
case 'comparison': {
const left = resolveOperand(node, condition.left);
const right = resolveOperand(node, condition.right);
return compare(left, right, condition.op);
}
case 'and':
return condition.conditions.every((c) => evaluateCondition(node, c));
case 'existence': {
const val = resolvePathOnNode(node, condition.segments);
return val !== undefined;
}
}
}
function resolveOperand(node, operand) {
if (operand.kind === 'literal') {
return operand.value;
}
return resolvePathOnNode(node, operand.segments);
}
function resolvePathOnNode(node, segments) {
let current = node;
for (const seg of segments) {
if (current === null ||
current === undefined ||
typeof current !== 'object') {
return undefined;
}
current = current[seg];
}
return current;
}
function compare(left, right, op) {
switch (op) {
case '===':
return left === right;
case '!==':
return left !== right;
/* eslint-disable eqeqeq -- JSONPath == and != operators require loose equality */
case '==':
return left == right;
case '!=':
return left != right;
/* eslint-enable eqeqeq */
case '<':
return left < right;
case '<=':
return left <= right;
case '>':
return left > right;
case '>=':
return left >= right;
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Evaluate a JSONPath expression against a data object.
*
* Drop-in replacement for `jsonpath.query()` covering the subset of JSONPath
* used by this project. Uses no `eval()` or dynamic code execution.
*
* @param data - The data to query
* @param expression - A JSONPath expression (must start with $)
* @returns An array of matching values
*/
function queryJsonPath(data, expression) {
const tokens = tokenize(expression);
const segments = parse(tokens);
return evaluate(data, segments);
}
//# sourceMappingURL=JsonPath.js.map