Python: fix files being silently dropped over Rust/Python escape mismatch - #22443
Merged
Conversation
`tsg-python` serialises strings with Rust's `Debug` formatting, which
`literal_eval` on the Python side disagrees with in two ways:
- characters Rust considers non-printable, including grapheme-extending
ones such as U+FE0F, U+200D and combining accents, come out as
`\u{...}`, which Python does not accept at all;
- NUL comes out as `\0`, which Python reads as the start of an *octal*
escape, so `"\0" + "1"` silently decodes to U+0001.
Everything else Rust emits round-trips, verified exhaustively over every
Unicode scalar value paired with every printable ASCII neighbour.
Also stop `Logger.log` from applying `%`-formatting when there are no
arguments, which turned the resulting warning into a `TypeError` and
dropped the whole file from the analysis when the offending literal
happened to contain a `%` directive.
Along the way, settle on `warning` as the logger method name, matching
`Logger` and the standard library. `Logger.warn` never existed, so the
two remaining `logger.warn` calls would have raised `AttributeError`.
Fixes #22435
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes Rust/Python escape incompatibilities that could corrupt parser output or drop valid Python files.
Changes:
- Translates Rust Unicode and NUL escapes before Python evaluation.
- Corrects logger formatting and deprecated warning calls.
- Adds parser and regression coverage for affected Unicode constructs.
Show a summary per file
| File | Description |
|---|---|
python/ql/lib/change-notes/2026-08-27-tsg-parser-unicode-escapes.md |
Documents the parser fix. |
python/extractor/tsg-python/src/main.rs |
Documents serialization coupling. |
python/extractor/tests/test_tsg_parser.py |
Tests escape conversion and logging. |
python/extractor/tests/parser/unicode_escapes_new.py |
Adds Unicode parser cases. |
python/extractor/tests/parser/unicode_escapes_new.expected |
Records expected AST output. |
python/extractor/semmle/worker.py |
Uses the supported warning API. |
python/extractor/semmle/python/passes/flow.py |
Reuses safe message formatting. |
python/extractor/semmle/python/parser/tsg_parser.py |
Translates Rust escape syntax. |
python/extractor/semmle/python/parser/dump_ast.py |
Correctly counts parser warnings. |
python/extractor/semmle/python/imports.py |
Uses the supported warning API. |
python/extractor/semmle/logging.py |
Avoids formatting argument-free messages. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 11/11 changed files
- Comments generated: 2
- Review effort level: Balanced
The test claimed exhaustive coverage but checked seven suffix characters and no prefix at all. Cover every printable ASCII neighbour on both sides of each escape shape, rendering `"` and `\` as Rust would. 2280 cases, 24ms. Also state the NUL case as the single literal `"\01"`, since the `+` notation read as concatenation of two separate literals. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
tausbn
reviewed
Aug 27, 2026
tausbn
left a comment
Contributor
There was a problem hiding this comment.
Overall, this looks sensible to me, but I have one comment about the logging fix that should maybe be addressed.
tausbn
approved these changes
Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A Python file could be silently dropped from the analysis, reported only as a generic "A parse error occurred" diagnostic pointing at syntax that is in fact perfectly valid. The reproducer in #22435 needs three ingredients at once, which is why it looked so arbitrary: PEP 695 syntax, a U+FE0F variation selector, and a
%directive.Those three ingredients turn out to be one trigger and two separate bugs.
The trigger
modules.pytries the oldblib2to3parser first and only falls back totsg-pythonwhen it fails. PEP 695 (like t-strings orlazy import) is simply what pushes a file onto thetsg-pythonpath. It is not implicated otherwise.Bug 1: Rust's
Debugformat is not a subset of Python'stsg-pythonprints its result withtree-sitter-graph'sGraph::pretty_print(), which renders string values through Rust'sDebug. The Python reader parses those values withast.literal_eval. The two formats disagree in two ways:\u{...}, which Python does not accept at all. This is much broader than the reported U+FE0F: U+200D zero width joiners, combining accents from NFD text, and soft hyphens all qualify, in string literals, comments and identifiers alike.\0, which Python reads as the start of an octal escape. So"\0"alone happens to work, but NUL followed by an octal digit silently decodes to the wrong character, with no exception raised.Rather than reason about which escapes diverge, I checked.
rust_to_python_escapestranslates both cases, and I verified the result by generating Rust'sDebugrendering of every Unicode scalar value and runningliteral_evalover each, plus a cross product of every distinct escape shape with every printable ASCII neighbour on both sides:SyntaxErrorout of 1,114,344 cases. The NUL bug was found by that audit, not by inspection.
test_every_escape_shape_round_tripswalks the same shape and neighbour table without needing a Rust toolchain, so the property stays guarded.Worth noting: the non-fatal path was a silent correctness bug, not just a warning. The
excepthandler left the value undecoded, so aStrnode'ssended up holding the Rust-escaped source text including its quotes.Bug 2: the warning about the failure was itself fatal
Logger.logapplied%-formatting unconditionally. The warning message embedsrepr(value), i.e. the offending source snippet, so a literal containing%sraisedTypeError: not enough arguments for format string.modules.pyconverted that into aSyntaxErrorand the whole file was dropped.format_messagenow matches stdlibloggingsemantics and only formats when there are arguments.Why this was invisible
StdoutLoggerin the parser test harness countedwarnanderrortowards its error count, buttsg_parsercallswarning, which the harness silently ignored. So the parser tests could not have caught this class of bug.warningnow counts.While fixing that I standardised on
warningas the method name, matchingsemmle.logging.Loggerand the standard library, wherewarnwas deprecated in 3.3 and removed in 3.13.Logger.warnnever existed here, so the two remaininglogger.warncalls inworker.pyandimports.pywould have raisedAttributeError. Both sit insideexcepthandlers, so they would have replaced a warning with a crash, the same shape as bug 2.Notes for review
tsg-pythonoutput line, and not to the reconstructed source thatevaluate_stringevaluates in its secondliteral_evalpass.r"\u{fe0f}"is serialised, is never mistaken for an escape introducer.tree-sitter-graphis third-party and theliteral_evalcontract lives on the Python side. There is now a comment at thepretty_print()call recording the coupling.is_printablefollows Rust's Unicode tables, so a toolchain upgrade could escape more characters. That is fine, since anything new takes the already-handled\u{...}path. The audit ran on rustc 1.91.0.Testing
tests/parser/unicode_escapes_new.pycovers a variation selector next to a%sdirective, a ZWJ sequence, NFD combining marks in both a literal and an identifier, a soft hyphen, a raw string containing a literal\u{fe0f}, an f-string, implicit concatenation and a comment.tests/test_tsg_parser.pyadds fast unit coverage that needs no cargo build. Both original defects were confirmed red before the fix by reverting each in turn.Fixes: #22435