Skip to content

bug(ci): the print guard scans whole changed files, so a multi-line call's noqa is invisible and the file becomes uneditable #14051

Description

@mrveiss

Found while landing #13149 Slice A (PR #14046). Pre-existing.

Problem

The print()/console.* guard scans whole changed files and matches line by line. A suppression comment on the closing-paren line of a multi-line call is therefore invisible to it:

print(
    f"Auto-repairing {len(fixable)} fixable issue(s)..."
)  # noqa: print  # canonical: ignore py-print-smoke

The guard sees print( on the first line, finds no suppression there, and reports a violation.

Consequence

autobot-backend/cli/doctor.py had three such calls. PR #14046 added zero prints — it changed a function signature elsewhere in the file — and still failed code-quality with:

VIOLATION autobot-backend/cli/doctor.py:227
VIOLATION autobot-backend/cli/doctor.py:238
VIOLATION autobot-backend/cli/doctor.py:246
COMMIT BLOCKED: 3 print()/console.* violation(s) found

So the file was effectively uneditable: any PR touching it failed CI on pre-existing, already-suppressed code it did not write. That is the same class as #13950 (the hardcoded-value guard blaming a PR for a file's backlog), which was fixed by scoping that guard to added lines — this guard did not get the same treatment.

Two separable fixes

  1. Understand multi-line suppressions. Look for the noqa anywhere in the statement, not only on the matched line. A minimal version: when the matched line ends with an unclosed (, scan forward to the closing paren for the comment.
  2. Scope to changed lines, like ci(code-quality): the hardcoded-value guard scans changed FILES, not changed LINES — three unrelated PRs went red in one session #13950 did. Even with (1), a pre-existing unsuppressed print in an untouched region will still blame whoever edits the file next.

(2) is the more valuable of the two — it is the same fix already proven for the sibling guard, and it removes the whole class rather than one shape of it.

Workaround applied meanwhile

PR #14046 moved the three suppressions onto the print( line. Note that collapsing such calls to a single line does not survive black (over 120 chars once the comment is appended), so the opening-line form is the black-stable placement. Worth documenting wherever the guard is described, since the obvious fix is the one that silently reverts.

Acceptance criteria

  • A multi-line call whose suppression is on the closing-paren line is not reported
  • A PR that touches a file without adding a print is not blamed for pre-existing prints in it
  • Test: both shapes (suppression on the opening line, on the closing line) are recognised
  • The black-stability caveat is documented so the suppression placement is not "fixed" back into a form black undoes

Related

Activity

  1. mrveiss commented on Aug 11, 2026

    @mrveiss
    OwnerAuthor

    Follow-up filed as #14115 — the hook spawns a subprocess per line, so a whole-repo scan does not finish. Out of scope for this issue's fix (PR #14112), which only needed the changed-file path.

  2. mrveiss commented on Aug 12, 2026

    @mrveiss
    OwnerAuthor

    Delivered by PR #14112, merged as 2437c375f.

    $ git show origin/Dev_new_gui:autobot-infrastructure/shared/scripts/hooks/lib/strip-significant.awk
      -> present
    

    Both halves of the issue

    The multi-line noqa is recognised on the closing-paren line as well as the opening one, and the CI step now runs through check-pre-commit-hook-pr.sh --changed-lines-only, reusing #13950's mechanism rather than a second implementation — so a PR is no longer blamed for a pre-existing violation in a file it merely touched.

    Three review rounds, and what each one caught

    Round 1 found the first fix weakened the guard in four ways. The forward scan seeded its paren balance from a stripped line but then read raw lines, so anywhere raw parens diverged from real syntax the scan ran past the end of the call and accepted a noqa belonging to a different statement. An unrelated // noqa: console up to 19 lines below silently exempted a live call. It also created a new bypass: deleting a closing-line noqa turned a suppressed call live while the reported line was untouched, so CI passed.

    Round 2 replaced the sed strip with a character tokenizer — and turned the guard off for .vue templates, because " delimits an attribute in markup, not a string. Six live sites in NpuWorkerPairConfirmDialog.stories.ts that base flagged went unreported. The finding-4 bypass also returned by a different route: when the scan exits with the balance still open, the close line was never advanced, so a single-line key was emitted and the wrapper's range check had nothing to widen.

    Round 3 fixed both and added a second, independent layer: a suppression is accepted only on the line where the balance actually reaches zero. That closes the whole class rather than the instances — the Python triple-quote and TS !/++/---before-division cases stop mattering without the tokenizer having to be perfect for them.

    Why the layering is the important part

    This is a hand-written lexer for three languages, so it will mis-lex something eventually. Verified the failure direction myself, since a="b" is valid compact TypeScript as well as Vue markup:

    c1.ts   const a="text with ( an open paren";      -> VIOLATION (reported, not suppressed
            console.log(a);                              by the noqa two lines below)
    c2.vue  <button @click="console.log(x)">Go</button> -> VIOLATION
    

    When the attribute heuristic misfires on compact TS, the string body stays in the balance, the balance never returns to zero on the noqa line, and the tightening refuses the suppression. A mis-lex produces a spurious report, never a missed violation.

    Evidence

    Miss set 30 → 24 (the legitimate closing-line-noqa sites), measured over the ~475 files that can contain a print/console call at all. The single new hit is the mapfile improvement — a final print("x") with no trailing newline that the old read loop silently skipped. 49 tests pass; mutation-tested against each prior round's hook.

    Also fixed: the hook failed open if lib/strip-significant.awk was missing or unparseable — every stripped line came back empty, nothing matched, exit 0. It now self-tests at startup and exits 2. _common.sh is sourced so its absence kills the script under set -u; this new dependency was the only silent one.

    Behaviour change, deliberate

    Editing any line inside a pre-existing unsuppressed multi-line call now fails the build, because the violation reports as a range and touching any line in that range takes responsibility for the whole call. Defensible — the PR did touch it — but stricter than #13950's stated intent, so it is recorded rather than left to be discovered.

    Related

    #14115 — the hook spawns a subprocess per line, so a whole-repo scan does not finish. Kept out of scope with the 50-violation baseline as its acceptance criterion.

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

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions