Skip to content

fix(backend): the recogniser had no idea what a string was - #62

Merged
derekslenk merged 2 commits into
mainfrom
fix/lexical-recogniser
Aug 22, 2026
Merged

fix(backend): the recogniser had no idea what a string was#62
derekslenk merged 2 commits into
mainfrom
fix/lexical-recogniser

Conversation

@derekslenk

Copy link
Copy Markdown
Owner

areyousievious-8fg.10 — the headline fix of the architecture-review epic. Unblocks .11, .13 and .15.

Three defects, one root cause

Block extent came from line.count("{") - line.count("}"); actions came from a regex over raw text. Neither knows what a string or a comment is. All three reproduced before touching anything:

# Input What came back
1 fileinto "Weird{Folder"; then a second rule One rule carrying subject is alpha and both actions — the second rule's condition gone
2 if A { if B { fileinto "X"; } } if A { fileinto "X"; } — condition B gone
3 # fileinto "Disabled"; inside a block A live fileinto "Disabled";

Defect 1 means mail matching beta stopped being filed and mail matching alpha was filed twice. Defect 2 means the inner action fired on the outer condition alone. Defect 3 means commenting an action out and reopening the editor re-enabled it.

None of them tripped the RawBlock safety net, because the parser did not fail. It succeeded, misread, and regenerated valid Sieve that routed mail somewhere else. That is what makes this corruption rather than a bug anyone would have reported.

The fix

_LexicalMap borrows sievelib's Lexer — a { inside a string is part of one string token, # ... is one hash_comment token — and gives the parser per-line real-brace deltas plus a masked copy where comment bodies become spaces. Same length, so every offset still lands where it did. Strings are not masked; the regexes still need to read them.

Only the Lexer. sievelib's Parser resets RequireCommand.loaded_extensions, a class attribute, on every parse (parser.py:138), so adopting it would need a process-wide lock. The Lexer holds nothing across calls and a fresh one is built per parse. Checked rather than assumed: 24,000 concurrent parses over 16 threads, 0 disagreements.

Recognition did not drop

The acceptance criterion, measured across all 60 fixtures, old parser vs new:

rules  68 -> 68      raw  86 -> 82

One fixture moved: vendor/set-command.sieve, (0, 6)(0, 2). It moved because it contains "${header}"defect 1 was shattering a real third-party script into six fragments. The corpus from .3 found that; nothing else would have.

The import lock

sieve_transform now imports from sievelib, which the .9 lock forbids — and that test says in terms "do not widen _MAY_IMPORT". So it isn't widened at the package.

_protocol_imports now reports full dotted names, and a grant covers what it names and anything beneath it. Every existing entry keeps its exact meaning; this one is written {"sievelib.parser"}.

Verified before granting: that module's entire import closure is re, sys, typing, typing_extensions, collections.abc and two sibling sievelib modules. It cannot open a socket, and sievelib.managesieve — which can — is not granted. Mutation-checked: adding that import to sieve_transform fails the lock, and the message names who may.

What this does NOT close

Said plainly rather than left to be discovered: a command we do not model is still dropped from a block we do recognise. setflag "\Seen"; next to a fileinto regenerates with the setflag gone. Nothing is mis-segmented there — it's the projection, which is .11. Pinned xfail(strict=True) naming that bead, so the day it's fixed this suite says so.

And .11 will want to know whether a sievelib Parser gate costs recognition. Measured: it rejects 0 of the 71 rules we recognise. That's a test in this PR, not a sentence in a commit message.

Verification

  • 1113 passed, 4 xfailed · ruff check + ruff format --check clean · frontend untouched and green
  • Each of the three fixes mutation-checked separately — each mutation kills its own tests and no others
  • Every fixture asserted lexable, so none is silently on the character-counting fallback
  • The undocumented Lexer.pos behaviour this rests on is pinned by a test: every token's recorded offset must reproduce that token's bytes

Refs areyousievious-8fg.10

🤖 Generated with Claude Code

Three defects, all reachable from the UI, all one root cause: block extent
came from `line.count("{") - line.count("}")` and actions came from a regex
over raw text. Neither knows what a string or a comment IS. Reproduced all
three before touching anything:

  1. `fileinto "Weird{Folder";` — the brace inside the STRING was counted, so
     the block never closed and the NEXT rule was swallowed into it. One rule
     came back carrying `subject is alpha` and BOTH actions. Mail matching
     `beta` stopped being filed; mail matching `alpha` was filed twice.
  2. `if A { if B { fileinto "X"; } }` came back as `if A { fileinto "X"; }`.
     Condition B gone; the filing happened on A alone.
  3. `# fileinto "Disabled";` came back as a live action, so commenting an
     action out and reopening the editor RE-ENABLED it.

None of them tripped the RawBlock safety net, because the parser did not
fail. It succeeded, misread, and regenerated valid Sieve that routed mail
somewhere else. That is why this is the headline fix and not a bug report.

_LexicalMap borrows sievelib's LEXER — a `{` inside a string is part of one
`string` token, `# ...` is one `hash_comment` token — and gives the parser
per-line real-brace deltas plus a masked copy where comment bodies are
spaces. Same length, so every offset still lands where it did. Strings are
NOT masked; the regexes still need to read them.

Only the Lexer. sievelib's PARSER resets `RequireCommand.loaded_extensions`,
a class attribute, on every parse (parser.py:138), so it would need a
process-wide lock; the Lexer holds nothing across calls and a fresh one is
built per parse. Checked rather than assumed: 24,000 concurrent parses over
16 threads, 0 disagreements.

RECOGNITION DID NOT DROP — the acceptance criterion. Measured across all 60
fixtures, old parser vs new: rules 68 -> 68, raw 86 -> 82. One fixture moved,
`vendor/set-command.sieve`, from (0, 6) to (0, 2) — and it moved because it
contains `"${header}"`, so defect 1 was shattering a real third-party script
into six fragments. The corpus from `.3` found it; nothing else would have.

IMPORT LOCK. sieve_transform now imports from sievelib, which the `.9` lock
forbids, and that test says in terms: do not widen `_MAY_IMPORT`. So it is
not widened at the package. `_protocol_imports` now reports FULL dotted names
and a grant covers what it names and anything beneath it — every existing
entry keeps its exact meaning, and this one is written `{"sievelib.parser"}`.
Verified before granting: that module's whole import closure is re, sys,
typing, typing_extensions, collections.abc and two sibling sievelib modules.
It cannot open a socket, and `sievelib.managesieve`, which can, is not
granted. Mutation-checked — adding that import to sieve_transform fails the
lock and the message names who may.

WHAT THIS DOES NOT CLOSE, said plainly rather than left to be discovered: a
command we do not model is still dropped from a block we DO recognise.
`setflag "\Seen";` next to a `fileinto` regenerates with the setflag gone.
Nothing is mis-segmented there — it is the PROJECTION, which is `.11`. It is
pinned xfail(strict=True) naming that bead, so the day it is fixed this
suite says so.

`.11` will want to know whether a sievelib Parser gate costs recognition.
Measured: it rejects 0 of the 71 rules we recognise. That is a test, not a
sentence in this message.

Each of the three fixes mutation-checked separately; each mutation kills its
own tests and no others. 1113 tests, 4 xfailed, ruff clean.

Refs areyousievious-8fg.10
@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Claude finished @derekslenk's task in 12m 14s —— View job


Code Review — PR #62

  • Gather context (PR diff, changed files, backend/sieve_transform.py)
  • Run review (correctness + doc-drift pass)
  • Verify findings against source
  • Post results

I traced the old vs. new block-collection logic against the changed fixtures — the vendor/set-command.sieve census move (0,6)(0,2) is a correct consequence of the fix (a "${header}" string value contains a {/} pair that used to fool the old character-counter into truncating the block early). The core lexical-map approach is sound. Three findings survived verification:

1. _LexicalMap.__init__ can crash outside its own safety netbackend/sieve_transform.py:218
text.encode("utf-8") runs before the try: block (line 232) that's meant to catch every way the lexer can reject input and fall back gracefully.

  • Failure scenario: PUT /api/scripts/{name}/raw (routers/scripts.py:130-132) stores req.content with no UTF‑8-encodability check — SaveRawRequest.content is a plain str, and stdlib json.loads happily decodes an unpaired \ud800 escape into a lone-surrogate Python str. The next GET /api/scripts/{name} calls parse_sieve(...) → hits the unguarded .encode("utf-8") → unhandled UnicodeEncodeError (500). The old, pure-string-manipulation parser never required UTF‑8 encodability anywhere, so this is a regression versus the pre-PR behavior, not just a pre-existing gap.
  • Suggested fix: move the .encode("utf-8") inside the existing try/except Exception (or catch UnicodeEncodeError explicitly) so it falls back to character-counting like every other "lexer can't handle this" case.
  • Fix this →

2. The except Exception around the lexer scan is wider than "unknown token"backend/sieve_transform.py:253
The docstring frames the fallback as triggering only when "the Lexer refuses the text outright." In practice the bare except Exception: also swallows bugs in the masking loop itself (e.g. an IndexError from masked[i] at lines 246-252 if a future sievelib upgrade changes token-value semantics so begin + len(value) overruns masked's bounds). Such a bug would silently disable the lexical model for that script — reintroducing the three data-corrupting defects this PR exists to close — with no log line and no signal outside the fixture corpus that test_every_fixture_is_lexable covers.

  • Lower severity than security hardening #1 (requires a future regression to trigger), but worth narrowing to the exceptions the Lexer itself can actually raise, or at least logging on fallback so silent corruption doesn't ship unnoticed.

3. Root AGENTS.md still claims a hand-rolled parserAGENTS.md:44
"The Sieve parser is hand-rolled (not sievelib)" is now false — sieve_transform.py imports and depends on sievelib.parser.Lexer. backend/AGENTS.md's file table and pipeline description were updated for this PR, but the root file's "Key Architectural Constraints" bullet was missed, leaving a doc-drift trap for the next agent/contributor who reads only the root file.

Nice work on the PR overall — the fixture-driven acceptance criterion, the mutation-checked import lock grant scoped to sievelib.parser specifically (not sievelib.managesieve), and the concurrency check on the stateless Lexer are all solid verification practice for a fix of this kind.

Three findings from the review of #62. The first is a real regression I
introduced, and I checked it rather than taking it on trust:

  json.loads(r'{"c": "keep;\ud800"}')  -> 'keep;\ud800'
  old parse_sieve -> 1 entry
  new parse_sieve -> UnicodeEncodeError

`SaveRawRequest.content` is a plain `str` and `json.loads` hands a lone
surrogate through intact, so that reaches PUT /raw and comes back on the
next GET as a 500. The character-counting parser this replaced never needed
the text to be bytes at all. `text.encode()` ran BEFORE the try that exists
to make exactly this fall back.

Scanning is now `_scan`, guarded for the two ways the INPUT can be the
problem — ParseError and UnicodeEncodeError — and nothing else.

Second finding, same block: the old `except Exception` also covered the
masking arithmetic. A bug there would have silently dropped the lexical
model and let all three defects back in with no signal. That code now sits
OUTSIDE the guard: it is our own arithmetic over tokens we already hold, and
loud is the safer failure for it.

Third: root AGENTS.md still said "The Sieve parser is hand-rolled (not
sievelib)", which this branch made false. backend/AGENTS.md was updated and
the root file was missed — precisely the drift that file exists to prevent.
Both it and SieveParser's own docstring now draw the line where it actually
falls: the LEXICAL questions are sievelib's, the PROJECTION is ours, and
sievelib's AST and tosieve() remain unused.

Both fixes pinned: the surrogate one by its own regression test, the narrow
guard by a test that asserts _scan raises for each input problem and that
only _scan is wrapped.

1115 tests, 4 xfailed, ruff clean.

Refs areyousievious-8fg.10
@claude

claude Bot commented Aug 22, 2026

Copy link
Copy Markdown

Claude finished @derekslenk's task in 1m 54s —— View job


Code Review — PR #62 (re-review after synchronize)

  • Gather context (new commits since last review, current diff vs origin/main)
  • Run review (correctness + doc-drift pass)
  • Verify findings against source
  • Post results

Summary

Commit 452b3d7 addresses all three findings from the prior review of this PR. Verified each against the current source:

1. Encode-before-try (regression, 500 on lone surrogates) — fixed.
text.encode("utf-8") now lives inside the new _LexicalMap._scan staticmethod (backend/sieve_transform.py:218-238), and __init__ wraps the call to _scan in try/except (SieveLibParseError, UnicodeEncodeError) (lines 247-256). Regression test test_text_that_cannot_be_bytes_falls_back_rather_than_500ing reproduces the exact json.loads(r'{"c": "keep;\ud800"}') path from the original finding and asserts fallback instead of a crash.

2. Overly-broad except Exception around masking arithmetic — fixed.
The masking loop (bisecting line numbers, writing into masked[i]) now sits outside the try block entirely (lines 258-277), so a future bug there raises loudly instead of silently disabling the lexical model. test_the_fallback_is_narrow_by_construction pins that only _scan is guarded, and only for the two input-side exceptions.

3. Root AGENTS.md doc-drift — fixed.
The "hand-rolled (not sievelib)" claim is replaced with an accurate description of the split: lexical questions (braces, comments) are sievelib's Lexer; projection onto Rule/Condition/Action stays hand-rolled; sievelib's AST/tosieve() remain unused. SieveParser's docstring was updated to match the same line.

No new issues found in the fix commit — the exception narrowing is precise (SieveLibParseError, UnicodeEncodeError only, matching what _scan's own docstring promises), and both fixes are pinned by dedicated tests rather than asserted only in the commit message. CI is green on the latest commit (CI and PR #62 workflows both passed).

Nothing further to flag on this pass.

@derekslenk
derekslenk merged commit fc015df into main Aug 22, 2026
8 checks passed
@derekslenk
derekslenk deleted the fix/lexical-recogniser branch August 22, 2026 22:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant