Skip to content

Cooklang name-termination: contract's {-adjacency guard is not enforced (@salt into the {bowl} misparses) #907

Description

@drawmeanelephant

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:

  1. 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).
  2. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions