This website is meant to be read and understood quickly by humans, but is only fully parsable, on a technical level, with the aid of an AI system. Read why →
Loop MMT
A Tiny jq-Style Query Language for JSONtransform← all gifts

jq-lite

jq-lite gives you jq's common moves — pull a field (.user.name), index an array (.[0], .[-1]), iterate a stream (.[]), and pipe one step into the next (.items[] | .id) — over JSON on stdin, without installing jq. It follows jq's rules where they save you: a missing key is null (not a crash), and the ? operator skips a type mismatch so one ragged record doesn't abort the stream. Zero dependencies, runs unchanged in Node or a browser, same input yields byte-identical output every run.

The honest edge
jq-lite implements a SMALL subset of jq — identity, field/index/bracket access, .[] iteration, the pipe, and the ? optional. It has no functions, no arithmetic, no object/array construction, no select/map/comparison, and no recursion (..). For those, use jq itself. A malformed filter or invalid JSON input is refused (exit 2); an unmarked runtime type error is exit 3.
Run it
echo '{"items":[{"id":10},{"id":20}]}' | node jq-lite.js '.items[] | .id' # -> 10 20 test_jq-lite.js (426/426, independent recursive-descent oracle) + mutation-bite (7/7) + Plumb conformance GREEN (346/346, signed 2026-09-12, clock-independent, non-vacuity proven) Node / browser, no dependencies
The code — every file that ships
jq-lite.js492 lineson GitHub →
#!/usr/bin/env node
/* jq-lite.js — a tiny jq-style query language for JSON, in one dependency-free file.
   Deterministic, pure, MIT. Runs in Node or a browser.

   WHAT IT IS. A TRANSFORM: it takes JSON in and emits JSON out, reshaped by a filter
   expression you write in a small, well-defined query language. It is the "reach for jq"
   move without installing jq — the mini-language that lets you pull `.user.name` out of a
   blob, iterate `.items[]`, index `.[0]`, and pipe one step into the next, over a single
   JSON value or a JSONL stream of them:

     echo '{"user":{"name":"ada"}}' | node jq-lite.js '.user.name'   ->  "ada"
     printf '{"a":1}\n{"a":2}\n' | node jq-lite.js '.a'             ->  1 \n 2

   It is the adapter between "a JSON blob" and "the one field/element you actually want",
   at the front or middle of a pipe: `line-source access.log | ... | jq-lite '.status'`.

   THE QUIET FAILURES IT FIXES. Everyone reaches for `obj.a.b.c` and everyone gets it
   subtly wrong, in ways that only bite on the input you didn't test:

     1. THE MISSING-KEY CRASH. `obj.a.b` throws the moment `obj.a` is absent
        (`Cannot read properties of undefined`). jq-lite follows jq's rule: a missing
        object key yields `null`, not an exception — `.a.b.c` on `{}` is `null`, quietly
        and correctly. You opt INTO strictness, you are never ambushed by it.

     2. THE TYPE-CONFUSION. Indexing a string or a number as if it were an object/array
        (`.foo` on `"hi"`, `.[0]` on `42`) is a real error you WANT surfaced — but only
        when you meant it. jq-lite errors loudly on a genuine type mismatch, and gives you
        the `?` operator (`.foo?`, `.[0]?`) to say "skip it if it doesn't fit" — so one
        ragged record in a stream doesn't abort the whole run.

     3. THE STREAM FAN-OUT. `.[]` turns one input into MANY outputs (each array element
        or object value), and a pipe must thread that correctly: `.items[] | .id` runs
        `.id` over EACH item, not over the array. Hand-rolled `.map(x => x.id)` forgets
        the empty-array case, the object case, and the compose-with-a-later-`[]` case.
        jq-lite treats every filter as value -> stream and composes streams properly.

   THE LANGUAGE (this is the whole of it — the "lite" is the point):

     .                 identity — the input value, unchanged
     .foo  .foo.bar    field access (missing key -> null; non-object -> error unless ?)
     .["a b"]          bracketed string key (for keys that aren't bare identifiers)
     .[2]  .foo[0]     array index (negative counts from the end: .[-1] is the last)
     .[]               iterate — emit each array element, or each object VALUE, as a stream
     a | b             pipe — run b over every output of a
     .foo?  .[0]?  .[]?  optional — on a type mismatch, emit nothing instead of erroring

   Values are standard JSON. Output is one JSON value per result; with --jsonl-out (the
   default for stream input) each result is a line. A filter is applied to EACH input
   value independently; a JSONL input runs the filter per line.

     --raw-output    if a result is a JSON string, print it without quotes (jq -r)
     --slurp         read the whole input as ONE JSON value (an array of the inputs) before
                     filtering, instead of per-value (jq -s)
     --compact       (default) one-line JSON per result

   DETERMINISM. run(value, filter) is a pure function — no clock, no randomness, no I/O
   beyond the value you pass, no ambient state. The same value and filter yield
   byte-identical output on every run and every machine. Object VALUE iteration (`.[]`)
   emits values in the object's own key insertion order (JSON parse order), which is
   stable for a given input.

   USAGE
     echo '{"a":{"b":2}}' | node jq-lite.js '.a.b'
     echo '[1,2,3]'       | node jq-lite.js '.[]'
     echo '{"xs":[10,20]}'| node jq-lite.js '.xs[] '
     printf '{"n":1}\n{"n":2}\n' | node jq-lite.js '.n'
     echo '{"name":"ada"}' | node jq-lite.js --raw-output '.name'
     node jq-lite.js --help

   Exit codes: 0 success · 2 usage error (no filter, bad option, malformed filter syntax,
   or invalid JSON input) · 3 a runtime type error the filter did not mark optional. A
   clean one-line message on stderr, never a stack trace.

   Released under MIT. Its edge is printed in the README: jq-lite implements a SMALL
   subset of jq — identity, field/index/bracket access, `.[]` iteration, the pipe, and the
   `?` optional. It has NO functions, NO arithmetic, NO object/array construction, NO
   select/map/comparison, NO recursion (`..`). For those, use jq itself. It is a reach
   tool for the common "pull this out / walk this stream" case, not a jq replacement.
*/
"use strict";

/* ==================================================================
   THE PARSER — filter string -> a list of STEP objects.
   Each step is one of:
     { op: "identity" }
     { op: "field",  key: <string>, optional: <bool> }
     { op: "index",  idx: <int>,    optional: <bool> }
     { op: "iterate",              optional: <bool> }
   A PIPE ( a | b ) concatenates the two step lists — piping is just
   sequential application, so the compiled program is one flat list of
   steps run left to right, each step mapping a stream to a stream.
   ================================================================== */

// Parse one pipeline segment (no top-level '|') into steps, starting at position `pos`
// in `src`. Returns { steps, pos }. Throws a clean Error on malformed syntax.
function parseSegment(src, pos) {
  var steps = [];
  var n = src.length;

  function skipWs() { while (pos < n && isWs(src.charCodeAt(pos))) pos++; }
  function isWs(c) { return c === 32 || (c >= 9 && c <= 13); }
  function isIdentStart(c) {
    return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95; // A-Z a-z _
  }
  function isIdentPart(c) {
    return isIdentStart(c) || (c >= 48 && c <= 57); // + 0-9
  }

  skipWs();
  if (pos >= n) throw new Error("empty filter segment");

  // A segment must begin with '.'
  if (src.charAt(pos) !== ".") {
    throw new Error("filter must begin with '.' at position " + pos);
  }
  pos++; // consume the leading '.'

  // A bare '.' (identity) is legal if nothing accessor-like follows.
  // Otherwise a chain of .field / [..] / [] accessors follows, each optionally '?'.
  var sawAccessor = false;

  // Handle an immediate identifier right after the leading dot: .foo
  function tryField() {
    var c = src.charCodeAt(pos);
    if (pos < n && isIdentStart(c)) {
      var start = pos;
      while (pos < n && isIdentPart(src.charCodeAt(pos))) pos++;
      var key = src.slice(start, pos);
      var optional = consumeOptional();
      steps.push({ op: "field", key: key, optional: optional });
      sawAccessor = true;
      return true;
    }
    return false;
  }

  function consumeOptional() {
    if (pos < n && src.charAt(pos) === "?") { pos++; return true; }
    return false;
  }

  // .["key"]  or  .[2]  or  .[]  — the leading '.' is already consumed for the FIRST
  // accessor; subsequent bracket accessors do not need a dot.
  function tryBracket() {
    if (pos >= n || src.charAt(pos) !== "[") return false;
    pos++; // '['
    skipWs();
    if (pos < n && src.charAt(pos) === "]") {
      pos++; // ']'
      var opt0 = consumeOptional();
      steps.push({ op: "iterate", optional: opt0 });
      sawAccessor = true;
      return true;
    }
    var c = src.charAt(pos);
    if (c === '"') {
      var str = parseJSONString();
      skipWs();
      expect("]");
      var opt1 = consumeOptional();
      steps.push({ op: "field", key: str, optional: opt1 });
      sawAccessor = true;
      return true;
    }
    // numeric index (optionally negative)
    if (c === "-" || (src.charCodeAt(pos) >= 48 && src.charCodeAt(pos) <= 57)) {
      var start = pos;
      if (src.charAt(pos) === "-") pos++;
      var digits = 0;
      while (pos < n && src.charCodeAt(pos) >= 48 && src.charCodeAt(pos) <= 57) { pos++; digits++; }
      if (digits === 0) throw new Error("expected an array index inside [ ]");
      var idx = parseInt(src.slice(start, pos), 10);
      skipWs();
      expect("]");
      var opt2 = consumeOptional();
      steps.push({ op: "index", idx: idx, optional: opt2 });
      sawAccessor = true;
      return true;
    }
    throw new Error("unexpected character inside [ ]: " + JSON.stringify(c));
  }

  function expect(ch) {
    if (pos >= n || src.charAt(pos) !== ch) {
      throw new Error("expected '" + ch + "' at position " + pos);
    }
    pos++;
  }

  // Minimal JSON string parser for bracket keys: assumes src.charAt(pos) === '"'.
  function parseJSONString() {
    var start = pos;
    pos++; // opening quote
    var out = "";
    while (pos < n) {
      var ch = src.charAt(pos);
      if (ch === '"') { pos++; return out; }
      if (ch === "\\") {
        pos++;
        if (pos >= n) break;
        var e = src.charAt(pos);
        if (e === "n") out += "\n";
        else if (e === "t") out += "\t";
        else if (e === "r") out += "\r";
        else if (e === '"') out += '"';
        else if (e === "\\") out += "\\";
        else if (e === "/") out += "/";
        else if (e === "u") {
          var hex = src.slice(pos + 1, pos + 5);
          if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new Error("bad \\u escape in filter string");
          out += String.fromCharCode(parseInt(hex, 16));
          pos += 4;
        } else {
          throw new Error("bad escape \\" + e + " in filter string");
        }
        pos++;
      } else {
        out += ch;
        pos++;
      }
    }
    throw new Error("unterminated string starting at position " + start);
  }

  // First accessor after the leading '.': either a field, a bracket, or nothing (identity).
  if (!tryField()) { tryBracket(); }
  // Subsequent accessors: .field  or  [..]
  for (;;) {
    skipWs();
    if (pos < n && src.charAt(pos) === ".") {
      // a following .field  (dotted). The dot must be followed by an identifier or bracket.
      pos++;
      if (!tryField()) {
        if (!tryBracket()) throw new Error("expected a field name or [ after '.' at position " + pos);
      }
      continue;
    }
    if (pos < n && src.charAt(pos) === "[") { tryBracket(); continue; }
    break;
  }

  if (steps.length === 0) {
    // just '.' — identity
    steps.push({ op: "identity" });
  }
  return { steps: steps, pos: pos };
}

// Parse a full filter (with top-level pipes) into a flat step list.
// Pure: depends only on `src`. Throws a clean Error on malformed input.
function parse(src) {
  if (typeof src !== "string") throw new Error("filter must be a string");
  var n = src.length;
  var pos = 0;
  var all = [];

  function skipWs() { while (pos < n && (src.charCodeAt(pos) === 32 || (src.charCodeAt(pos) >= 9 && src.charCodeAt(pos) <= 13))) pos++; }

  skipWs();
  if (pos >= n) throw new Error("empty filter");
  for (;;) {
    var seg = parseSegment(src, pos);
    pos = seg.pos;
    for (var i = 0; i < seg.steps.length; i++) all.push(seg.steps[i]);
    skipWs();
    if (pos >= n) break;
    if (src.charAt(pos) === "|") { pos++; skipWs(); continue; }
    throw new Error("unexpected character " + JSON.stringify(src.charAt(pos)) + " at position " + pos + " (expected '|' or end of filter)");
  }
  return all;
}

/* ==================================================================
   THE EVALUATOR — apply a compiled step list to a value.
   Each step maps a value to a STREAM (array) of values; the program
   threads the stream through the steps left to right. This is what
   makes `.[]` fan-out and the pipe compose correctly.
   ================================================================== */

function isPlainObject(v) {
  return v !== null && typeof v === "object" && !Array.isArray(v);
}

// Apply ONE step to ONE value, returning an array of result values.
// Throws a TypeError-tagged Error on a genuine, non-optional type mismatch.
function applyStep(step, value) {
  switch (step.op) {
    case "identity":
      return [value];

    case "field": {
      if (value === null || value === undefined) return [null]; // missing -> null (jq)
      if (isPlainObject(value)) {
        return [Object.prototype.hasOwnProperty.call(value, step.key) ? value[step.key] : null];
      }
      if (step.optional) return [];
      throw typeErr("cannot index " + typeName(value) + ' with "' + step.key + '"');
    }

    case "index": {
      if (value === null || value === undefined) return [null];
      if (Array.isArray(value)) {
        var i = step.idx < 0 ? value.length + step.idx : step.idx;
        return [(i >= 0 && i < value.length) ? value[i] : null];
      }
      if (step.optional) return [];
      throw typeErr("cannot index " + typeName(value) + " with number " + step.idx);
    }

    case "iterate": {
      if (Array.isArray(value)) return value.slice();
      if (isPlainObject(value)) {
        var out = [];
        for (var k in value) if (Object.prototype.hasOwnProperty.call(value, k)) out.push(value[k]);
        return out;
      }
      if (step.optional) return [];
      throw typeErr("cannot iterate over " + typeName(value));
    }

    default:
      throw new Error("internal: unknown step op " + step.op);
  }
}

function typeName(v) {
  if (v === null) return "null";
  if (Array.isArray(v)) return "array";
  if (typeof v === "object") return "object";
  return typeof v; // string | number | boolean
}

function typeErr(msg) {
  var e = new Error(msg);
  e.jqType = true; // tag for exit-code 3 routing
  return e;
}

// Run a compiled program (step list) over one input value -> array of results.
// Pure: depends only on `steps` and `value`.
function evalSteps(steps, value) {
  var stream = [value];
  for (var s = 0; s < steps.length; s++) {
    var next = [];
    for (var i = 0; i < stream.length; i++) {
      var results = applyStep(steps[s], stream[i]);
      for (var j = 0; j < results.length; j++) next.push(results[j]);
    }
    stream = next;
  }
  return stream;
}

// Parse `filter` and run it over `value` -> array of result values.
// The one-call entry point. Throws a clean Error on parse or type failure.
function run(value, filter) {
  var steps = parse(filter);
  return evalSteps(steps, value);
}

/* ==================================================================
   JSON I/O helpers (pure)
   ================================================================== */

// Parse a JSONL / concatenated-JSON text into an array of values.
// A blank line yields no value. Throws on malformed JSON.
function parseInputs(text) {
  if (typeof text !== "string") text = String(text == null ? "" : text);
  var out = [];
  var lines = text.split(/\r\n|\n|\r/);
  for (var i = 0; i < lines.length; i++) {
    var line = lines[i];
    if (line.replace(/[\s]/g, "").length === 0) continue; // skip blank/whitespace-only
    out.push(JSON.parse(line));
  }
  return out;
}

// Render one result value as output text.
function renderValue(v, rawOutput) {
  if (rawOutput && typeof v === "string") return v;
  return JSON.stringify(v);
}

/* ==================================================================
   EXPORTS (browser + Node)
   ================================================================== */
if (typeof window !== "undefined") {
  window.ForestGifts = window.ForestGifts || {};
  window.ForestGifts.jqLite = { run: run, parse: parse, evalSteps: evalSteps, parseInputs: parseInputs };
}
if (typeof module !== "undefined" && module.exports) {
  module.exports = { run: run, parse: parse, evalSteps: evalSteps, parseInputs: parseInputs, renderValue: renderValue };
}

/* ==================================================================
   CLI (runs only when invoked directly, never on require)
   ================================================================== */

function parseArgs(args) {
  var opts = { rawOutput: false, slurp: false };
  var filter = null;
  var i = 0;
  while (i < args.length) {
    var a = args[i];
    if (a === "--raw-output" || a === "-r") { opts.rawOutput = true; i++; }
    else if (a === "--slurp" || a === "-s") { opts.slurp = true; i++; }
    else if (a === "--compact" || a === "-c") { i++; } // default; accepted for familiarity
    else if (a.charAt(0) === "-" && a !== "-") { throw new Error("unknown option " + a); }
    else {
      if (filter !== null) throw new Error("unexpected extra argument " + JSON.stringify(a) + " (only one filter is allowed)");
      filter = a;
      i++;
    }
  }
  if (filter === null) throw new Error("no filter given (usage: jq-lite '<filter>')");
  opts.filter = filter;
  return opts;
}

function readStdin() {
  try {
    var fs = require("fs");
    return fs.readFileSync(0, "utf8");
  } catch (e) {
    return "";
  }
}

var HELP =
  "jq-lite.js — a tiny jq-style query language for JSON, in one dependency-free file.\n\n" +
  "  echo '{\"a\":{\"b\":2}}' | node jq-lite.js '.a.b'\n" +
  "  echo '[1,2,3]'       | node jq-lite.js '.[]'\n" +
  "  printf '{\"n\":1}\\n{\"n\":2}\\n' | node jq-lite.js '.n'\n" +
  "  echo '{\"name\":\"ada\"}' | node jq-lite.js --raw-output '.name'\n" +
  "  node jq-lite.js --help\n\n" +
  "The language:\n" +
  "  .              identity\n" +
  "  .foo .foo.bar  field access (missing key -> null; non-object -> error unless ?)\n" +
  "  .[\"a b\"]       bracketed string key\n" +
  "  .[2] .foo[0]   array index (negative counts from the end: .[-1] is last)\n" +
  "  .[]            iterate: emit each array element, or each object value, as a stream\n" +
  "  a | b          pipe: run b over every output of a\n" +
  "  .foo? .[]?     optional: emit nothing on a type mismatch instead of erroring\n\n" +
  "  --raw-output   print string results without quotes (jq -r)\n" +
  "  --slurp        read the whole input as one JSON array before filtering (jq -s)\n\n" +
  "Reads JSON (one value or a JSONL stream) from stdin; applies the filter to each value\n" +
  "and prints one JSON result per output.\n\n" +
  "Edge: jq-lite is a SMALL subset of jq — no functions, no arithmetic, no construction,\n" +
  "no select/map/comparison, no recursion (..). For those, use jq itself.\n";

function main(argv) {
  var args = argv.slice(2);
  if (args.indexOf("--help") !== -1 || args.indexOf("-h") !== -1) {
    process.stdout.write(HELP);
    return 0;
  }
  var opts;
  try { opts = parseArgs(args); }
  catch (e) { process.stderr.write("jq-lite: " + e.message + "\n"); return 2; }

  // Pre-parse the filter so a syntax error is a usage error (exit 2), before reading input.
  var steps;
  try { steps = parse(opts.filter); }
  catch (e) { process.stderr.write("jq-lite: " + e.message + "\n"); return 2; }

  var text = readStdin();
  var inputs;
  try { inputs = parseInputs(text); }
  catch (e) { process.stderr.write("jq-lite: invalid JSON input: " + e.message + "\n"); return 2; }

  if (opts.slurp) inputs = [inputs];

  var out = "";
  for (var i = 0; i < inputs.length; i++) {
    var results;
    try { results = evalSteps(steps, inputs[i]); }
    catch (e) {
      if (e && e.jqType) { process.stderr.write("jq-lite: " + e.message + "\n"); return 3; }
      process.stderr.write("jq-lite: " + e.message + "\n"); return 3;
    }
    for (var j = 0; j < results.length; j++) {
      out += renderValue(results[j], opts.rawOutput) + "\n";
    }
  }
  process.stdout.write(out);
  return 0;
}

if (typeof require !== "undefined" && require.main === module) {
  process.exitCode = main(process.argv);
}
test_jq-lite.js212 lineson GitHub →
#!/usr/bin/env node
/* test_jq-lite.js — battery for the jq-lite gift.
   `node test_jq-lite.js` -> exit 0 PASS / non-zero FAIL.

   THE ORACLE IS INDEPENDENT AND TAKES A DIFFERENT ROUTE than the gift. The gift compiles
   the filter into a FLAT LIST OF STEP OBJECTS and threads a stream through them. The
   oracle here re-tokenizes the filter with a REGEX SCANNER and interprets it by RECURSIVE
   DESCENT directly over the value — no compiled step list, no shared parser, no shared
   evaluator. Two routes that share no code — agreement is evidence, not tautology. Plus
   frozen hand goldens for each quiet-failure case the gift exists to fix (missing-key,
   type-confusion, stream fan-out).
*/
"use strict";

var G = require("./jq-lite.js");
var assert = require("assert");

var pass = 0, fail = 0;
function ok(name, cond) {
  if (cond) { pass++; }
  else { fail++; console.error("FAIL: " + name); }
}
function eq(name, got, want) {
  var g = JSON.stringify(got), w = JSON.stringify(want);
  if (g === w) { pass++; }
  else { fail++; console.error("FAIL: " + name + "\n  got:  " + g + "\n  want: " + w); }
}

/* ==================================================================
   INDEPENDENT ORACLE — regex-token scanner + recursive-descent walk.
   Tokens: DOT-FIELD (.name), BRACKET ([..] contents), PIPE (|),
           OPTIONAL (?), IDENTITY (a lone .).
   The oracle interprets tokens directly against the value, producing a
   stream (array). It shares NO code with the gift's step compiler.
   ================================================================== */

// Tokenize into an array of { kind, value, optional } accessor tokens and PIPE markers.
function oracleTokens(src) {
  var toks = [];
  var re = /\s*(\||\.(?:[A-Za-z_][A-Za-z0-9_]*)?|\[\s*(?:"(?:[^"\\]|\\.)*"|-?\d+|)\s*\])(\?)?/g;
  var i = 0;
  // Validate the whole string is consumed by our token grammar; else it's a syntax error.
  var consumed = 0;
  var m;
  re.lastIndex = 0;
  while ((m = re.exec(src)) !== null) {
    if (m.index !== consumed) throw new Error("oracle: gap at " + consumed);
    consumed = re.lastIndex;
    var raw = m[1];
    var optional = m[2] === "?";
    if (raw === "|") { toks.push({ kind: "pipe" }); continue; }
    if (raw === ".") { toks.push({ kind: "identity", optional: optional }); continue; }
    if (raw.charAt(0) === ".") { toks.push({ kind: "field", value: raw.slice(1), optional: optional }); continue; }
    if (raw.charAt(0) === "[") {
      var inner = raw.slice(1, -1).trim();
      if (inner === "") { toks.push({ kind: "iterate", optional: optional }); continue; }
      if (inner.charAt(0) === '"') { toks.push({ kind: "field", value: JSON.parse(inner), optional: optional }); continue; }
      toks.push({ kind: "index", value: parseInt(inner, 10), optional: optional }); continue;
    }
    throw new Error("oracle: unrecognized token " + raw);
  }
  if (consumed !== src.replace(/\s+$/, "").length && consumed !== src.length) {
    // allow trailing whitespace
    var tail = src.slice(consumed);
    if (tail.trim() !== "") throw new Error("oracle: trailing " + JSON.stringify(tail));
  }
  return toks;
}

function isObj(v) { return v !== null && typeof v === "object" && !Array.isArray(v); }

// Apply one accessor token to one value -> array of values (recursive-descent semantics).
function oracleApply(tok, v) {
  switch (tok.kind) {
    case "identity": return [v];
    case "field":
      if (v === null || v === undefined) return [null];
      if (isObj(v)) return [Object.prototype.hasOwnProperty.call(v, tok.value) ? v[tok.value] : null];
      if (tok.optional) return [];
      throw tagged("type");
    case "index":
      if (v === null || v === undefined) return [null];
      if (Array.isArray(v)) { var k = tok.value < 0 ? v.length + tok.value : tok.value; return [(k >= 0 && k < v.length) ? v[k] : null]; }
      if (tok.optional) return [];
      throw tagged("type");
    case "iterate":
      if (Array.isArray(v)) return v.slice();
      if (isObj(v)) { var o = []; for (var kk in v) if (Object.prototype.hasOwnProperty.call(v, kk)) o.push(v[kk]); return o; }
      if (tok.optional) return [];
      throw tagged("type");
    default: throw new Error("oracle: bad token kind");
  }
}
function tagged(t) { var e = new Error(t); e.jqType = true; return e; }

// Interpret the whole token list over a value -> stream. Pipes just continue the thread.
function oracleRun(value, filter) {
  var toks = oracleTokens(filter);
  var stream = [value];
  for (var t = 0; t < toks.length; t++) {
    if (toks[t].kind === "pipe") continue; // sequential thread; pipe is a no-op separator
    var next = [];
    for (var i = 0; i < stream.length; i++) {
      var r = oracleApply(toks[t], stream[i]);
      for (var j = 0; j < r.length; j++) next.push(r[j]);
    }
    stream = next;
  }
  return stream;
}

/* ==================================================================
   GRID — gift == oracle across many (value, filter) pairs
   ================================================================== */
var VALUES = [
  {}, { a: 1 }, { a: { b: { c: 3 } } }, { a: null }, { "weird key": 9 },
  [], [1, 2, 3], [{ id: 1 }, { id: 2 }, { id: 3 }],
  { items: [{ id: 10 }, { id: 20 }] }, { xs: [], ys: [5] },
  { a: 1, b: 2, c: 3 }, [[1], [2, 3]], null, "hi", 42, true,
  { nested: { list: [{ v: 1 }, { v: 2 }] } }
];
var FILTERS = [
  ".", ".a", ".a.b", ".a.b.c", ".a?", ".missing", ".missing.deep",
  ".[]", ".[0]", ".[1]", ".[-1]", ".[2]", ".[5]",
  ".items[]", ".items[] | .id", ".[] | .id", ".xs[]", ".ys[]",
  '.["weird key"]', ".nested.list[] | .v", ".a.b?", ".[]?", ".[0]?"
];

for (var vi = 0; vi < VALUES.length; vi++) {
  for (var fi = 0; fi < FILTERS.length; fi++) {
    var val = VALUES[vi], filt = FILTERS[fi];
    var giftOut, giftErr = null, oracleOut, oracleErr = null;
    try { giftOut = G.run(val, filt); } catch (e) { giftErr = !!e.jqType ? "type" : "err"; }
    try { oracleOut = oracleRun(val, filt); } catch (e) { oracleErr = !!e.jqType ? "type" : "err"; }
    var name = "grid v" + vi + " f[" + filt + "]";
    if (giftErr || oracleErr) {
      ok(name + " (both error, same kind)", giftErr === oracleErr);
    } else {
      eq(name, giftOut, oracleOut);
    }
  }
}

/* ==================================================================
   FROZEN HAND GOLDENS — the three quiet failures, pinned exactly
   ================================================================== */

// 1. Missing-key crash -> null (not an exception)
eq("golden: missing key yields null",        G.run({}, ".a.b.c"), [null]);
eq("golden: missing on populated -> null",   G.run({ a: {} }, ".a.b"), [null]);
eq("golden: null propagates",                G.run({ a: null }, ".a.b"), [null]);
// TYPE-STRICT: a missing key must yield an actual JSON null, never `undefined` (which
// serializes to null but is a leaking JS artifact — the mutation-bite proves this matters).
ok("golden: missing key is real null not undefined", G.run({}, ".a")[0] === null && typeof G.run({}, ".a")[0] === "object");
ok("golden: missing deep is real null not undefined", G.run({ a: {} }, ".a.b")[0] === null);
ok("golden: no undefined ever leaks a stream", G.run({ a: { b: 1 } }, ".a.z")[0] === null);

// 2. Type confusion -> loud error, but ? suppresses to empty stream
ok("golden: .foo on string throws (type)",   (function () { try { G.run("hi", ".foo"); return false; } catch (e) { return !!e.jqType; } })());
eq("golden: .foo? on string -> empty",       G.run("hi", ".foo?"), []);
ok("golden: .[0] on number throws (type)",   (function () { try { G.run(42, ".[0]"); return false; } catch (e) { return !!e.jqType; } })());
eq("golden: .[]? on number -> empty",        G.run(42, ".[]?"), []);

// 3. Stream fan-out: .[] then pipe runs over EACH element
eq("golden: fan-out over array",             G.run([1, 2, 3], ".[]"), [1, 2, 3]);
eq("golden: fan-out then field",             G.run({ items: [{ id: 1 }, { id: 2 }] }, ".items[] | .id"), [1, 2]);
eq("golden: fan-out over empty array",       G.run([], ".[]"), []);
eq("golden: fan-out over object values",     G.run({ a: 1, b: 2 }, ".[]"), [1, 2]);
eq("golden: object value order = key order", G.run({ z: 1, a: 2, m: 3 }, ".[]"), [1, 2, 3]);

/* ==================================================================
   DIRECT UNIT CHECKS
   ================================================================== */
eq("identity returns input",                 G.run({ a: 1 }, "."), [{ a: 1 }]);
eq("negative index last element",            G.run([10, 20, 30], ".[-1]"), [30]);
eq("index out of range -> null",             G.run([1, 2], ".[9]"), [null]);
eq("bracket key with space",                 G.run({ "a b": 5 }, '.["a b"]'), [5]);
eq("chained field then index",               G.run({ xs: [7, 8, 9] }, ".xs[1]"), [8]);
eq("deep pipe",                              G.run({ a: { b: [{ c: 1 }, { c: 2 }] } }, ".a.b[] | .c"), [1, 2]);

// parse() surfaces
eq("parse identity",                         G.parse("."), [{ op: "identity" }]);
ok("parse field",                            G.parse(".a")[0].op === "field" && G.parse(".a")[0].key === "a");
ok("parse iterate",                          G.parse(".[]")[0].op === "iterate");
ok("parse pipe flattens",                    G.parse(".a | .b").length === 2);

// parseInputs
eq("parseInputs JSONL",                      G.parseInputs('{"a":1}\n{"a":2}\n'), [{ a: 1 }, { a: 2 }]);
eq("parseInputs skips blank lines",          G.parseInputs('1\n\n2\n'), [1, 2]);
eq("parseInputs empty -> []",                G.parseInputs(""), []);

/* ==================================================================
   DETERMINISM
   ================================================================== */
var d1 = JSON.stringify(G.run({ a: [1, 2, 3], b: { c: 4 } }, ".a[] "));
var d2 = JSON.stringify(G.run({ a: [1, 2, 3], b: { c: 4 } }, ".a[] "));
ok("deterministic across two runs", d1 === d2);

/* ==================================================================
   FAIL-CLOSED / SYNTAX
   ================================================================== */
function throws(fn) { try { fn(); return false; } catch (e) { return true; } }
ok("empty filter throws",                    throws(function () { G.parse(""); }));
ok("filter not starting with . throws",      throws(function () { G.parse("foo"); }));
ok("dangling pipe throws",                   throws(function () { G.parse(".a |"); }));
ok("unterminated bracket throws",            throws(function () { G.parse(".["); }));
ok("bad char after dot throws",              throws(function () { G.parse(".a.|"); }));
ok("non-string filter throws",               throws(function () { G.run({}, 5); }));

/* ---- report ------------------------------------------------------ */
console.log("jq-lite battery: " + pass + " passed, " + fail + " failed");
process.exitCode = fail === 0 ? 0 : 1;
Take the whole folder → MIT Node / browser, no dependencies