A jq replacement, written in the openOODA language. Reads a JSON document, applies a jq-style filter, emits values as JSON.
Part of openOODA-tools.
The CLI works and is byte-compatible with jq where it overlaps.
parse/, filter/, and render/ are implemented and main.oo wires them to
argv. 1036 behavioural tests and all eight governance gates pass.
Current parity against jq 1.8.1, measured by make parity: 98% of the
filters jq accepts, byte-identical in every output mode — 2433 byte-identical,
27 refused, 6 answered differently across the three output modes. The case list
is the full set in three output modes, not a curated subset, and a case where
both binaries answer differently is printed on its own line rather than folded
into either total, so the number cannot flatter the implementation.
A second, deliberately disjoint corpus runs under make sweep. It is blind to
the parity corpus on purpose, and it is the only layer that can find a class of
input nobody thought of. Two bugs came out of its first run and both are now
closed: 426 byte-identical, 12 refused, 3 answered differently, 97% of the
cases jq accepts. See
How the answers are checked.
The percentage measures the corpus, not the program. Read the byte-identical count and the refusal list together, never the ratio alone. The loop runs every case in all three output modes, so a refused case is counted once per mode: the 27 refusals are 9 cases, and every one of them is a boundary written down here rather than a hole.
- 6
sqrtof a value that is not a square, which has no exact answer here. - 1
teston a pattern carrying a character class, which the std regex engine cannot honour. - 2
strftime, both deliberate and both reasoned in UTC dates:%s, which jq answers in the machine's own timezone — three different values for one input, measured — and a format written as a generator, which the hub refuses forindex,rindex,contains, anderrortoo, because the literal-argument path can only carry one value.
Four more strftime cases are in the corpus but are not in that 27: jq rejects
them outright (strftime(1), a bare strftime, and two bad datetime values), so
the loop never reaches oojq and books them as cases jq rejects rather than as
parity requirements. They are covered by make test instead. Nothing else in the
corpus is refused.
Two cases are answered differently (the sweep counts each over a few inputs, so the raw "answered differently" line reads higher). They are in the corpus on purpose and they fall into two groups, because differs and is wrong are not the same thing and only one of them is a defect.
One is oojq being wrong, and it is the real state of the build:
"i+/v"|@base64dwrites threeU+FFFDwhere jq writes two. Only the number of replacement characters in already-invalid UTF-8 differs; the decoded characters never do. This one is left open on purpose, and the reason is worth stating because it is a trap rather than a chore: jq's error-recovery rule is not any standard, and measured across 38 sequences it is not even self-consistent in the way a rule usually is.c2 41decodes toU+FFFDthenA— the bad byte is kept — whilee0 41decodes to a singleU+FFFD, consuming theA; a two-byte lead behaves differently from a three-byte one with the identical following byte, andf7is not treated as a lead at all. That is a quirk of the specific decoder jq links, so matching it means copying an accident, and the accident is when to swallow the bad byte, which sits one step from the valid path. A speculative reimplementation here would risk corrupting well-formed UTF-8 — the part anyone actually reads — to get a replacement-character count right on bytes that were already invalid. The decoded characters agreeing is the property that matters, and it holds. See the@base64note below.
The nineteen arithmetic cases are all byte-identical to jq now. They used to
fill this section: 0.1 * 0.2 answering 0.020000000000000004, 0.1 + 0.2 > 0.3
answering false, 3.3 / 1.1 answering a flat 3, 5.5 % 2 answering 1.5. Every
one of them was fixed by matching jq rather than by out-arguing it, and that is the
recurring lesson here. A jq replacement's selling point is that a migrating script
does not change under it, and an answer that differs on a non-integer operand with
nothing erroring is a compatibility break wearing the costume of a virtue. % was
the sharpest case: jq's is not a remainder at all — it truncates both operands, so
5.5 % 2 is 1 and 1 % 0.5 is refused — and oojq wrote the mathematically correct
1.5 for a while before reversing to jq's 1. The arithmetic case was a good one
and it is still true, but it was not the question being asked.
One divergence is left in the arithmetic, and it is deliberate. It is
exactness past a double, on the whole-number path. 9223372036854775807 + 1 is
9223372036854775808 here and 9223372036854776000 in jq; 9007199254740993 / 1 is
9007199254740993 and 9007199254740992. jq's answers are the doubles next to them.
The literal cases are byte-identical — 18446744073709551615 prints as itself in
both — and so is 18446744073709551615|length. A 63-bit Int holds what a double
rounds away, and a jq replacement that returns 18446744073709552000 for a number
that was written in full is lossy. This is the one place oojq answers a different
number than jq on purpose, and being different is the point: the exact decimal path
is kept for two whole numbers precisely so a sum or a quotient past 53 bits keeps
every digit, and / falls back to the double only where that path cannot finish
(1/3 is jq's 0.3333333333333333, where it used to be a refusal, while 10/4 is
still the exact 2.5). It is the decision most deserving a second opinion and it is
written down here rather than hidden.
Closing the float gap was reachable, and it has been measured rather than
assumed. It was the largest parity item by a wide margin — 17 of the 20 cases
above — and the thing worth knowing is that it is not blocked by the language.
The compiler has a Float and real IEEE arithmetic, and Int has &, |, ^,
<<, and >> with a 53-bit significand surviving a shift intact. What a Float
cannot do is become a value: the runtime has 149 helpers and no float-to-String
among them, so the compiler answers ERR llvm float to_string needs a runtime helper and a printed double can never be stored. The way past that is to do
binary64 on Int, and the crux — turning a decimal literal into bits — is written
and checked.
filter/eval/float/decimal_bits.oo turns written decimal text into the 16 hex digits
of the binary64 word it is, correctly rounded, with no wide integer anywhere: the
fraction is doubled from the right, the integer halved from the left, the first 53
bits are the significand, the 54th is the guard, and what falls below is the sticky.
It matches an independent oracle (Python's struct, a separate implementation of
the same standard) on 5821 cases with zero mismatches, including 13 exact
round-half-even ties, 24 exact decimal expansions of real doubles up to 1074
digits, 1200 long digit runs, and 439 expected refusals. The smallest normal double
comes back 0010000000000000 and the largest finite 7fefffffffffffff.
The other half of the round trip is written and checked too.
filter/eval/float/exact_digits.oo writes out the exact decimal value of a
double — every double is a dyadic rational, so that value is exact and finite,
and the widest one in range is 767 digits across. filter/eval/float/bits_text.oo
takes the word apart, expands it, and then drops digits one at a time while the
word still reads back the same, testing each candidate against the converter that
wrote it. 1269 cases come back byte-identical to jq's computed output, and
the same corpus confirms jq is itself shortest-round-trip in 1269 of 1269, so
this matches jq exactly rather than approximately. 0.1 prints 0.1 and not the
55 digit number it actually is.
And the arithmetic on top of them works, which is the part that was actually
wrong. filter/eval/float/float_word.oo and float_ops.oo add, subtract,
multiply, and order two doubles by doing the arithmetic exactly and rounding
once at the end. A double is a dyadic rational, and dyadic plus dyadic and
dyadic times dyadic are both exact, so there is no floating point to model: the
digit-run arithmetic that was already here does the work, and the converter that
was already here does the single rounding IEEE asks for. 6676 cases come back
identical to Python's IEEE binary64, zero mismatches, including all 852
subnormal cases and every underflow-to-zero case. 0.1 * 0.2 is now
0.020000000000000004 and 0.1 * 0.2 > 0.02 is true, which it is not today —
and of the 17 float divergences, 5 were comparisons and therefore wrong answers
rather than alternative renderings.
Division is the one operation this approach cannot do by the same route, because a
quotient is not dyadic and so has no exact digit run to hand to decimal_bits. It
is written anyway, in filter/eval/float/flt_div.oo, and it is the part of the
float work that is a proof rather than a very good guess. Seventeen digits of each
operand are long-divided — seventeen is the most a 63-bit Int holds while leaving
remainder × 10 below the top of the range — which gives seventeen digits of the
quotient, one place short of the seventeen a double is pinned by. The gap is closed
by taking the exact residual with dd_mul and dividing that by the divisor
for eighteen more places: thirty-three digits, a thousand times past what the
rounding can notice. Rounding those once with decimal_bits gives a candidate, and
a candidate can be one out when the true quotient lands on a midpoint, so the two
midpoints bordering it are built exactly, multiplied by the divisor exactly, and
compared with the dividend exactly. Every answer is one a comparison confirmed. The
algorithm was checked against CPython's correctly-rounded division on 4839 jq
cases and 40000 random doubles with zero mismatches, and ties round to even.
3.3 / 1.1 is now 2.9999999999999996 and 3.3 / 1.1 < 3 is true, both as in jq;
before this the exact long division answered a flat 3. Two whole numbers still
try the exact long division first — it is the only route that can answer a quotient
past the 53 bits a double holds — and the double takes over only where that path
refuses, so 1/3 is jq's 0.3333333333333333 where it used to be a refusal while
10/4 is still the exact 2.5.
gmtime, mktime, todate, fromdate, and fromdateiso8601 are implemented in
filter/builtin/date/, with no clock and no timezone anywhere: they are one
era-based calendar formula run forwards and backwards, so the whole family is
deterministic and testable against jq with fixed inputs. gmtime writes the
broken-down array jq passes around — [year, month, day, hour, minute, second, weekday, yearday] with the month counted from zero (6 is July), the weekday
from Sunday, and the yearday from zero — and mktime reads the first six back
and ignores the last two, because a caller that hand-builds [2000,0,1,0,0,0,99,999]
means the first six. -1|gmtime is 1969-12-31 at 23:59:59, and todate is ISO
YYYY-MM-DDTHH:MM:SSZ. 89 of 91 measured cases are byte-identical to jq.
The two that are not are the same case twice, and they are one of jq's accidents
rather than a rule: jq refuses mktime for 1969-12-31. [1969,11,31,…]|mktime
and -1|gmtime|mktime both raise invalid gmtime representation, while every
other date round-trips — it is an artifact of the C library at the epoch boundary,
not a rule jq chose. oojq answers -1, which is what 1969-12-31T23:59:59Z is.
Replicating a C-library boundary bug is not jq-compatibility, so this is left as a
deliberate one-case divergence. The lesson matches the float work: a rule read off
jq has to be tested on a second shape of expression before it is believed.
strftime is the sixth member of the family, in filter/builtin/date/strftime.oo, and
it takes both shapes jq takes: a number, read as seconds since the epoch exactly as
gmtime reads it, or the eight-slot array, normalized the way a C struct tm
normalizes. That normalization is not a detail — it is most of what strftime is. A
month past December rolls into the next year, a day of 0 is the day before the
first, an hour of 25 is one o'clock the next day, and slots 6 and 7 are ignored and
recomputed from the date. So [] is 1899-12-31 00:00:00 (the zeroed struct, which is
1900 less one day) and [-1] is -2-12-31, because a year of −1 with a day before its
first January is a year of −2. Every base code is implemented — %Y %y %C %m %d %e %j %H %I %M %S %p %a %A %b %B %u %w %U %W %G %V %z %Z %n %t %% — along with all eight
composites, which are pushed back onto the format queue so %c and %F are each
defined once. 144 of 146 measured cases are byte-identical to jq**, including %X
being the twelve-hour clock (02:30:25 PM) where C would give %H:%M:%S, and %y
wrapping so a year of −1 is 99.
The two that are not are refusal wording: strftime(["%Y"]) and strftime(null) get
oojq's generic "needs a scalar literal argument" where jq says strftime/1 requires a string format. Both refuse, which is what matters; neither answers wrongly.
%s is refused, and that is the interesting decision here. jq's %s is not the
epoch of the instant being formatted — it is mktime of a wall clock, so it reads in
the machine's own timezone. Measured here: 1425601825 on this host, 1425565825
under TZ=UTC, 1425533425 under TZ=Asia/Tokyo, all for the same input. oojq has no
timezone database and no clock, so the UTC epoch would be the right answer for exactly
one of those machines and silently wrong on every other. It refuses the whole call with
the reason: strftime format code %s needs the local UTC offset, which this build cannot read. The same reasoning is why now, localtime, and local are still absent, and
why the rest of strftime — including %Z, which jq also always writes as GMT, and
%z, always +0000 — is implemented rather than refused alongside it. A value that is
right on one machine and wrong on the next is not an answer; it is a coin toss.
strptime is the seventh, in filter/builtin/date/strptime.oo with its three
helpers, and it is the one where the contract had to be measured rather than
assumed — twice. An earlier reading of jq said the composite codes could not be
parsed at all. That reading was wrong, and it was wrong because of a shell
escaping bug in the probe itself: the format string reached jq as %%F instead
of %F, every case failed, and "all eight refuse" looked like a clean finding.
Re-measured one escape level down, seven of the eight composites parse and
only %c is refused, for every one of seven different input shapes. The same
second measurement found that jq's reading table and its writing table
disagree in one place: %x is %m/%d/%Y when reading and %m/%d/%Y when
writing is %m/%d/%y, so "03/05/15" is 2015 under %D and 15 under %x.
That is why the composite table lives in its own page, parse_codes.oo, spelled
once, rather than inlined in the walk where a copy could drift from strftime.
Across 401 measured cases the implementation is byte-identical to jq on 297 and every one of the remaining 104 is a refusal. There are no wrong answers. The refusals fall into exactly three kinds, and the count of each was taken by reading the answers rather than by assuming:
- 86 rest on a slot jq never computed. A format naming no date —
"14","%H","%T","%p","%Z","%z"alone — leaves jq's weekday and day of the year as whatever its C stack held. The signature is stable and is not a valid date: slot 6 is8, which is not a weekday, and slot 7 is367, which is not a day of any year that has one. Every one of the 86 was checked to carry one of those before being counted. - 5 are jq's ninth element. jq's
strptimedoes not require the format to consume the whole input. What is left over is appended to the answer as a ninth element — a String where the ninth element ofgmtimeis an integer UTC offset."2015 extra" | strptime("%Y")answers[2015,0,0,0,0,0,3,-1," extra"]. oojq refuses and says which text was left over, because a number slot holding text is not a shape worth reproducing. - 12 are cases jq also refuses, differing only in wording.
The rest of the contract is implemented because it was measured, and several
parts of it are not what a first reading suggests. A day of the year fills in
the month and the day independently: "2015 064 9" | strptime("%Y %j %d") is
the 9th of March, and "2015 9 064" | strptime("%Y %d %j") is the same date,
because %d wrote the day and %j wrote the month whichever order they appear
in — but a month already named is left alone, so "2015 12 031" is the 31st of
December. A yearday that walks off the end of its year gets jq's own nonsense
answer, a month of 24 and a day of 31, while still taking the weekday from
the real date a year on: "2015 366" is a Friday and "1900 366" a Tuesday,
both of them nameable and both shipped. %Z runs to the next blank and not
to the next dash, so GMT-2015 is one token and "%Z-%Y" finds no dash to
match. %u counts seven days to Sunday, so 7 is 0. %z checks its minutes
and not its hours: +2500 and +9900 are accepted, +0060 is refused. And a
name is matched in full before its first three letters, which is the whole
difference between reading March as March and reading it as Mar with ch
left over.
That last one was found by a harness, not by reading, and it is the shape of
thing this project keeps finding: the first implementation of a reader that
returns two answers conflated them. One Int was carrying both "March is
month 2" and "the match ended at 5", which works for every case where the two
coincide — Mar — and is wrong for every case where they do not. Four wrong
answers came out of that one page, and none of them would have been found by
testing Mar alone.
has and join were both wrong, and both were wrong the same way: each had
one shape in mind and coerced the rest into it. has asked an object whether
it carried a name, so {"a":1} | has(1) answered false where jq refuses, and
the refusal names both sides — Cannot check whether object has a number key.
join accepted an array of strings, so it refused [1,2,3] | join(",") where
jq answers "1,2,3", and — worse — it read its separator as text, so
["a","b"] | join(1) answered "a1b" where jq refuses outright.
That last one is the interesting half. jq's + does not coerce: a string on
the left concatenates a string, leaves itself alone for a null, and refuses a
number, a boolean or a container. "a" + 1 is an error in jq, not "a1".
join is a reduce whose every step is an ordinary +, and the two operands are
treated differently on purpose. The member is spelled to text first, which
is why [1,2,3] joins at all and [[1],[2]] does not join at all. The
separator is added as it was written, which is why join(1) is a refusal
and join(null) is a no-op. The accumulator is written into the arena as it is
built because the refusal names the string that had been reached:
{"a":1,"b":[1,2]} | join(",") fails on
string ("1,") and array ([1,2]) cannot be added, with the comma already inside
the quoted half.
An object joins its values, in the order it already holds them:
{"b":2,"a":1} is "2,1", not a sort. A null member is the empty string, so
[1,null,true] is "1,,true". A non-iterable input is
Cannot iterate over null (null), with the value written the way it was
written.
has has a rule worth naming, because it looks like it needs a rounding and
does not. jq tests 0 <= trunc(n) < length, and has(-0.5) is true — which
is what shows it is not testing 0 <= n. For an integer length that collapses
exactly to n > -1 and n < length, so a float key is answered by two exact
comparisons and never by a truncation this build has no way to take. A null
input is false for every key kind, including the ones an array would refuse,
because there is no shape left to ask about.
Both are measured rather than asserted: 64 of 64 has cases and 51 of 51
join cases are byte-identical to jq 1.8.1, every refusal sentence included.
add(f) used to be refused, and the refusal was correct — it just said so
awkwardly. The reason it was refused at all is the third instance of the shape
this section is about. add had one shape in mind, an array of numbers, and
add(.) was read as "sum the members", so [1,2,3] | add(.) answered 6 where
jq answers [1,2,3]. That is a different number, not a different shape, and
it is the worst kind of gap because nothing about it looks like a gap.
The rule is jq's own definition: add(f) is [f] | add. The filter runs
once over the whole input and every answer it gives is summed. Measured, that
is the whole of it:
| filter | answer | why |
|---|---|---|
[1,2,3] | add(.) |
[1,2,3] |
one member, the input itself |
[1,2,3] | add(.[]) |
6 |
the collect holds the three members |
[1,2] | add(.[0], .[1]) |
3 |
a top-level comma is one argument |
[1,2,3] | add(empty) |
null |
an empty collect is an empty array |
[1,2,3] | add(.;.) |
refused | a semicolon is add/2, and there is none |
So it is a parse-time rewrite to [f] | add, in
filter/builtin/rewrite/rewrite.oo, which costs the evaluator nothing: a collect
already walks the body and the bare add already sums an array. That is the
same bargain recurse's two-argument form and map_values already take.
The one case that would have been a wrong answer rather than a gap is the
last row, and measuring found it before writing anything. A top-level comma is
one argument holding a generator, so add(.[0], .[1]) is legal and is 3. A
top-level semicolon is a second argument, and jq answers
add/2 is not defined — so a body carrying one is refused by name, using the
module's existing semi_at_top, rather than collected into [a;b] and summed.
Saying "a semicolon is a second argument" is the difference between a refusal a
reader can act on and a number they cannot explain.
46 measured cases, 42 byte-identical, 0 wrong answers. The four differences
are all refusals on both sides: add() and add( ) are syntax errors in jq and
"add()" needs an argument in this build here, and add(f) with an undefined
name is f/0 is not defined in jq and unknown builtin "f" here.
The kind matrix — every dispatched builtin against every input kind against every scalar argument, 4 620 cases — is the layer that keeps finding things reading does not. Its latest run reported 714 cases where one side answered and the other did not. Almost all of them were one defect wearing many names:
1 | tonumber("b") -> 1 jq: tonumber/1 is not defined
"abc" | length(1) -> 3 jq: length/1 is not defined
"abc" | type(1) -> "string"
1.5 | ascii_upcase("b") -> 1.5
oojq read the body and ignored it. The value it produced is the correct
answer to the call the reader did not make, which is the worst shape a wrong
answer can take: it is silent, it is plausible, and nothing about it looks like
a gap. Across 21 builtins that is ~700 cases, and a test written against
length(1) would have recorded 3 as correct.
The repair is a parse-time refusal in filter/builtin/rewrite/arity.oo, and the
name list is measured, not remembered. Each of the 94 names this build knows
was asked about at arity 1 and at arity 2 against jq 1.8.1:
- refused at both → the name takes no body at all. 47 of them.
- refused only at arity 1 → it keeps its two-argument form. That test is what
keeps
subandgsubout of the list:sub("a")issub/1and does not exist,sub("a";"b")issub/2and does. Listingsubwould have refused a call that works.
It is a deny-list, not an allow-list of what takes a filter, on purpose. Every
name left out behaves exactly as it does today, so a builtin that takes a body
and is not listed is a coverage gap rather than a working call that stops
working. Both directions are gated: 47 of 47 listed names refuse with jq's own
length/1 is not defined sentence, and 47 of 47 others — first, last, map,
any, sort_by — still take their bodies.
The same run found one more, in the opposite direction: oojq was refusing
something jq answers. contains over two booleans. jq says a boolean contains
itself and not the other one, so true|contains(true) is true while
true|contains(false) is refused. The test here had been "the two kinds match",
which is the same for every kind pair except this one — and for this one it
answered a value where jq gives a refusal.
714 → 17. Of the 17, six are the deliberate index-into-an-object refusal
and eleven are range with a float bound, which is a gap rather than a wrong
answer: oojq answers range needs whole numbers where jq answers 0 1. The
rule there is exact and is the next thing to implement — range(from; to; by)
emits from, from+by, … while the next value is < to, in real numbers, so
range(0.5;2.5) is 0.5 1.5 and range(1.5) is 0 1.
1 as $x | $x answered unexpected "a" at character 3. The same word at the
start of a filter was refused properly — binding a variable with "as" is not supported in this build — so the construct was readable in one of the two
places a reader would write it, and the message at the other named neither the
construct nor the reason.
It was not only as. Every reserved word behaved this way, because all of them
are recognised in one place only:
| filter | before | after |
|---|---|---|
1 as $x | $x |
unexpected "a" at character 3 |
binding a variable with "as" is not supported… |
1 reduce . as $x (0;.) |
unexpected "r" at character 3 |
"reduce" is not supported… |
1 def f: 1; f |
unexpected "d" at character 3 |
"def" is not supported… |
1 label $out | 1 |
unexpected "l" at character 3 |
"label" is not supported… |
Nineteen lines in filter/syntax/parse_top.oo: when leftover text begins with an
identifier that is a reserved word, that word's own sentence is returned.
reserved_word became public so the two positions share one list rather than
two drifting copies.
The part that matters more is what it must not do. A leftover that is not a
reserved word still gets the generic unexpected message, so 1 2, 1 @ and
.a b still say unexpected "2" at character 3. A fix that swallowed every
leftover would look tidier and would hide a real syntax error behind a sentence
about a feature, so the harness checks the generic path as well as the new one,
along with six ordinary filters that must still parse.
make parity reported jq rejects case 135 (not a parity requirement) and moved
on. That number had gone to 213 by the time anybody looked, and 78 of those
cases were not jq being careful — they were my own typos.
A parity case jq cannot compile contributes nothing: it is neither agreement nor
divergence, so it can never fail and never teaches anything. Ten new cases
looked fine in the source and were silently inert. Eight of them were not valid
jq at all, because only as binds an expression on its left — 1 reduce …,
1 def … and 1 label … are syntax errors, so the corpus was scoring nothing
for them.
Both loops now print the cases jq refuses, sorted, the way the refusal list is printed:
jq rejects case 177 (not a parity requirement, listed below)
the cases jq ITSELF refuses, printed because a counter nobody can read is a
counter nobody can hold to:
jq refuses 0.1%0.05
jq refuses 1%0.3
jq refuses [[1,2]]|@csv
That is the same lesson as printing refusals rather than counting them, applied to the bucket that was hiding the typos. It is the bucket a malformed case lands in, which makes it the one bucket that must be readable.
The eight invalid cases came out of the corpus — their refusal messages are
pinned by make test assertions, which is the right place for them, since parity
cannot score a filter jq will not parse. What stayed is the three that are real
jq: 1 as $x | $x, as $x and .a as $x | ., and those now show up honestly as
unsupported rather than as nothing at all.
A related trap, in the same edit: $$ is how a $ reaches the shell from a
recipe, so 1 as $x | $x in a Makefile arrives as 1 as | — a mangled filter
that jq also rejects, and so was equally invisible. make -n parity | grep is
how that one was found, and it is the cheapest way to see what a corpus entry
actually became.
//= is the obvious sibling of |=, and the two look interchangeable:
.a //= 9 jq: {"a":1} (1 is truthy, so nothing changes)
.a |= (. // 9) the same thing
Measuring them against each other across ten shapes found two where the rewrite is silently wrong, and both are the same cause — jq reads the right-hand side eagerly, before it looks at the left:
| filter | jq | the |= rewrite |
|---|---|---|
.a //= (empty) |
nothing | {"a":1} |
.a //= error("x") |
error: x | {"a":1} |
.a //= (1|debug) |
["DEBUG:",1] |
{"a":1} |
The first is not expressible at all: //= with an empty right-hand side
produces no output, and |= assigns a value, so it cannot say "this
assignment happened to nothing" — that needs a binding, and this build has no
variables. The other two are the same eagerness seen from two sides.
So the rewrite was not taken. It would have converted a loud refusal into a
quiet wrong answer, which is the one trade this project will not make for
convenience. What changed instead is that the decision is now on the record: the
refusal names both divergences and says the |= spelling is "silently wrong for
an impure right hand side", and seven assertions generated from live jq pin the
whole boundary — including that .a |= (. // 9) is supported here, that it
fills a null the way jq does, and that it leaves 0 and "" alone because
both are truthy in jq, which is the part most likely to be got wrong by
anyone reading the table above and assuming "falsy" means "null and false".
Everything else about the operator was already right. .d //= 9 leaves d at
0 and .e //= 9 leaves e at ""; a missing .z is created. The gap was
never the semantics, it was one unreachable corner of them.
jq turns out to have two ways to write a double and they disagree: a number
parsed from text keeps its point and takes 1E+16, while a computed one loses
the point when it is whole and takes 1e+16. The computed path is what the
parity corpus compares against, and its rule is not the one a standard suggests
— scientific notation is used below 1e-4, or when the full form would end in
sixteen or more zeros after the significant digits, which is why 1e+16 is an
exponent and 1.1e+16 is not.
None of it is wired into the value tree yet, and that is deliberate. No value is a double and no arithmetic sits on top; the point of building both ends alone is that a wrong bit in the arithmetic would otherwise be indistinguishable from a wrong bit here. Subnormals and overflow are refused by name rather than answered approximately. Cost is 0.02 ms for a typical literal read, but the exact expansion is far heavier — about 0.6 s per value in the worst case — and the arena never frees, so a single process degrades after roughly 130 conversions and starts returning NUL bytes. That is a measured limit, not a rounding error, and the fix is a reusable buffer the language does not have. The full evidence and the eight traps that produced wrong answers along the way are in AGENTS.md.
One builtin is oojq-only, and it has no jq counterpart to differ from.
leaf_paths is not a jq 1.8.1 builtin — jq -n 'builtins' does not list it and
jq refuses it as undefined at both arities. It answers every path to a
non-container, and it counts null as a leaf, so it is paths(scalars) plus the
nulls: on {"a":{"b":1},"c":[2,{"d":3}],"e":null} it gives [["a","b"],["c",0], ["c",1,"d"],["e"]] where paths(scalars) stops before ["e"]. It is listed here
because the parity corpus can never check it — jq rejects every case — and because
it shipped for a long time with no test at all. Three now pin its definition.
A slice whose bound runs past the end used to kill the process.
[1,2,3][0:5] answered nothing — it read off the end of the children and died
on an out-of-bounds read, where jq answers [1,2,3]. The end of a slice was
translated into a position without ever clamping it to the array's length, so
[1,2,3][1:99], [][0:2], and [1,2,3][2:5] all died the same way. Every one
of 531 tests missed it, because the only slice in the corpus was .tags[0:1],
comfortably in bounds. This is the worst failure mode in the project — not a
plausible wrong answer but a death — and it lived on an input nobody thinks
about, because nobody writes .[0:5] when they already know the length is 3.
It was limit(n; f) that walked into it, by being the first thing here to slice
with a bound it had not verified. Four cases now cover the class, not just the
one that tripped.
A test score cannot see this class of bug, and four of the eleven were not
arithmetic at all until this increment found them. .a + .b — the commonest jq
idiom there is — was silently evaluating .b against .a's value, so
.ratio + .port raised cannot index a float with "port". add summed the
integer part off every member, so [1, 2.5] | add answered 1 with the 2.5
dropped. min and max did the same, so [1.5, 2.5] | max answered 1.5.
Every one of them passed the whole corpus, because the corpus had the right
shape and the wrong contents: every case with a . on the right of an operator
was a literal or a wrapped (.a), and every case of add was all-integer.
After a fix lands, the question that finds the next one is: what class of
input was never in the corpus at all? A passing score measures the corpus,
not the program.
The same question found the last of the four wrong answers above, in a place
nobody had looked. A collect [f] read its body over the whole stream at once,
so [1,2,3] | .[] | [.] was [[1,2,3]]. It had been written down as
unbuildable for a long time: the loop it needs has to live in gather, on the
one page that is already at its line ceiling. The fix was not a new loop. It
was noticing that per_member already walks values one at a time and gathers
each one's answers for map, and that a collect is map pointed at the value
itself rather than at its members. gather became a call with a different mode,
four conditions grew an ||, and a line came back. A page at its ceiling is a
constraint on writing, not on designing.
Asked once more, that question found the largest class in this build, and it is
one sentence long: every step above a field access is a per-value step, and
three of them were reading the whole stream at once. .items[] | .id * 2
answered 2 4 6 2 4 6 2 4 6 — the left side over three values is three, the
right side 2 over three values is also three, and the cross product is nine.
.items[] | .id, .k answered 1 2 3 "a" "b" "a" where jq reads them in step.
.items[] | if .id==1 then 1 else 2 end answered 27 values. A question mark had
the same shape: (.a,.b) | .c? was nothing, because the first refusal ended the
walk and took the good answer with it. The corpus had three hundred cases and not
one put an operator, a comma, or a conditional downstream of an iterate — the
iterate was always consumed by map, select, or a field. A corpus needs
cases where the previous step changes the arity of the next one, not only cases
where it does not. All four are now one rule and one loop.
Two more came from the same question asked of the numbers. A literal wider than
a 64-bit Int wrapped: 18446744073709551615 decoded to -1 and
100000000000000000000 to 7766279631452241920. An int × int fast path
wrapped too, so 9223372036854775807 * 2 answered -2. A negative zero was
not a number at all: 0 * -1 answered 0 where jq writes -0, and num_cmp
called -0 < 0 true. Each was a silent wrong answer, and each is now measured
and byte-identical or named.
And one more, which is the reason a corpus of answers cannot be trusted on its
own: paths(f) read its filter and ignored it. [paths(.=="a")]|length
answered 16 where jq gives 2, and [paths(empty)] answered 16 where jq gives 0.
Both look like plausible answers. The walk lives on a page that cannot reach the
evaluator, so f cannot be asked; it is now refused by name. A builtin that
cannot run its filter argument must refuse, never run it without.
@base64 and @base64d are both implemented, and @base64d follows jq's own
shape rather than the tidier one people assume. jq's decoder is a bit
accumulator, not a four-character block reader: six bits go in, whole bytes come
off, and a = ends the run wherever it sits. That is why "ab" decodes to one
byte with no padding written, "YQ===" decodes to "a" with the extra =
ignored, and "A===" is a trailing byte rather than a bad pad. Both refusal
messages are jq's own, down to naming the string it was given:
string ("!!!") is not valid base64 data and string ("a") trailing base64 byte found. The std base64_decode disagrees with jq on padding and accepts the
URL-safe alphabet that jq rejects, so it is not used here.
@csv, @tsv, and @sh are all implemented. A row is one flat array, which is
jq's contract and not a limitation: .[] | @csv over a stream of rows is how a
whole table is written, and each row is answered on its own. Every string cell
in CSV is quoted and a quote inside it is written twice, so a comma or a
newline inside the cell is just a character; every string cell in TSV has its
backslash doubled and its tab, newline, and carriage return spelled as \t,
\n, and \r, because a literal one would end the cell or move to another
line. A null cell is empty in both, which is why [1,null] is 1, and not
1,null. @sh quotes a string word whatever it holds, closes the quote and
reopens it around an apostrophe, and writes a number, a boolean, or a null
bare. A container inside a row, or a container where @sh wants one word, is
refused by name rather than written out as JSON and read back as a single cell.
A bare any and a bare all are the two builtins that are also a filter all on
their own, and both now answer. jq reads a bare any as any(.) over the
members, so [false,false] | any is false and [true,false] | all is false
rather than a refusal. map, select, and the five _by builtins still need
a filter argument and are still refused without one, because they have no
meaning without a condition. The two-argument forms any(f; g) and all(f; g)
are not supported.
Two things about the pair above are not byte-identical to jq, and both are in the parity corpus rather than left out:
- The number of
U+FFFDcharacters can differ when@base64ddecodes bytes that are not UTF-8. jq folds a 3- or 4-byte lead and the byte that fails it into one replacement; this build follows the Unicode maximal-subpart rule and starts a new one at the next byte. The corpus line"i+/v"|@base64d|utf8bytelengthis the case: jq answers 6, this build answers 9. Only the count in already-invalid input changes; the characters themselves never do. anyandallover something that is not a container are refused by both, and the wording is jq's:Cannot iterate over number (500), with the kind jq calls it and the value as it is written.minandmaxare refused in jq's other wording,number (1) and number (1) cannot be iterated over, because that is what jq writes when it cannot iterate. One sentence, two shapes, both measured rather than guessed.
Every refusal of this kind now says what jq says. The two sentences jq usesfor indexing and iterating a scalar live in one place, select_node.oo, and
every page that refuses one of those ways uses them. This build used to answer
cannot index a int with "b" and cannot iterate over a int, which are
sentences jq never writes — a and bool are the words of a value tree, not of
a filter language, and a reader had nothing to compare them against. The
measured rules are narrow and worth stating:
- An index names the value's kind and the key's:
Cannot index number with string "a",Cannot index object with number. A key is named by its value when it is a string and by what it is otherwise, so a position is the wordnumberand never its digits. - An iterate names the value and its kind:
Cannot iterate over number (1),Cannot iterate over string ("s"),Cannot iterate over boolean (true). - A null is refused by an iterate and not by an index:
null|.[]isCannot iterate over null (null), whilenull|.aandnull|.[0]arenull. A missing member is a null, and it is still a null when indexed. - One float is still written as it was typed:
1e2|.[]saysCannot iterate over number (1e2)where jq writes1E+2. That is the float rendering gap below, in a sentence rather than in a value, and it is left rather than half-fixed for the same reason.
del(f) and path(f) are both implemented, and both draw their line in the
same place: the argument must be a literal path expression. del(.port),
del(.limits.rps), del(.tags[0]), del(.a, .b), del(.) and path(.a.b)
all work, and each is a per-value step, so [.tags[] | del(.)] answers
[null,null] rather than one null. Deleting a key that is not there is a
no-op, which is what del(.missing) means, and del(.) is null because the
last step of a path is the value itself.
A path with an iterate, a slice, a pipe, or recursion in it is refused by
name rather than read as far as it looks like a path: del(.a[]) and
path(..) both say so. Reading the part of a filter that happens to look like
a path would answer about a path the writer never wrote, which is the one thing
this document refuses to do anywhere. Closing that gap needs a path mode in the
evaluator, which is a change to the one page that cannot take one.
The one place this build answers where jq refuses is path(.[0]) written
against an object. jq walks the path and errors on the index; this build reads
it as text and answers [0], which is the path that was written. That is a
refusal replaced by the right answer rather than by a wrong one, and it is the
only difference in this pair.
Not yet built: the AF_UNIX socket in ipc/, variable binding (as $x), object
construction, and the operators listed under What Is Not Supported.
The one place oojq is not trying to match jq is the tool surface. oojq --mcp
serves the engine over JSON-RPC and publishes jq_check and jq_grammar
alongside jq, so a caller can ask what the engine supports and validate a
filter before spending a run on it. jq has no equivalent.
error is implemented and its message is jq's, down to the (not a string):
prefix on a non-string. Two things about it are oojq's own: the line is wrapped
as oojq: filter "<f>": <message> because a filter failure is reported the same
way wherever in the filter it came from, and the exit status is 2 where jq
uses 5, because this build has no second channel to mark a stop as deliberate.
You do not need to quote a filter that contains a space. Every argument before
the first one that names an existing file is treated as filter text, so
oojq .name, .port server.json works exactly as it does in jq.
oojq has zero runtime dependencies. It compiles to a standalone native binary linked directly with the host libc.
Installs the standalone native binary to /usr/local/bin (or ~/.local/bin) with automatic SHA-256 seal verification:
curl -fsSL https://openooda-tools.github.io/oojq/install.sh | bashInstall directly from the sovereign release channel or download the RPM package:
# Direct remote install via DNF
sudo dnf install https://github.com/openOODA-tools/oojq/releases/download/v0.1.1/oojq-0.1.1-1.x86_64.rpm
# Or download and install locally
sudo dnf install ./oojq-0.1.1-1.*.rpmDownload and install the Debian binary package via APT:
# Fetch and install via APT
curl -fsSLO https://github.com/openOODA-tools/oojq/releases/download/v0.1.1/oojq_0.1.1-1_amd64.deb
sudo apt install ./oojq_0.1.1-1_amd64.debBuild and install using makepkg and the provided Arch Linux PKGBUILD:
# Option A: From cloned repository
git clone https://github.com/openOODA-tools/oojq.git
cd oojq/packaging
makepkg -si
# Option B: Direct download of PKGBUILD
curl -fsSL https://openooda-tools.github.io/oojq/PKGBUILD -O
makepkg -si
# Option C: Prebuilt Pacman package
sudo pacman -U https://github.com/openOODA-tools/oojq/releases/download/v0.1.1/oojq-0.1.1-1-x86_64.pkg.tar.zstoojq can be cleanly and completely uninstalled at any time without leaving stray files or broken registrations:
# Method 1: Using the companion uninstaller deployed with oojq
oojq-uninstall
# Method 2: Via the Universal Web Uninstaller
curl -fsSL https://openooda-tools.github.io/oojq/install.sh | bash -s -- --uninstall
# Method 3: From source repository
make uninstall
# Method 4: Via native package managers
sudo dnf remove oojq # Fedora / RHEL
sudo apt remove oojq # Debian / Ubuntu
sudo pacman -R oojq-bin # Arch Linux# Preview actions without modifying the host
curl -fsSL https://openooda-tools.github.io/oojq/install.sh | bash -s -- --dry-run
# Verify cryptographic SHA-256 seal only
curl -fsSL https://openooda-tools.github.io/oojq/install.sh | bash -s -- --verify
# Custom installation prefix
curl -fsSL https://openooda-tools.github.io/oojq/install.sh | bash -s -- --prefix ~/.local/bin
# Simulate clean uninstallation
curl -fsSL https://openooda-tools.github.io/oojq/install.sh | bash -s -- --uninstall --dry-runmake build # compile main.oo to dist/oojq
make test # 1036 behavioural assertions against the built binary
make parity # byte-compare every filter against the real jq
make verify # line-cap, file-law, academy, density, suggest-audit, dead-tests, dup-names, check
make package # build .deb, .rpm, and .pkg.tar.zst packages
make install # install binary to ~/.openooda/bin/oojq
make uninstall # cleanly remove installed binary, companion uninstaller, and cachesmake parity runs the same filters through the installed jq and through
oojq, in all three output modes — plain, -c, and -r — and compares the bytes.
It is the honest measure of "is this a better jq", so it is kept out of verify
and skips cleanly when jq is not installed.
Requires the oodac compiler, found at ~/.openooda/bin/oodac, falling back to
../../openOODA/oodac/bin/oodac. Override with make OODA_COMPILER=/path/to/oodac.
After a compiler update, clear the build cache before the first build.
rm -rf .ooda-cache dist/oojq && make buildThe cache is keyed on the .oo files it read, not on the compiler that read them,
so an update leaves it able to hand back the previous compiler's decisions for
pages whose source did not change. The result is a binary that builds cleanly,
passes oodac check on every page, and is simply wrong: 381 of the 687 assertions
that existed then failed that way under oodac v2.11.8, in three unrelated families, all of them
values read wrong at runtime. make build keys only on the .oo mtimes, so it
will not notice that oodac itself is newer and may report Nothing to be done.
oojq [options] <filter> [file]
oojq --mcp
options:
-h, --help display help
-V, --version display version
-c, --compact-output one value per line without spaces
-e, --exit-status 1 when the last value was false or null, 4 when
nothing was ever produced, as in jq
--mcp serve the filter engine over MCP on stdin/stdout
Short flags may be run together, so -ce is two flags.
The file argument is optional; with no file oojq reads stdin.
oojq --mcp speaks JSON-RPC 2.0 on stdio. Frames are Content-Length delimited
when the client opens with a header and newline-delimited JSON when it does not,
and every answer reuses whichever the client chose. It implements initialize,
ping, tools/list, and tools/call.
It publishes three tools:
| Tool | Arguments | Returns |
|---|---|---|
jq |
filter, input |
the results, in the same form the command line prints |
jq_check |
filter |
accepted, or the parser's own error |
jq_grammar |
— | the filter language and builtin set this build has |
The last two are the reason to run oojq rather than jq as a tool server. A caller
that can ask what the engine supports stops guessing: jq_grammar names the
builtins and also names the ones this build refuses and why, so a model does
not ask for ascii or splits and then get an error. jq_check answers whether
a filter parses in about a millisecond, which is far cheaper than running it over
a large document and discovering it was wrong.
# One framed round trip
printf '%s' '{"jsonrpc":"2.0","id":1,"method":"ping"}' | oojq --mcp
{"jsonrpc":"2.0","id":1,"result":{}}| Flag | Effect |
|---|---|
-c, --compact-output |
one value per line, no spaces |
-r, --raw-output |
a top-level string is written as its own text rather than as a JSON string; every other kind is unchanged |
-e, --exit-status |
exit 1 when the last value was false or null, 4 when nothing was ever produced |
--mcp |
serve the engine over JSON-RPC on stdin/stdout instead of running a filter |
-r is the one that makes the table formats usable: oojq -r '.rows[] | @csv'
writes a CSV file rather than a file of quoted CSV rows. It changes exactly one
kind, which is jq's rule too — a string at the top level, and nothing else. A
number is still a number, null is still null, and a container is still JSON
in the spacing -c or the default asked for.
Short flags run together, so -rc and -re are two flags rather than a filter
that starts with a dash.
| Filter | Result |
|---|---|
. |
the whole document |
.name |
one member of an object |
.a.b |
a chain of members |
.[] |
every child of an array, or every value of an object |
.a[] |
every child of a member |
.a[0] |
one element; a negative index counts from the end |
.a[1:3] |
a slice, which is itself an array |
.a[9] |
null when the index is out of range |
a | b |
run b over each result of a |
a, b |
both results, in source order |
if c then a else b end |
a when c is truthy, b otherwise |
if c then a elif c2 then b end |
the first branch whose condition is truthy, left to right |
Builtins:
| Shape | Names |
|---|---|
| no argument | length keys type not empty reverse sort unique add first last min max to_entries from_entries tostring tonumber ascii_downcase ascii_upcase |
| one literal argument | has("k") range(n) join("-") startswith("s") endswith("s") ltrimstr("s") test("re") |
| one literal of any shape | contains(x) — a string, a number, an array, or an object |
| position of a value | index(x) rindex(x) indices(x) — over a string or an array; an object is refused |
| Code | Meaning |
|---|---|
0 |
values printed |
1 |
filter selected nothing |
2 |
trouble: bad usage, unreadable file, malformed JSON, refused filter |
4 |
with -e only: nothing was ever produced |
The 1 is oojq's own contract, not jq's. jq exits 0 when a filter selects
nothing; it only reports that under -e, and then it uses 4. So oojq's default
distinguishes "found nothing" from "found something", which jq does not.
With -e oojq matches jq exactly, including the 4:
last value false or null |
nothing produced | values printed | |
|---|---|---|---|
jq -e |
1 |
4 |
0 |
oojq -e |
1 |
4 |
0 |
jq |
0 |
0 |
0 |
oojq |
0 |
1 |
0 |
# Read a field
oojq .name package.json
# Iterate an array, piped onward
oojq '.tags[] | .' openOODA-tools/oojq/README.md
# Union two paths, no quoting needed
oojq .a, .b config.json
# From a pipe
cat data.json | oojq '.items[]'
# Cut a string on a literal separator, streaming one value per piece
oojq '.log | split(" ")' app.log
# Percent-encode a value for a URL
oojq '.title | @uri' page.json
# Write a CSV file, one row per record
oojq -r '.rows[] | @csv' report.json
# Check every record, and answer nothing at all if one is missing its id
oojq -e 'all(has("id"))' rows.jsonSpacing follows jq's default form, one value per line:
$ oojq . demo.json
{ "name": "oojq", "tags": [ "json", "cli" ], "meta": { "stars": 42 }, "note": null }
$ oojq .tags[] demo.json
"json"
"cli"
Each is refused by name rather than silently approximated, because a narrowed filter that returns a confident wrong answer is worse than a refusal.
-
The date builtins
now,localtime, andgmtime-as-a-string are still refused.gmtime,mktime,todate,fromdate,fromdateiso8601,strftime, andstrptimeare implemented (see UTC dates above); these are the rest. -
Errors go to stdout, where jq puts them on stderr.
oojq -c '2|sqrt' > outwrites the refusal intoout, and2>/dev/nulldoes not hide it. This is not a choice. The runtime exposes no stderr writer:printlowers to@oo_print_str, which writes to stdout with no trailing newline, andeprintln— although the compiler knows the name, intc_names_known.ooand in the side-effect classifier inll_fn.oo— has no lowering at all. There is no@oo_eprintlndeclaration inll_need_tab.ooand no matching runtime symbol anywhere in the toolchain, so calling it would not write anywhere. The compiler is a parent project and out of bounds here, so the divergence is recorded and asserted rather than papered over: three assertions inmake testpin the exact text on stdout, pin stderr to empty, and pin the version banner to stdout, so the day the runtime grows a stderr writer the suite says so instead of the docs quietly going stale. -
Exit codes are oojq's own contract where jq's are finer. jq distinguishes
2for a usage or system error,3for a compile error, and5for a runtime error; oojq returns2for all of them, which is written down inmain.ooand encoded in 47 assertions. Two of the three are already matched exactly:-ereports4for never having produced a value, and--helpand--versionexit 0. The remaining gap is deliberate and is not a defect, but it is the one place a shell script that branches on$?will see different behaviour from jq. -
addwith a filter.addwith no argument sums the members and is implemented.add(f)— jq's[f] | add, which runs the filter once over the whole input and sums every answer — is refused with that reason rather than answered with the member sum, which is a different number. See Design Notes. -
Variable binding:
as $x | ...,reduce,foreach, anddef. These need a named environment threaded through the evaluator. All of them, plustry,label,import,include, and__loc__, are refused by their own name rather than as an unknown builtin. That distinction was worth a fix: the page already had a function refusing all nine reserved words, and nothing called it, sodefwas answered unknown builtin "def"; did you mean "del"? — a suggestion pointing a reader at a builtin with one letter in common, on exactly the input where they had named a language construct. A reserved word is still an ordinary field name, so.importanswers1. -
Object construction is supported, including
{}, a bare name key, a quoted key, a key in brackets, the{a}shorthand, and a value written as a generator.{a: (1,2), b: (3,4)}answers four objects in jq's order, and an entry that answers nothing leaves no object at all. It is not a rule in the run:{a: X, b: Y}is rewritten at parse time into a call over the body[[X], ["a"], [Y], ["b"]], so the entries are already gathered by a collect and the product comes from multiplying them.{1: 2},{.k: .v}, and{("a")}are refused by name, because jq reads all three as syntax errors and answering them would build an object nobody wrote. The+merge of two objects also works, and keeps the left hand side's order while a right hand side member replaces the one it shares a name with, so.limits + .limitsis byte identical to jq. -
setpath(p; v)is supported, and it is the whole of what it supports. Both sides are read before anything is written and the body is rewritten at parse time to[[P], [v]], so each side arrives already gathered and a generator on either one multiplies exactly as it does in jq:setpath(["a"]; 1, 2)answers both objects, andsetpath(["a"]; empty)answers nothing rather than a null. A member keeps the position it was first given and a member that was not there is appended; an array position is replaced, a negative position counts back from the end, and a position past the end grows the array with nulls. A null with path still to walk becomes whatever the next step names, sosetpath([0]; 1)onnullis[1]andsetpath(["x","y"]; 1)on{"a":1}is{"a":1,"x":{"y":1}}. The refusals are jq's sentences rather than this tree's: a name into an array saysCannot index array with string "a", a numeric key is named by its kind and not its digits (Cannot index object with number), a position before the start of an array saysOut of bounds negative array index, a path that is not an array saysPath must be specified as an array, and the wrong number of arguments names the arity (setpath/1 is not defined). -
The update operators are supported, and they are not one feature.
=,|=,+=,-=,*=,/=and%=all work on a literal path, and they are three different features wearing one operator table. Measured against jq 1.8.1 before any of them was written, because none of it is derivable from the spelling:.a = vis exactlysetpath(P; v), empty value and all:.a = (1,2)gives two objects and.a = emptygives nothing..a |= fkeeps only the first answer ofgetpath(P) | f(.a |= (1,2)is one object), and when the body answers nothing it cuts the key:.a |= emptyon{"a":1,"b":2}is{"b":2}, where.a = emptyis nothing..a += vand the rest keep every answer (.a += (1,2)gives{"a":2,...}and{"a":3,...}) and answer nothing when the body does, with no cutting branch:.a += emptyis nothing where.a |= emptyis{"b":2}.
All of it is a parse-time rewrite onto
setpath,getpath, and thedelthat already existed, which is why the run needed no line at all for any of it. A path in parentheses is still a path, so(.a) |= .+1answers, and an operator written tight (.a-=1) is still an operator while.a + 1is still a sum. The refusals are jq's sentences: an assignment through a scalar saysCannot index number with string "b", and so does an update through one. What is refused://=, by name, because jq reads its right hand side eagerly —.a //= emptyanswers nothing where.a // 9would not, and.a //= error("x")raises on a value wherexis truthy. That is not a filter this build can hand over, and an operator that answered something else would be a wrong answer. A path naming two members,(.a,.b) = 7, is refused for the same reason: it needsreduceoverpath(), not a literal path. -
walk, and the other builtins that take a filter rather than a literal.getpath,range,limit,with_entries,recurse, andwalkare the exceptions.walk(f)appliesfat every node with the value in hand last: a container is rebuilt from its walked children andfis applied to what was rebuilt, which is why[[1]] | [walk(if .==[1] then "saw" else . end)]answers["saw"]and not[[1]]. A child that answers nothing is dropped and takes its key with it, so{"a":1,"b":2} | [walk(if .==1 then empty else . end)]is[{"b":2}]while a body that answers twice keeps both, and1 | [walk(if .==1 then 7,8 else . end)]is[7,8]. Unlike every other filter-argument builtin it keeps every answer of its body rather than the last. All twenty-two cases measured against jq 1.8.1 are byte-identical. It is answered by regenerating one level as text and handing that back to the same dispatcher, which is where its recursion comes from — see AGENTS.md for why the recursion cannot live in the text itself, and why the object form updates.valuerather than walking the entry.recurse(f)appliesfat every level and answers in preorder — the value in hand, then whatfgives from it, and for each of those the same again — so[[1],[2]] | [recurse(.[]?)]is[[[1],[2]],[1],1,[2],2]and not the breadth-first[[[1],[2]],[1],[2],1,2].fanswering nothing ends that branch, an error is not caught (jq does not catch it either; the barerecurseis safe only because it is written.[]?), and a fixed point does not terminate, so1 | recurse(1)runs forever exactly as in jq.recurse(f; cond)isrecurse(f | select(cond))— measured, both answer[1,2,4,8,16]for1 | recurse(.*2; . < 20)— so it is a parse-time rewrite of the argument rather than a second walk. A barerecursemeansrecurse(.[]?). Thelimit(n; f)rewrite is collect, slice, iterate — so it costs the evaluator nothing and every case jq answers byte for byte matches, includinglimit(0; …)answering nothing and a count past the end keeping all of them. Its one difference is the count: jq readslimit(.a; f)at run time, while a slice bound here is fixed while the filter is parsed, so a count that is not digits written in the filter is refused by name. Running the body without one would answer with every value it produced, which is a plausible wrong answer.with_entries(f)is the same kind of rewrite: it isto_entries | map(f) | from_entries, checked against jq 1.8.1 on a value update, a key update, and aselect, and the two spellings are byte identical both ways, including an array refusing with the same "Cannot use number (0) as object key" because the index is a number andfrom_entrieswill not make it a name. Its bodies are limited to what this build can already write, so the usual.value += 1still needs the update operators below. -
map_valueson a value that is not a container, and a baremap_values.map_values(f)is otherwise answered in full: it is the first answer offat every member, keeping the shape it was given, so{"a":1,"b":2} | map_values(1,2)is{"a":1,"b":1}and not whatmapwould do —mapgathers every answer, and[1,2] | map(1,2)is[1,2,1,2]. A member that answers nothing is dropped and takes its key with it. Both shapes are answered by a parse-time rewrite toto_entries | map(.value |= (f)) | map(select(has("value"))) | map(.value)over an array and the same text intofrom_entriesover an object, which is the one route here to "first answer, dropped on empty" over both — the rewrite costs the evaluator nothing, and 17 of 21 probed cases are byte-identical to jq 1.8.1. The remaining four are refusal wording, not wrong answers: a number, a string and a null say "to_entries needs an array or object, not a …" where jq says "Cannot iterate over number (1)", and a baremap_valuesis "unknown builtin" where jq says "map_values/0 is not defined". Both forms refuse, which is the point; matching the wording would cost the hub a line it does not have. -
from_entrieson an entry that is not shaped the way it is read. The name and value are read from four and two spellings respectively, in an order that is measured rather than taken from the manual:keybeatsKeybeatsnamebeatsName, andvaluebeatsValue, whilek,v,valandKEYare not read at all — so[{"k":"a","v":1}] | from_entriesis not{"a":1}, it is the refusal "Cannot use null (null) as object key", which is also what an entry with no key at all gives. A member that kept its key and lost its value reads as null, matching jq; it used to answer nothing at all, which is the exact shapewalk(f)handsfrom_entrieswhen a child answers empty, so one dropped key of two dropped the whole object. A key that is not a string is refused by kind and by value ("Cannot use boolean (true) as object key"), and a non-object entry is refused the way jq indexes it ("Cannot index number with string "key""). All sixteen cases probed are message-identical to jq 1.8.1. -
splits,sub, andgsub, andteston a pattern the std engine cannot honour. These are refused by name. The reason for the first three is a defect in the std regex engine, not a missing parser:match_pattern_first, which all three are built on, callsstr_sliceone index past the end of the string, so a pattern that does not match reads off the buffer and the process dies on a signal."abc" | sub("z"; "y")crashes rather than answering. Fortestthe defect is quieter and was worse: the NFA reads a pattern one character at a time and has no escape, group, counted repetition, class range, or negated class, while the validator accepts all of them as well formed. Sotest("a\\.c"),test("(ab)c"),test("a{1}"),test("a\\d"),test("[a-z]"),test("[^a]"), andtest("[A-Z]")each answered a confidentfalsewhere jq answerstrue. Afalsefromtestdrops the rows it was meant to keep, so each is now refused by the name of the construct it uses. Literals,.,*,+,?,|,^,$, and a plain[abc]still work, andsplitis literal, so the common cases are covered. -
paths(f)andleaf_paths(f). The bare forms work. The filter form is refused by name rather than answered with every path, which is what it used to do:[paths(.=="a")]|lengthwas 16 where jq gives 2. -
fromjson?— the barefromjsonworks, and its answer is copied into the arena the caller keeps, butfromjson?needs aparse_primmarker for the bare form. -
$__loc__,input,inputs, and every other environment or input source builtin. -
tostream,fromstream,todate, andfromdate.indicesused to be on this list and is now implemented — see Where a value appears below. Where a builtin takes a generator where this build's argument path carries one, the call refuses instead:index,rindex,indices,containsanderroranswer "… takes one value, and the argument written gives several", which is where[1,2] | index(1,2)used to answer1where jq answers0then1.setpathis deliberately left out of that refusal: it takes a generator on purpose and answers once per value.toarrayandasciiare deliberately not here: jq 1.8.1 does not have them either, and matching a builtin that does not exist is not parity. -
inandcombinations, both of which jq 1.8.1 itself is broken on.inis a syntax error in jq 1.8.1 for the form it is written in (. as $o | "a" in $odoes not parse), andcombinationsis implemented in terms ofin, so[1,2] | combinationserrors withCannot iterate over number (1)while[[1],[2]] | combinationsworks. Matching a builtin whose own reference behaviour depends on a broken one would mean copying the breakage, so neither is built.combinations(n)does work in jq and is the only part worth having; it is not built yet. -
Division whose quotient does not terminate, and arithmetic over a number too wide for a whole number to hold.
1/3has no exact decimal form at all, so it is refused rather than rounded, and1.5/8080is refused for the same reason where jq prints seventeen digits of a double. Everything that terminates is answered exactly:10/4is2.5,1/8is0.125,1.5/0.5is3,0.5/0.25is2, and1/0.0001is10000. Both sides are lifted to whole numbers over the same power of ten first, so a decimal divides by exactly the same long division a whole number does. Seventeen fraction places and seventeen or eighteen digits is the widest this will name, and anything wider is refused rather than wrapped. -
ifwithout anelse. jq treats a missingelseasnull; this build requires the branch.
Float arithmetic works, and it is exact. The compiler bug that blocks
to_string on a Float is still real and is recorded below, but it is worked
around by carrying numbers as decimal text and folding the digits, so 1.5 + 1
is exactly 2.5, floor, ceil, round, and abs all work, and * and /
work on decimals rather than only on whole numbers.
sqrt is the one arithmetic builtin with a boundary rather than a yes or a no,
and the boundary is drawn where it can be drawn exactly. A square has an exact
root and oojq gives it: 4|sqrt is 2, 0.25|sqrt is 0.5, 1000000|sqrt is
1000, and a whole root prints as a whole because that is how jq prints it. A
negative has no real root, so -4|sqrt is null, which is jq's answer and not a
refusal.
Everything else is refused by name rather than rounded. jq answers 2|sqrt
with 1.4142135623730951 — the shortest text that names the IEEE double it
computed — and that is a different thing from the exact root, which has no
finite decimal spelling at all. Producing it would need the double itself, and
this build has no way to hold one. Printing a rounded root as though it were
exact is precisely the confident wrong answer the rest of this document refuses
to give, so 2|sqrt says why it cannot and stops. The same applies past
eighteen digits, where the whole root no longer fits.
AGENTS.md is the real document: house laws, domain contracts, and the runtime
traps that cost debugging time. These are the ones worth knowing before editing:
-
A string operation checks its operands, and getting that wrong is a wrong answer rather than a gap. Nine builtins read a string out of their argument —
index,rindex,indices,split,startswith,endswith,ltrimstr,rtrimstr,test— and each had a path where it coerced the wrong kind instead of refusing:"abc" | index(1)answerednull,null | startswith("a")answeredfalse,1 | split("b")answered[""]. All eight are now refused, in jq's own words, by one guard infilter/eval/eval_builtin.oobecause the shape is identical and only the wording differs. The guard runs before the generic "needs a scalar literal argument" check, so"abc" | startswith(null)saysstartswith() requires string inputsrather than the vaguer sentence.ltrimstrborrowsstartswith's sentence andrtrimstrborrowsendswith's, because that is what jq does — it builds one from the other. The lesson generalises: an answer produced for a shape the author did not think about is a wrong answer, and a kind check is the cheapest test there is. -
""|split("b")is[]and not[""]. The empty string has no pieces once the separator is not itself empty, so the trailing-piece step that makes"a" | split("a")give["",""]must not run at all. Found by accident, by writing a probe whose pipe changed the input to the builtin under test. -
A kind matrix finds what a corpus cannot. Running every dispatched builtin against every kind of input and every kind of scalar argument — 1 504 agree, and of the 893 that differ, 2 113 more are refusals on both sides differing only in wording — left 29 cases where both binaries answer and the answers differ. Those are the only ones that matter, and two families came out:
add(f)dropped its filter and summed the members instead ([1,2] | add(.)is[1,2]in jq and was3), andindexover anullaccepted a needle it should refuse. Both are fixed. The matrix is at/tmp/kinds.sh; the discipline is that a value produced for a shape nobody thought about is a wrong answer, and the cheapest way to find those is to cross the kinds rather than to enumerate more examples. -
add(f)is[f] | add, not a member sum, and is refused rather than guessed. jq runs the filter once over the whole input and sums every answer, which is why[1,2,3] | add(.)is[1,2,3]and[[1,2],[3]] | add(length)is2. Summing the members gives a different number, so the call now refuses with the reason. The machinery to do it properly already exists — collect mode runs a body over the input and gathers every answer — but the page that would finalise it is at the 256-line ceiling and its directory at the 8-page density limit, so it is backlog rather than a rushed change. -
Numbers. std reports a number as the byte offset past its last digit and never materialises the digits.
parse/decode_number.oois the only place a literal becomes anInt, andparse/float_scan.oohides decimal points from the scanner by rewriting each float to digits of the same width, which keeps every byte offset valid. A float keeps its source text, so parse and render round trip losslessly. -
No Float to String.
oodac/emit/llvm/ll_builtin_host.oo:220routesto_stringtooo_int_to_strfor every argument type, soto_string(1.5)emitsoo_int_to_str(i64 1.5)and fails at the LLVM stage. The runtime has nodouble-to-string function either. oojq therefore never puts aFloatin the arena:filter/eval/num_read.ooandeval_num.oocarry a number as its decimal text and fold the digits, which is exact where aFloatwould round. -
Escaping.
std/core/format/json_writer.oois a complete fluent encoder, but its escaper leaves every control byte below0x20unescaped apart from three.render/render_string.oocovers the full set, and escapes0x7fand anything above it as\uXXXXso output stays valid UTF-8. -
Text is characters, but
byte_atis bytes.chars_lencounts characters whilebyte_atindexes bytes, so a loop that pairs them walks off the end of any multi-byte string. The renderer did exactly that and printed"héllo"as"h\u00c3\u00a9ll". Both now walk characters withchar_atand copy a multi-byte one through whole. -
The std JSON reader refuses
\uXXXX. It reportsbad_escapeand stops.parse/unescape_unicode.ootherefore rewrites every escape into the UTF-8 bytes it stands for before the reader sees the document, joining a surrogate pair into the one code point it denotes and standing a lone surrogate in asU+FFFD. It hands back a byte buffer rather than a string, because reading those bytes back out of a string would lose every one after the first multi-byte character. -
Determinism. The arena is append-only and object member order is preserved as parsed, so two runs over identical input produce identical bytes.
make testasserts this. -
An object never holds a name twice. jq keeps the position a name was first written in and the value it was last given, which is what a merge does and what a source saying
{"a":1,"a":2}means. The parser andfrom_entrieseach used to keep both copies, so one name read as two:keysanswered["a","a"],lengthanswered 2, andto_entriesinvented a member that was never written. The reason it survived is worth keeping:.aanswered2on both binaries, because last-write-wins is what a lookup does. Only the questions that read the container as a whole found it. The rule now lives input_memberinparse/value_tree.ooand is enforced byjval_object, which every way of building an object passes through. -
A document of more than a few hundred members is slow, and the cause is the language rather than the filter. This is the one measured place oojq is far worse than jq, so it is worth being exact about why. In openOODA a
Listappend returns a new list:list_pushcopies. There is no growable buffer in the language, and the std collections that look like one are not —pvec_pushinpersistent_vector.oorebuilds its whole element list, andfhm_insertinflat_hash_map.oogoes through alist_set_strthat rebuilds the key list. Sojdoc_push, which appends one node to the arena's list, is O(n) per push and an n-node arena is O(n²) by construction, and nothing in the filter language can work around it. Measured on this build,lengthover a document of n members, where jq answers every one of these in under 0.01s:n oojq object oojq array 250 0.01s <0.01s 500 0.05s 0.01s 1000 0.19s 0.04s 2000 1.17s 0.16s 3000 3.21s 0.41s The two are quadratic and the object is about seven times the array at the same size, because
jval_objectenforces the no-duplicate-name rule with a second quadratic pass over the keys. The two are indistinguishable from jq up to about a hundred members, which is most documents anyone writes by hand; the crossover into "noticeable" is around 250. This is a representation limit, not a filter limit, and the honest fix is a non-copying arena rather than a cleverer walk. What is not done here is a fast path that scans for a duplicate name before rebuilding: it would cut the constant and leave the asymptotics exactly where they are, in the single most-used function in the tree, and a silent miscompile there is not a trade worth making for a constant. -
@formatistostringplus a transform, andtostringis not scalar-only. jq writes a container as the same compact JSON it would print, sorender_as_textinrender/render_value.ooanswers every node kind and@text,@html, and@uriall build on it. Refusing a non-string would be refusing something jq answers:1|@htmlis"1".
main.oo argv routing, the exit-code contract, the pipeline
parse/ JSON text -> value tree (arena of nodes plus a root index)
filter/ filter text -> value stream
render/ value stream -> JSON text
ipc/ file and stdin reader today; MCP and socket later
All code adheres to the openOODA House Laws documented in AGENTS.md:
- Page Rule: Every source page strictly bounded between 16 and 256 lines, at most 8 per directory.
- Academy Headers: Mandatory 4-element docstrings on every page, all four elements inside the first 7 lines.
- Zero-Panic Invariant: Robust error propagation without unvetted crashes.
- Exit Code Law:
0values,1nothing selected,2trouble. - File Law: Clean tree with no forbidden file extensions or stray docs.
There are three oracles, and they fail differently on purpose.
| what it asks | what it cannot see | |
|---|---|---|
make test |
does each named behaviour still hold | any input nobody wrote a test for |
make parity |
is this one output byte-identical to jq | a filter the corpus does not mention |
make sweep |
is a corpus-blind probe byte-identical to jq | nothing — that is the point |
parity and sweep draw on different corpora on purpose. The parity corpus only
grows where a divergence was already found, so it confirms and never discovers.
The sweep corpus in qa/sweep_cases.txt is deliberately disjoint from it, and it
is the only layer that can find a class of input nobody thought of. Two real
bugs came out of its first run: max compared with > and so kept the earlier
of two equal values, and an if branch was handed the condition's answer instead
of the value the condition was asked of.
Two rules are built into how the sweep runs, both learned by breaking them:
- Comparison is
cmpon files, never$(...). Command substitution drops NUL bytes from both sides, so a value holding one would compare equal to a value that is simply absent. - A reference run is never truncated. Piping
jqintohead -1on a filter that answers several times hides the shape of the answer, and it is how this project briefly came to believe that anifreads only its first answer. Every divergence is printed in full.
make dead-tests fails when two assertions share a name. It was written because
three did, and each was a continuation of the sentence before it — "and not above
it", "and the same the other way round" — which is what a name becomes when
nobody can say which assertion failed. A name has to stand on its own.
make coverage prints, per dispatched builtin, how many behaviour assertions and
how many parity cases name it. It is not coverage and does not claim to be — it
cannot tell a test that exercises a builtin from one that mentions the word. What
it catches is the failure that actually happened: a builtin no test reaches at
all, which prints 0 0. leaf_paths printed 0 and had been shipping untested,
and no other gate noticed. Two columns because the two oracles differ —
booleans and objects are reached by parity alone.
Assertions that assert nothing was written use assert_bytes, not
assert_out. Command substitution strips trailing newlines, so assert_out
cannot tell one empty line from no output at all, and a test written that way
passes even when the program writes nothing. Counting bytes on stdout does tell
them apart.
Not covered: there are no fuzz, property, or performance tests, and no
mechanism yet for retiring a test that no longer protects anything. parity and
sweep are deliberately outside verify, so the byte-diff against real jq is
not part of the green gate.