Repository navigation
Count the offsets that decide a result in code points - #37
Merged
Merged
Conversation
The scanner measures how far back a negation cue or a framing phrase may sit, how far a hit must read the same in the raw input, where the next search resumes, which character sits beside a keyword, the co-occurrence window, the match text and the context around a keyword in code points. An index here counts UTF-16 units, and a character outside the Basic Multilingual Plane takes two, so a window was half as wide over such text and a step could land inside a pair. A text the scanner blocks could be allowed, a text it allows could be blocked, and a reported match could end on half of a pair. Each of those distances now steps by code point. A rule searched around a rare token reads the whole document when it holds a surrogate pair, as it already did when folding changed the length. The cut of an over-long folded view no longer splits a pair.
The offsets entry said the windows are counted as Python counts them and that a rule reads the whole document when it holds a surrogate pair. Review of the first head measured two limits over isolated surrogates: the whole-document search runs for any surrogate code unit, so the scanner's anchor window is not applied there, and removing an invisible character between two isolated halves makes this runtime read them as a pair where Python keeps two code points. Narrow the README paragraph and the matching LIMITATIONS string to say so. Wording only; no code path changes.
This was referenced Oct 14, 2026
azrollin
added a commit
that referenced
this pull request
Oct 14, 2026
When the negation walk pairs a look-alike letter with the view, it asks whether the pipeline decodes more than that reading shows, because then the text after it was made from raw characters the reading does not use and nothing past it can be vouched for. The scanner answers by decoding: the reading must be a fixed point of the escape passes, and the reading with the raw text after it must decode to the reading followed by what that text decodes to alone. This runtime answered yes for any ampersand, percent sign, backslash or tag character in the reading or the next 64 characters, so a negated command followed by one such character lost its negation and was blocked where the scanner allows it. `layered` in src/negation.js now ports the scanner's check: it folds the text, runs the percent and hex escape passes up to three times, compares as above, and keeps the scanner's rule for an unfinished escape cut off by the 64 character window. Two places stay cautious on purpose and only answer yes where the scanner answers no: any entity-shaped reference, because the scanner knows every HTML entity name and this runtime a subset; and a literal lone surrogate in the reading next to a percent escape, because the shared URL decoder re-encodes it as U+FFFD where Python keeps it. Both are listed under what remains different. Against the scanner on 200,000 generated pairs there is no case where the scanner says yes and this says no; the six rows the code-point offsets work (#37) left open now match. The rule set is untouched.
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.
Counts the windows, steps and cuts that decide a result in code points, the way the scanner counts them for ordinary text. Two remaining limits over surrogate input are listed under What remains different. Follows the list in #35 (review item 4). The rule set is untouched.
Cause
The scanner measures how far back a negation cue or a framing phrase may sit, how far a hit must read the same in the raw input, where the next search resumes, which character sits beside a keyword, the co-occurrence window, the 50 character match text and the context around a keyword in code points. An index in JavaScript counts UTF-16 units, and a character outside the Basic Multilingual Plane (an emoji, a mathematical letter, a regional indicator, a rare CJK character) takes two. Over such text a window was half as wide and a step could land inside a pair. Both directions followed: a text the scanner blocks was allowed here, a text it allows was blocked, and a reported match could end on half of a pair (a lone surrogate in
matched_text).Change
src/negation.js:cpBackandcpFwdstep by code point. The negation and framing windows, the raw-read span, the layered-decoding probe (64 code points), the neighbour step insamePlaceand the cue start use them.src/engine.js: the resume position after a match, the character beside a keyword, the co-occurrence window and stride, the 50 character match text and the keyword context (excerpt) count code points. A rule searched around a rare token reads the whole document when it holds any surrogate code unit, paired or isolated (the span is a number of code points and its windows are ranges of units), as it already did when folding changed the length. The cut of an over-long folded view no longer splits a pair.src/preprocessor.jsand the README: the matching entry is rewritten to what remains. The first commit is the README wording for the base64 padding entry that the review of Decode a base64 run only where the scanner decodes it #36 asked for.Test, red on main and green on this head
tests/code_point_offsets.test.mjswithtests/fixtures/scan_corpus_code_point_offsets.json(76 rows: 60 decision rows from the scanner's own engine at 0.6.8, in families: negation cue then a wide character, a cue glued to its payload, a negated payload with a wide character, framing then a wide character, plain controls; and 16 separate rows that also check the reported match text) plus four generated rows for the anchored window.Differential against the scanner
12,870 texts per seed: the scanner-recorded corpus and 3,000 generated rows. A generated row takes a source text of at most 3,000 code points and inserts a run of wide characters (1 to 121 long) at one of four places: the front, the middle, one third in, or the end. Each source text gets three such draws, made at random with replacement. Only new differences count: the row without the run already matched the scanner and the row with it does not.
matched_texton the seed 11 set, compared to the scanner: 1,814 rows differ on main, 789 of them with a lone surrogate; on this head 5 differ (a different rule id in the pair, not the cut) and none carries a lone surrogate.Replays unchanged on this head:
scan_corpus_068.json1 differing row, the generated cue x payload set 16, the negation fuzz set 16, the unicode length set 0.What remains different
&,%,\or a tag character within 64 characters after it, and the Worker's layered-decoding check answers "yes" for any such start where the scanner decodes and compares. The direction seen is stricter (blockwhere the scanner givesallow_redacted). Tracked separately.This PR makes no claim that the hosted demo matches the scanner beyond the rows and checks listed here.
Gates on this head
Private checkout of the scanner at 0a3b4686. All exit 0 on head 4870dc0 (r1): disclosure gate; policy parity (14 cases, 0 mismatches); engine parity (137 cases, 0 verdict, 0 finding-set deltas); channel parity (2,488 cases, 0, 0); wide parity (1,555 pairs, 0, 0, no known entries); counterexample parity (0 deltas, 0 slower than the bound);
parity_test.py(890 regexes, 0 misses; 13,024 benign checks, 0 disagreements; seed 3720106481, replay withPARITY_SEED); v-flag disclosure; metadata contract.node --test tests/*.test.mjs: 172 passed, 0 failed (164 on main, plus the 8 new).python3 -m pyteston the dotless-i tests: 62 passed.r2 head a041cde: wording only. It narrows the README paragraph and the matching
LIMITATIONSstring insrc/preprocessor.js(one string literal) to name the two surrogate limits; no other file differs from 4870dc0. On r2 the disclosure gate, the v-flag disclosure gate and the metadata contract exit 0 andnode --test tests/*.test.mjsis 172 passed, 0 failed. The parity gates above were not re-run, since no code path changed.