Summary
Severity: medium · Class: Confirmed defect (contract / pinned-parser drift)
Authority: found on review-only audit card #820
Locus: docs/contracts/cooklang-compatibility.md:93-106 (claims) vs the pinned Oliver parser tryToken (oliver pin d742494, oliver#77; vendored src/cooklang.zig:~815-875) consumed by src/cooklang_seam.zig:200 (oliver.cooklang.parse).
Claim (contract, normative)
A name containing spaces must be closed with {, and that { must touch the name — no space between them. … Without the adjacency rule, add @salt into the {bowl} reads salt into the as the name and bowl as its amount, deleting the prose between them from the rendered step — an unrelated braced word later in a sentence is not part of the name.
Observed (black-box, boris/0.8.2 @ cbc9fa9f)
Corpus:
Add @salt into the {bowl} and stir.
boris --cooklang --input content --html-dir dist (exit 0), rendered page:
<li>salt into the — bowl</li> <!-- Ingredients list -->
<li>Add salt into the and stir.</li> <!-- Method: the braced word "bowl" is deleted from prose -->
boris recipe-scale --input content --id index --factor 2 --cooklang (exit 0):
{ "name": "salt into the", "quantity": { "amount": { "class": "fixed", "original": "bowl", "scaled": "bowl" }, "unit": "" }, ... }
This is exactly the misparse the contract says the adjacency rule prevents. Source-level confirmation: Oliver tryToken scans from the marker to the first { on the line with no adjacency check; a { anywhere later in the sentence converts a one-word name into a multi-word name and swallows the intervening prose as a fixed amount.
The contract's other two name-termination conditions do hold (verified same session, black-box):
- sigil stop:
Add @salt and @pepper{1} → salt + pepper ✓
- sentence punctuation:
Add @salt. Then taste. → salt ✓
- unrelated
/ names stay ordinary ingredients: @half/half{1%cup} → recipeRef: null ✓
Impact
- Wrong ingredient extraction and wrong amount in the
recipe IR facet (salt into the / bowl), wrong Ingredients list, and prose deletion in the rendered Method step.
- Deterministic, no injection surface (names still go through the escape table). No build failure: the misparse is silent.
- Diverges from upstream Cooklang, where a brace-less name ends at whitespace.
Smallest remediation card
Two options; pick one explicitly:
- Enforce the guard (preferred): add the
{-adjacency check to Oliver's tryToken (a { not touching the name, modulo the single-word form, ends the name scan like any other stop character), repin Boris, and pin the case with a cooklang_seam test (@salt into the {bowl} → name salt, no quantity, prose intact).
- Re-claim the contract: if the no-adjacency multiword form is judged acceptable, rewrite
cooklang-compatibility.md §Name termination to describe the actual first-{-on-line rule and delete the "must touch the name" claim — including the table row "Name ends at {, which must touch the name".
Verification
- Repro above (exit 0 build + recipe-scale view).
- After fix: the same corpus must yield ingredient
salt, amount empty, step prose Add salt into the {bowl} and stir. (with {bowl} escaped per the escape table), and the recipe facet matching.
zig build test + ./scripts/release-gate.sh (cooklang lanes).
Summary
Severity: medium · Class: Confirmed defect (contract / pinned-parser drift)
Authority: found on review-only audit card #820
Locus:
docs/contracts/cooklang-compatibility.md:93-106(claims) vs the pinned Oliver parsertryToken(oliverpind742494, oliver#77; vendoredsrc/cooklang.zig:~815-875) consumed bysrc/cooklang_seam.zig:200(oliver.cooklang.parse).Claim (contract, normative)
Observed (black-box,
boris/0.8.2@cbc9fa9f)Corpus:
boris --cooklang --input content --html-dir dist(exit 0), rendered page:boris recipe-scale --input content --id index --factor 2 --cooklang(exit 0):{ "name": "salt into the", "quantity": { "amount": { "class": "fixed", "original": "bowl", "scaled": "bowl" }, "unit": "" }, ... }This is exactly the misparse the contract says the adjacency rule prevents. Source-level confirmation: Oliver
tryTokenscans from the marker to the first{on the line with no adjacency check; a{anywhere later in the sentence converts a one-word name into a multi-word name and swallows the intervening prose as a fixed amount.The contract's other two name-termination conditions do hold (verified same session, black-box):
Add @salt and @pepper{1}→salt+pepper✓Add @salt. Then taste.→salt✓/names stay ordinary ingredients:@half/half{1%cup}→recipeRef: null✓Impact
recipeIR facet (salt into the/bowl), wrongIngredientslist, and prose deletion in the rendered Method step.Smallest remediation card
Two options; pick one explicitly:
{-adjacency check to Oliver'stryToken(a{not touching the name, modulo the single-word form, ends the name scan like any other stop character), repin Boris, and pin the case with acooklang_seamtest (@salt into the {bowl}→ namesalt, no quantity, prose intact).cooklang-compatibility.md§Name termination to describe the actual first-{-on-line rule and delete the "must touch the name" claim — including the table row "Name ends at{, which must touch the name".Verification
salt, amountempty, step proseAdd salt into the {bowl} and stir.(with{bowl}escaped per the escape table), and therecipefacet matching.zig build test+./scripts/release-gate.sh(cooklang lanes).