Skip to content

ci: reject raw bidi control characters in first-party files (fixes #628) - #644

Merged
Yaraslaut merged 2 commits into
masterfrom
laneBIDI-628
Sep 21, 2026
Merged

Yaraslaut merged 2 commits into
masterfrom
laneBIDI-628

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Sep 20, 2026

Copy link
Copy Markdown
Member

Closes #628. (#642 is closed separately as triage: invalid — its premise was the runner's stale-checkout measurement, not a defect this PR fixes.)

#610 asked for this lint in its own closing note and deferred it as "a bigger
decision than this issue". #628 filed it. This is it.

What the gate rejects

Twelve codepoints in three classes — exactly the list #610 wrote:

class codepoints why
Arabic letter mark U+061C the one #628 typed by accident, twice
directional marks U+200E, U+200F the ones #610 converted nine assertion lines away from
embeddings / overrides U+202AU+202E reorder rendered text
isolates U+2066U+2069 reorder rendered text

Escaped spellings (\u200E, \u{200E}, ‎) are always accepted; only
the literal codepoint is rejected.

Comments are in scope, deliberately. #610's original sketch said "outside
comments". #628's own reproduction was inside comments, and the trojan-source
shape lives in comments by construction. In a literal a raw control is a
silently wrong assertion; in a comment it is source that stops saying what it
means.

The one claim this branch's safety rests on

The tree contains zero raw bidi controls, so this gate cannot break anyone
today, and every one of its failure paths is exercised by the self-test rather
than by the tree.

Measured on c55ea5b7, over all 1242 tracked files, reading bytes rather than
trusting a regex:

tracked=1242 text=1242 binary=0 raw_bidi_hits=0
detector probe: 12 of 12

The second line is the control on the first: the same detector, pointed at a
string holding all twelve codepoints, finds all twelve. Without it,
raw_bidi_hits=0 is equally consistent with a clean tree and a dead scan.

If that claim is wrong — if some file does carry one — prose-lint goes red on
this PR and the fix is either an escape or an EXEMPT entry. No build, no
toolchain, no timing in the failure mode.

#642: the five raw controls are not in the merged tree

#642's premise does not reproduce. The five raw U+200E/U+200F it reports
in src/qt/forms/tests/tst_i18n.qml do not exist at 513145a1 — the squash
commit that closed #610 and #591 — and do not exist at c55ea5b7:

$ git show 513145a1:src/qt/forms/tests/tst_i18n.qml > /tmp/at630.qml
$ grep -cP '[\x{202A}-\x{202E}\x{2066}-\x{2069}\x{200E}\x{200F}\x{061C}]' /tmp/at630.qml
0

That pattern is live, not dead — pointed at a file holding one raw U+200E it
returns 1:

$ printf 'a\xe2\x80\x8eb\n' > /tmp/probe.txt
$ grep -cP '[\x{202A}-\x{202E}\x{2066}-\x{2069}\x{200E}\x{200F}\x{061C}]' /tmp/probe.txt
1

The excerpt in #642 also shows a different function signature from the one on
master — positional arguments (normalizeLocaleNumber(s, ".", "", "-", sign))
where master has an options object (normalizeLocaleNumber(s, { decimalSeparator: ".", ... })).
So, inferred rather than measured: #642 was taken against an intermediate
revision of #630's branch, and the review round that converted the signature
also escaped the controls. I did not verify that inference against #630's
pre-squash branch history. What I verified is the merged result, which is what
master has.

There is therefore no fix commit for #642, because there is nothing to fix.
Its own closing condition is met by this PR: "Close as resolved when the five
are escaped and #628's lint exists"
— they are (they never landed
unescaped), and it now does.

This contradicts my dispatch brief, which expected a separate #642 commit.
Recorded here rather than shipped around, per AGENTS.md.

The gate caught its own author, on this PR, before the push

Not a hypothetical, and the strongest evidence in this PR.

The first draft of the workflow comment said "typing \u061C through a
JSON-payload tool that decoded the escape"
. The tool payload decoded it. A
single invisible U+061C landed in .github/workflows/drift-guard.yml, went
into the commit, and was invisible in git show:

::error::.github/workflows/drift-guard.yml:217:66: raw U+061C ARABIC LETTER MARK
    217 |       # what it means -- #628 reproduced that on itself, typing `<U+061C>` through

That is #628's reproduction a fourth time, on the PR that closes it, in the
file that adds the gate. Nothing else in the repository would have reported it.
The comment now spells the character in ASCII and says so in place.

Closing the vacuity traps

The tree is already clean, so this gate ships green and would be worth nothing
unless every way of going blind is closed. Three exist, each with its own
control:

  1. The detector drifts away from the declared set. Before opening any file,
    the checker probes itself against each declared codepoint, one at a time, and
    refuses to run if any comes back unflagged. Pinned by two self-test cases: a
    detect() narrowed to U+200E (the shape "only guard what we have actually
    hit"), and a detect() returning nothing. Both fail on a clean tree.
  2. The file set stops matching. Scanning zero files is an error, not a pass.
    Pinned by a self-test case pointing the checker at an empty directory — the
    same handling check_nolint_directives.sh gives the same problem.
  3. The declared set shrinks. The in-process probe cannot see this: it
    iterates the same table. So scripts/test_check_bidi_controls.sh holds its
    own independent list of the twelve, runs the checker over
    tests/lint/bidi_controls/invalid/, and requires every codepoint on its
    list to be named in the output. One fixture directory per character class,
    each rejected on its own and each required to name its own file.

Fourth, specific to this gate: the diagnostic must itself be readable. Every
offending line is echoed with each control replaced by a visible <U+200E>
marker, and the self-test asserts no diagnostic contains a raw control. A gate
against invisible characters that prints them reports the defect by committing
it.

Self-test output, run locally on 5085f268:

ok: the repository passes: bidi-control lint OK: 1244 text file(s) scanned (0 binary skipped), 12 codepoint(s) searched for, 0 raw occurrence(s).
ok: valid fixtures accepted
ok: invalid fixture arabic_letter_mark rejected
ok: invalid fixture directional_marks rejected
ok: invalid fixture embeddings_and_overrides rejected
ok: invalid fixture isolates rejected
ok: all 12 declared codepoints found in the invalid fixtures
ok: no diagnostic contains a raw bidi control
ok: directory with no scannable files rejected
ok: caught: an unexempted file carrying a raw U+200E
ok: accepted: an exemption for the file that carries the control
ok: caught: an exemption for a file with no raw controls
ok: caught: an exemption naming a file that does not exist
ok: caught: the detector narrowed to a subset of the declared codepoints
ok: caught: the detector disabled outright

all cases passed.

And the end-to-end mutation, which is the case that matters: reintroducing
#642's exact defect into tst_i18n.qml (un-escaping line 284) makes the
repo-wide run red, naming file, line, column and codepoint:

::error::src/qt/forms/tests/tst_i18n.qml:284:55: raw U+200E LEFT-TO-RIGHT MARK
    284 |             compare(Qt.locale("az_IR").positiveSign, "<U+200E>+<U+200E>")

Exemptions

EXEMPT maps a repository-relative path to a written reason, and is empty
which is the measured state of the tree, not an aspiration. Both hygiene rules
from check_workflow_option_coverage.py's EXEMPT and
check_workflow_job_banners.py's UNBANNERED apply, and all three cases are
pinned by the self-test against a scratch tree holding a patched copy of the
checker:

  • an entry for a file that does not exist is an error;
  • an entry for a file with no raw controls is an error;
  • and — so the two above are not testing a mechanism that does not work — an
    entry for a file that does carry one clears the tree.

The grain is the file, not the line. Deliberate: a file that genuinely needs a
raw control is a file whose whole subject is that character, and the argument
for it belongs in prose once, rather than sprinkled per line where it would
drift back into being invisible.

Where it runs

drift-guard.yml's prose-lint job, where #635 put the NOLINT-directive scan
for the same reason: a fast, dependency-free text scan that compiles nothing,
guarding the neighbouring class of defect. No new job, no new runner, and no
change to any existing job's name:, so no required-check names move.

Review reasoning, done inline

  • Why not a three-line grep -P in a run: block? It could not have a
    self-test, could not carry reasoned exemptions, and would print the raw line —
    reproducing the invisible character in the failure message. The three vacuity
    controls above are the whole value here; a bare grep has none of them.
  • Why scan every tracked file rather than *.cpp/*.hpp/*.qml? No lint keeps raw bidi controls out of first-party sources, and the authoring path that introduces them was hit twice while fixing #610 #628's
    reproduction hit a header and a test, and the trojan-source class applies to
    YAML, JSON, shell and Markdown equally. The tree is clean everywhere today, so
    breadth costs nothing now; narrowing it later is a change someone would have
    to argue for.
  • False-positive risk is real and unmeasured over time. A future doc
    legitimately quoting RTL text would trip this. That is what EXEMPT is for,
    and the hygiene rules stop it accumulating. No lint keeps raw bidi controls out of first-party sources, and the authoring path that introduces them was hit twice while fixing #610 #628 flagged this as its own
    unverified point and it remains one.
  • Binary files are skipped by git's own rule (a NUL byte), counted and
    reported separately. Zero today, across 1242 files.
  • tests/lint/bidi_controls/ is pruned from the repo-wide run, since it
    holds raw controls on purpose — the same treatment
    check_nolint_directives.sh gives its own inert-directive fixtures. Naming a
    path inside it still scans it, which is how the self-test reaches it.
  • The fixtures are clang-format-clean (clang-format 22.1.8 --dry-run -Werror, exit 0), because ci.yml's clang-format job checks every tracked
    .hpp/.cpp, tests/lint/ included.

What I did not verify

  • CI. Reported immediately on push, as dispatched — not polled, not waited
    on. Everything above is a local measurement, on Linux, with Python 3 and bash
    only.
  • Windows and macOS runners. The checker is stdlib Python reading bytes and
    the self-test is bash; neither runs outside ubuntu-24.04 in this workflow.
  • render/forms: tst_i18n.qml still embeds five raw bidi controls, in the half of the file #630 added #642's pre-squash branch history, as stated above — only the merged result.
  • The false-positive rate over time, as stated above.

Filed, not folded

Gates run locally before pushing

check_workflow_job_banners.py (24 banners, ok) · test_check_workflow_job_banners.sh
(all cases passed) · check_nolint_directives.sh (164 directives, ok) ·
check_mutation_survivors.py (15 citations resolve) · check_spec_citations.sh ·
check_catch_test_names.sh (2986 names) · check_deprecated_markers.sh ·
check_test_type_names.sh · check_journal_stamps.sh ·
check_tidy_suppression_scope.sh · test_check_bidi_controls.sh ·
check_bidi_controls.py · YAML parse of drift-guard.yml. All green.


Follow-up commit: the fixtures under clang-tidy-diff (red → green)

The first push was red on clang-tidy-diff (job 106163759717, run
35542939709) with 14 findings, every one inside the new fixtures:

11  clang-diagnostic-unused-const-variable
 3  misc-misleading-bidirectional

Cause. Every line of a new file is a changed line, and that job applies no
path exclusion at all: there is no -exclude in its clang-tidy-diff.py
invocation (deliberately — see the long comment on that step) and there is no
.clang-tidy anywhere under tests/lint/. The .hpp and .qml fixtures
produced nothing; only the three .cpp ones did.

Reproduced locally before and after, clang-tidy 22.1.8 (CI's pinned major),
with the warning set cmake/compiler_options.cmake builds for Clang —
-Weverything minus every suppression in its Clang branch. That flag set
reproduces CI's list exactly: same 14 findings, same files, same lines, same
columns, and it also reproduces the absence of pre-c++23-compat on
escaped_literals.cpp:16, which a plain -Weverything does raise. Before:

tests/lint/bidi_controls/invalid/arabic_letter_mark/alm_in_a_comment.cpp:9:23: error: unused variable 'kSign' [clang-diagnostic-unused-const-variable,-warnings-as-errors]
tests/lint/bidi_controls/invalid/embeddings_and_overrides/reordering.cpp:8:23: error: unused variable 'kEmbed' [clang-diagnostic-unused-const-variable,-warnings-as-errors]
tests/lint/bidi_controls/invalid/embeddings_and_overrides/reordering.cpp:9:23: error: unused variable 'kEmbedRtl' [clang-diagnostic-unused-const-variable,-warnings-as-errors]
tests/lint/bidi_controls/invalid/embeddings_and_overrides/reordering.cpp:10:23: error: unused variable 'kOverride' [clang-diagnostic-unused-const-variable,-warnings-as-errors]
tests/lint/bidi_controls/invalid/embeddings_and_overrides/reordering.cpp:10:35: error: string literal contains misleading bidirectional Unicode characters [misc-misleading-bidirectional,-warnings-as-errors]
tests/lint/bidi_controls/valid/escaped_literals.cpp:8:23: error: unused variable 'kArabicLetterMark' [clang-diagnostic-unused-const-variable,-warnings-as-errors]
tests/lint/bidi_controls/valid/escaped_literals.cpp:9:23: error: unused variable 'kLeftToRightMark' [clang-diagnostic-unused-const-variable,-warnings-as-errors]
tests/lint/bidi_controls/valid/escaped_literals.cpp:10:23: error: unused variable 'kRightToLeftMark' [clang-diagnostic-unused-const-variable,-warnings-as-errors]
tests/lint/bidi_controls/valid/escaped_literals.cpp:11:23: error: unused variable 'kRightToLeftOverride' [clang-diagnostic-unused-const-variable,-warnings-as-errors]
tests/lint/bidi_controls/valid/escaped_literals.cpp:11:46: error: string literal contains misleading bidirectional Unicode characters [misc-misleading-bidirectional,-warnings-as-errors]
tests/lint/bidi_controls/valid/escaped_literals.cpp:12:23: error: unused variable 'kFirstStrongIsolate' [clang-diagnostic-unused-const-variable,-warnings-as-errors]
tests/lint/bidi_controls/valid/escaped_literals.cpp:12:45: error: string literal contains misleading bidirectional Unicode characters [misc-misleading-bidirectional,-warnings-as-errors]
tests/lint/bidi_controls/valid/escaped_literals.cpp:13:23: error: unused variable 'kPopDirectionalIsolate' [clang-diagnostic-unused-const-variable,-warnings-as-errors]
tests/lint/bidi_controls/valid/escaped_literals.cpp:16:23: error: unused variable 'kBraced' [clang-diagnostic-unused-const-variable,-warnings-as-errors]

After: empty output, exit 0.

The fix, and why it beat the alternatives

Followed the existing convention. The six fixture directories already under
tests/lint/ (nolint_directives, automoc_includes, catch_test_names,
deprecated_markers, journal_stamps, test_type_names — 30 .cpp and 12
.hpp between them) carry no .clang-tidy and no exclusion. They are green
because they are written so clang-tidy has nothing to say about them. These
fixtures now are too.

Rejected: a tests/lint/.clang-tidy. It would be a second convention where
one already works, and it would put a new suppression file into exactly the
area #632 landed check_tidy_suppression_scope.sh to audit. Suppressing
misc-misleading-bidirectional for a whole directory would also have thrown
away the coverage described next.

Rejected: renaming the three .cpp fixtures to .hpp. That would dodge
unused-const-variable only because clang does not raise it for a header — a
subtle behaviour to depend on — and it would not have touched a single one of
the three misc-misleading-bidirectional findings, which fire on .hpp just
the same.

Rejected: deleting the raw controls. That destroys the fixture.
test_check_bidi_controls.sh requires each invalid/ directory to be rejected
on its own.

misc-misleading-bidirectional and this gate are complementary, not redundant

Worth stating plainly, because it is nearly this PR's own subject.
misc-misleading-bidirectional is an existing clang-tidy check that fires on
bidi controls in string literals — and it fires on escaped_literals.cpp:11
and :12, whose controls are written as escapes and whose bytes on disk
are pure ASCII. That is not a bug in either tool:

  • clang-tidy judges the string's content. An escape produces the same bytes
    in the compiled string, so "‮" is as misleading to it as a raw RLO.
  • this gate judges the source bytes, which is the property a reviewer reads
    and a diff renders.

Neither subsumes the other, and the difference is directional:

misc-misleading-bidirectional check_bidi_controls.py
input translation units in compile_commands.json all 1244 tracked text files
languages C/C++ only any text file — .qml, .md, .yml, .json, .sh
sees escapes yes (post-expansion) no
sees raw bytes in a comment no (string literals only) yes
tst_i18n.qml, this workflow's own YAML never analysed scanned

The two cases that motivated #628 are both outside clang-tidy's reach: #628's
own reproduction was a raw U+061C in a comment, and #610's nine lines were
in src/qt/forms/tests/tst_i18n.qml. The fourth reproduction this PR caught
was in .github/workflows/drift-guard.yml. clang-tidy would have reported none
of the three. Conversely this gate reports neither of the two escaped literals
clang-tidy flags. Both gates stay.

Verification of the follow-up

Mutation, not assertion. Each half of the fix was reverted on its own, in
place in the worktree
so clang-tidy resolves the identical .clang-tidy chain,
and the diagnostics came back:

=== B. 'inline ' removed from escaped_literals.cpp ===
tests/lint/bidi_controls/valid/escaped_literals.cpp:17:23: error: unused variable 'kArabicLetterMark' [...]
  ... 7 findings ...
=== C. the three NOLINTNEXTLINE directives removed ===
tests/lint/bidi_controls/invalid/embeddings_and_overrides/reordering.cpp:16:42: error: string literal contains misleading bidirectional Unicode characters [...]
tests/lint/bidi_controls/valid/escaped_literals.cpp:23:53: error: string literal contains misleading bidirectional Unicode characters [...]
tests/lint/bidi_controls/valid/escaped_literals.cpp:24:52: error: string literal contains misleading bidirectional Unicode characters [...]
=== D. restored ===
(nothing)

So both halves are load-bearing and the probe measures something rather than
being satisfied by a dead command.

The fixtures still fail when they should. test_check_bidi_controls.sh,
all 15 cases, on the rebased tree:

ok: the repository passes: bidi-control lint OK: 1244 text file(s) scanned (0 binary skipped), 12 codepoint(s) searched for, 0 raw occurrence(s).
ok: valid fixtures accepted
ok: invalid fixture arabic_letter_mark rejected
ok: invalid fixture directional_marks rejected
ok: invalid fixture embeddings_and_overrides rejected
ok: invalid fixture isolates rejected
ok: all 12 declared codepoints found in the invalid fixtures
ok: no diagnostic contains a raw bidi control
ok: directory with no scannable files rejected
ok: caught: an unexempted file carrying a raw U+200E
ok: accepted: an exemption for the file that carries the control
ok: caught: an exemption for a file with no raw controls
ok: caught: an exemption naming a file that does not exist
ok: caught: the detector narrowed to a subset of the declared codepoints
ok: caught: the detector disabled outright

all cases passed.

Also green locally after the rebase: check_bidi_controls.py ·
check_nolint_directives.sh (164 directives repo-wide; and 3 with
tests/lint/bidi_controls named explicitly, since the default target prunes
tests/lint) · check_tidy_suppression_scope.sh (13 subtracted checks, both
behavioural probes) · check_workflow_job_banners.py (24 banners) ·
check_spec_citations.sh · check_ci_clang_pin.sh · check_catch_test_names.sh
(2986 names) · clang-format 22.1.8 --dry-run -Werror over all five C++
fixtures.

Filed, not folded (follow-up)

Rebase

Rebased onto f7c231df (#647), which changed this very job — it now builds
two *_autogen targets so the two AUTOMOC self-including sources parse.
#646's 84 newly-reachable findings in test_qml_surface.cpp and
tst_main.cpp are not this branch's and are not addressed here; if they
appear on this run, that is why. The tracked-text-file count moves 1242 → 1244
in three comments as a consequence of the new base; the one stamped
at c55ea5b7 in check_bidi_controls.py names its revision and is left alone.

What the follow-up did not verify

  • CI. Reported on push as dispatched; not polled. The check counts seen at
    hand-off are incomplete.
  • The interpolated compile command. The local probe ran with no compile
    database, so clang-tidy used the flag set above verbatim, while CI
    interpolates a command from a neighbouring compile_commands.json entry.
    The one claim the fix rests on: none of the five C++ fixtures #include
    anything, so the extra -I/-D flags interpolation adds cannot change the
    finding set. That is an argument from the fixtures' contents, not a
    measurement against the real database — a local clang-tidy-diff.py run
    needs the full Qt + ladder configure this job does.
  • Whether the previous push's "all green" gate list was complete. It was
    not: clang-tidy-diff is not runnable without that configure and was not in
    it. Noted rather than glossed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut Yaraslaut changed the title ci: reject raw bidi control characters in first-party files (fixes #628, fixes #642) ci: reject raw bidi control characters in first-party files (fixes #628) Sep 20, 2026
@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner verification

The lane is right and I was wrong. #642 was my error: my checkout was still at a8511aa6, four commits behind, so I grepped the pre-#630 file and reported it as post-merge master. Counted by codepoint over the stored blobs — a8511aa6: 9 lines; 513145a1 (#630): 0; c55ea5b7: 0. #630 escaped all nine, and the true count at the stale revision was 9, not the 5 I published. #642 is closed invalid with the full measurement, and the Closes #642 line here is removed — this PR does not fix it, because there was nothing to fix.

Two things from that worth carrying into this gate, both of which the lane had already reasoned its way to:

Verified the load-bearing claim — that the tree is clean, so every failure path is exercised by the self-test rather than by the tree — by mutation rather than by reading:

clean tree:   bidi-control lint OK: 1244 text file(s) scanned (0 binary skipped),
                                    12 codepoint(s) searched for, 0 raw occurrence(s).   exit=0

one raw U+200E injected into tst_i18n.qml:
  ::error::src/qt/forms/tests/tst_i18n.qml:226:53: raw U+200E LEFT-TO-RIGHT MARK
      226 | compare(signForm.normalizeLocaleNumber("<U+200E>−5", { … }), "-5")  // fa_IR
                                                                                      exit=1

U+200E entry deleted from the checker's own table:
  test_check_bidi_controls.sh -> 3 case(s) failed

1244 files against 12 codepoints is a real denominator, not a green tick over an empty set; the injected control is caught with file, line and column; the diagnostic renders it as <U+200E> rather than echoing a raw control into the log; and the self-test's independent list catches a codepoint being dropped from the checker — which the in-process detector probe structurally cannot see. That third one is the control I would have expected to be missing, and it is the one that matters most for a list-shaped gate.

Not merged this sweep: CI incomplete at hand-off. Next sweep counts the checks.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner: clang-tidy-diff is red — partial diagnosis, log not yet available

gh pr checks 644 reports 23 pass, 1 fail, 14 pending. The failing leg is clang-tidy-diff; its log cannot be read yet (run 35542939709 is still in progress; logs will be available when it is complete), so this is a hypothesis with the evidence I have, not a diagnosis.

Hypothesis: the five new C++ fixture files are being analysed as changed lines.

tests/lint/bidi_controls/invalid/arabic_letter_mark/alm_in_a_comment.cpp
tests/lint/bidi_controls/invalid/embeddings_and_overrides/reordering.cpp
tests/lint/bidi_controls/invalid/isolates/isolates.hpp
tests/lint/bidi_controls/valid/escaped_literals.cpp
tests/lint/bidi_controls/valid/prose_naming_the_codepoints.hpp

Every line of a new file is a changed line, and the clang-tidy-diff step applies no path exclusion — I read the whole invocation (-path build/clang-debug, -p1, -extra-arg=-std=c++23, -extra-arg=-Wno-missing-include-dirs, -quiet) and there is no -exclude and no .clang-tidy anywhere under tests/lint/.

What points the other way, and is worth checking before assuming: six fixture directories already live under tests/lint/ (nolint_directives, automoc_includes, catch_test_names, …), and the PRs that added them went green. So fixtures can pass this leg, and the question is what is different about these five — most likely a finding the existing fixtures happen not to produce (a free function with external linkage, a header without a guard), or a clang-diagnostic-error from a file with no compile command.

Deliberately not guessing at a fix: the log names the finding and the line, and rewriting fixtures blind risks weakening the very cases the self-test depends on. This is queued for the next sweep, which will read the completed log first.

Nothing here bears on the gate itself, which I verified separately by mutation in the comment above — this is about the fixtures being seen by a different leg.

Coincidence worth noting: this is #624's class of defect — a source clang-tidy tries to analyse that the build never intended for it — reported by the leg #647 is currently changing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@codecov

codecov Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Yaraslaut and others added 2 commits September 21, 2026 02:45
A bidirectional control renders as nothing and occupies no column, so a diff
containing one looks exactly like a diff that does not. In a string literal
that is a silently wrong assertion (#610: nine lines of tst_i18n.qml whose
expected values held raw U+200E / U+200F / U+061C). In a comment it is source
that stops saying what it means (#628 reproduced that on itself, twice, in two
files, typing an escape through a JSON-payload tool that decoded it before the
file was written). Between them is the trojan-source shape, where an override
or isolate makes a line render as a different program from the one that
compiles.

#610 asked for this lint and deferred it as "a bigger decision". Review is not
a control for this class -- the PR that closed #610 was reviewed by people who
could not have seen a raw control had one survived -- so a gate is the only
thing that can hold the line.

scripts/check_bidi_controls.py rejects twelve codepoints in three classes, the
list #610 wrote: U+061C, U+200E/U+200F, U+202A-U+202E and U+2066-U+2069.
Comments are in scope, not excluded: #628's own reproduction was inside
comments, and trojan source lives in comments by construction.

The tree is already clean -- 0 raw controls across 1242 tracked files -- so the
gate ships green and would be worth nothing unless every way of going blind is
closed:

  - The detector is probed against each declared codepoint, one at a time,
    before any file is opened. A detect() narrowed to the two marks this
    repository has actually hit fails on a clean tree.
  - Scanning zero files is an error, not a pass.
  - scripts/test_check_bidi_controls.sh holds its own independent list of the
    twelve and requires each to be found in tests/lint/bidi_controls/. That is
    what notices a codepoint being deleted from the checker's table, which the
    checker's in-process probe cannot see, because both sides read it.
  - The diagnostic replaces each control with a visible <U+200E> marker, and
    the self-test asserts no diagnostic contains a raw control: a gate against
    invisible characters that prints them reports the defect by committing it.

The gate caught its own author before this branch was pushed. The first draft
of the workflow comment below said the six characters of a U+061C escape; the
tool payload decoded them, a single invisible U+061C landed in
drift-guard.yml, and nothing but this scan would have reported it. That is
#628's reproduction a fourth time, on the PR that closes it.

EXEMPT maps a path to a written reason and is empty, which is the measured
state of the tree rather than an aspiration. Both hygiene rules from
check_workflow_option_coverage.py's EXEMPT and check_workflow_job_banners.py's
UNBANNERED apply and are pinned by the self-test: an entry for a file that does
not exist is an error, and an entry for a file with no raw controls is an
error.

Rides drift-guard.yml's prose-lint job, where #635 put the NOLINT-directive
scan for the same reason: a fast, dependency-free text scan that compiles
nothing, guarding the neighbouring class of defect -- a file that looks correct
to every reader while saying something else.

Also closes #642, whose five raw U+200E/U+200F do not exist in the merged tree:
`git ls-files` lists 1242 files at c55ea5b and none of them contains any of
the twelve codepoints. See the PR body for the measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Every line of a new file is a changed line, and the clang-tidy job applies
no path exclusion -- no -exclude in its clang-tidy-diff.py invocation, and
no .clang-tidy anywhere under tests/lint/. The three .cpp fixtures added
for morph#628 therefore produced 14 findings, all inside themselves:

    11  clang-diagnostic-unused-const-variable
     3  misc-misleading-bidirectional

Reproduced locally on clang-tidy 22.1.8 (CI's pinned major) with the
warning set cmake/compiler_options.cmake builds for Clang -- -Weverything
minus the suppressions in its Clang branch -- and the result matches CI's
deduplicated list file for file, line for line, column for column.

Fixed the way the six fixture directories already under tests/lint/ avoid
this: the fixtures are written so clang-tidy has nothing to say about
them. No new .clang-tidy, no path exclusion, no byte of a raw control
removed from an invalid/ fixture.

  - `inline` on each namespace-scope constant. A `constexpr` there has
    internal linkage, so an unused one is a diagnostic; an `inline` one
    has external linkage and is not. This is house style in any case --
    include/morph/ spells 36 of its constants that way -- and it changes
    no byte of any string literal.

  - Three NOLINTNEXTLINE(misc-misleading-bidirectional), reason above the
    directive, never wrapped onto a second line (morph#631/#635).

misc-misleading-bidirectional is not the same gate as this PR's, and the
suppressions are where the two are recorded as disagreeing. It reads the
string's *content*, so it fires on escaped_literals.cpp's `"‮"` and
`"⁨"` although those are written as escapes and the file on disk is
pure ASCII; this gate reads the *source bytes*, so it does not. Neither
subsumes the other: clang-tidy sees only C++ translation units the
configure put in the compile database, so it would never have looked at
tst_i18n.qml, a Markdown file or a workflow YAML, while this scan covers
all 1244 tracked text files and cannot see a control that exists only
after escape expansion. Suppressing the check globally, or for
tests/lint/ as a directory, would have thrown away the half of the
coverage this PR does not provide.

Verified, not asserted. Before: 14 findings. After: 0. Mutated back one
change at a time, in place so the .clang-tidy chain resolves identically:
dropping `inline` from escaped_literals.cpp restores 7
unused-const-variable findings, and deleting the three directives
restores all 3 misc-misleading-bidirectional findings -- so both halves
are load-bearing and the probe still measures something. The fixtures
still fail when they should: scripts/test_check_bidi_controls.sh passes
all 15 cases, including "invalid fixture <name> rejected" for each of the
four invalid directories on its own. Also green locally:
check_bidi_controls.py, check_nolint_directives.sh (and again with
tests/lint/bidi_controls named explicitly, since the default target prunes
tests/lint), check_tidy_suppression_scope.sh, check_workflow_job_banners.py,
check_spec_citations.sh, check_ci_clang_pin.sh, check_catch_test_names.sh,
and clang-format --dry-run -Werror over the fixtures (the clang-format job
is whole-tree).

The tracked-text-file count in drift-guard.yml and in the self-test's
header moves 1242 -> 1244: the same measurement, restated on this branch's
new base after morph#647, morph#641 and morph#630 landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
@Yaraslaut

Copy link
Copy Markdown
Member Author

Runner verification of the fix

The fix does not weaken the fixtures — checked, not assumed. That was the one risk I flagged when dispatching, so I counted the raw controls in every fixture on both sides of the fix commit:

invalid/arabic_letter_mark/alm_in_a_comment.cpp   raw_controls before=2 after=2  SAME
invalid/directional_marks/marks_in_literals.qml   raw_controls before=3 after=3  SAME
invalid/embeddings_and_overrides/reordering.cpp   raw_controls before=6 after=6  SAME
invalid/isolates/isolates.hpp                     raw_controls before=6 after=6  SAME
valid/escaped_literals.cpp                        raw_controls before=0 after=0  SAME
valid/no_controls_at_all.qml                      raw_controls before=0 after=0  SAME
valid/prose_naming_the_codepoints.hpp             raw_controls before=0 after=0  SAME

Not one byte of fixture payload moved. The self-test agrees on the rebased branch — 15 cases, all pass, each invalid/ directory rejected on its own — and the gate reports 1244 text file(s) scanned, 12 codepoint(s) searched for, 0 raw occurrence(s).

Reproduced the clang-tidy mutation independently:

as pushed                        (no unused-const-variable, no misc-misleading-bidirectional)
mutation A: strip 'inline'        7 unused-const-variable
mutation B: delete the 3 NOLINTs  2 misc-misleading-bidirectional   (the 3rd is in reordering.cpp)

Both halves are load-bearing, so the fix addresses the diagnostics rather than hiding them.

Two of my own probes were vacuous before this one worked, which is worth recording since this PR is about controls that measure nothing. First I ran clang-tidy with bare -std=c++23, which enables none of the warnings CI builds with — every variant came back clean, including the mutated ones, and had I stopped there I would have "confirmed" the fix with a probe that could not see the findings at all. Then my match pattern \[[a-z0-9-]+\] missed every finding, because they end [check-name,-warnings-as-errors] — comma and leading dash outside the class. The lane's own measurement used the flag set from cmake/compiler_options.cmake's Clang branch and reproduced CI's list exactly, which is why its numbers were right and my first two attempts were not.

The load-bearing claim checks out: all five C++ fixtures contain zero #include lines, so interpolated flags from a neighbouring compile-database entry have nothing to pull in. That is an argument from content, as the lane said, not a measurement against the real database.

Convention followed, not invented — the six pre-existing tests/lint/ fixture directories carry no .clang-tidy and no exclusion; they are green because the fixtures are written so clang-tidy has nothing to say. Rejecting a tests/lint/.clang-tidy was right on its own terms and because it would drop a new suppression file into the area #632's check_tidy_suppression_scope.sh audits.

#650 is the durable finding here — that the six existing fixture directories are green by accident of content, with the convention written down nowhere, so the eighth directory learns it the way the seventh just did. That is the reusable lesson from this red leg, and filing it beats fixing three files and moving on.

Not merged: CI incomplete at hand-off. Next sweep counts the checks.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

@Yaraslaut
Yaraslaut merged commit 0067b5b into master Sep 21, 2026
50 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant