Skip to content

fix(i18n/audit): eliminate false positives in raw-site classification - #656

Open
thomwebb wants to merge 3 commits into
mpfaffenberger:mainfrom
thomwebb:fix/i18n-audit-false-positives
Open

fix(i18n/audit): eliminate false positives in raw-site classification#656
thomwebb wants to merge 3 commits into
mpfaffenberger:mainfrom
thomwebb:fix/i18n-audit-false-positives

Conversation

@thomwebb

Copy link
Copy Markdown
Collaborator

Two bugs fixed

1. f-string false positives in _has_string_literal

Previously any ast.JoinedStr (f-string) was unconditionally classified as raw, meaning pure-variable forms like f"{result}" or f" {msg}" were counted as un-extracted literals even though they contain zero translatable content.

Fix: walk the f-string's values list and only return True when at least one ast.Constant part contains non-whitespace text (e.g. f"Error: {e}" still flags correctly; f"{var}" becomes dynamic).

Impact: 23 false positives reclassified as dynamic (raw: 1302 → 1279).

2. Single-file path silently returning 0 sites in _iter_py_files

os.walk(file_path) on a regular file yields nothing, so:

python -m code_puppy.i18n.audit code_puppy/command_line/session_commands.py

always reported 0 sites (while the full-tree scan correctly found 33 in that file). Confusing and broke the per-file workflow.

Fix: detect a file path up front and yield it directly when it ends in .py, before falling through to os.walk.

Tests added (5 new → 25 total)

Test What it guards
test_fstring_pure_variable_is_dynamic f"{var}" → dynamic
test_fstring_whitespace_only_literal_is_dynamic f" {msg}" → dynamic
test_fstring_with_content_and_variable_is_raw f"Error: {e}" → raw (regression guard)
test_fstring_with_only_arrow_is_raw non-whitespace punctuation counts
test_single_file_path_is_accepted per-file audit finds raw sites
test_single_file_pure_variable_fstring_not_raw both fixes together on a single file

@thomwebb thomwebb left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial Review — Automated (code-puppy-4aeaa3)

Verdict: CONDITIONAL PASS — the two core bugs are correctly fixed; two follow-on gaps in the same bug class need addressing.


Must Fix

1. New false negative: f"{'literal string'}" silently drops from audit

The fix scans node.values of ast.JoinedStr for direct ast.Constant children. But a constant inside a FormattedValue (e.g. f"{'Error: connection refused'}") produces:

JoinedStr(values=[
    FormattedValue(value=Constant('Error: connection refused'))  # one level deeper
])

There is no direct ast.Constant in node.values — the check returns Falsedynamic. The old code caught this correctly (return True for any JoinedStr). Verified on Python 3.14.

In practice nobody writes f"{'literal'}" instead of 'literal', so real-world impact is low — but the auditor now has a documented false-negative class. Fix or explicitly document the limitation in the docstring:

# Also recurse into FormattedValue.value for the rare f"{'literal'}" pattern
or (isinstance(v, ast.FormattedValue) and isinstance(v.value, ast.Constant)
    and isinstance(v.value.value, str) and v.value.value.strip())

2. Non-existent path silently returns coverage: 100%, raw: 0

_iter_py_files('/nonexistent/missing.py')[] with no output. This is the same class of bug as the one being fixed (silent zero report), just for a different bad input. A developer who fat-fingers a path gets a misleadingly clean report.

Fix: add an explicit warning to stderr when the path is neither a file nor a directory:

if not os.path.isdir(root):
    print(f"warning: {root!r} does not exist", file=sys.stderr)
    return

Should Fix

3. PR description says "5 new tests" — it is actually 6

test_single_file_pure_variable_fstring_not_raw is in the diff but missing from the PR description's table. Minor credibility issue for a review-focused PR. (pytest correctly reports 25 passed — the count in the description is the error.)

4. audit_tree docstring not updated

"""Audit every Python module under ``root``.""" — the function now also accepts a single .py file path. Update to: """Audit every Python module under ``root``, or ``root`` itself when it is a ``.py`` file."""


Confirmed Non-Issues (adversarial probes that cleared)

  • Nested f-strings like f"Error in {f'{m}.{f}'}" — outer JoinedStr.values[0] is a direct ast.Constant('Error in '); fix handles correctly
  • Multiline f-strings with \n/\tstr.strip() removes all ASCII whitespace; ' \n '.strip() == '' → dynamic
  • Symlinks to .py files — os.path.isfile follows symlinks; works on macOS
  • Empty directories, directories with no .py files — correctly return empty iterator
  • Non-.py file passed directly — silently skipped (acceptable; document if desired)

The core logic is correct and minimal. The any(isinstance(v, ast.Constant) and ... v.value.strip()) pattern is clean and Pythonic. The 23 false positives it removes are all genuine non-translatable pure-variable f-strings. The regression tests are real and explicitly documented.

@thomwebb thomwebb left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review feedback addressed — commit da025f70

Automated fix pass (code-puppy-4aeaa3). Both "must fix" items resolved.

Landed:

  • FormattedValue recursion: _has_string_literal for JoinedStr now also matches FormattedValue whose .value is a non-empty string Constant. Kills the f"{'Error: connection refused'}" false negative.
  • Non-existent path guard: audit_tree checks os.path.isdir(root) or os.path.isfile(root) at the top; on failure prints warning: '<path>' does not exist to stderr and returns an empty Report instead of the misleading silent "100% coverage, 0 raw".
  • Docstring updated: audit_tree now reads """Audit every Python module under \`root``, or ``root`` itself when it is a ``.py`` file."""`
  • Two regression tests added:
    • test_fstring_wrapped_literal_is_raw — builds AST for the FormattedValue-wrapped literal case and asserts _classify returns "raw"
    • test_nonexistent_path_warns_and_returns_empty — asserts empty result + stderr warning

Verification:

  • pytest tests/i18n/test_i18n_audit.py -q → 27 passed (25 prior + 2 new)
  • ruff check --fix / ruff format → clean
  • uv.lock side-effect from uv run was reverted before commit; only the two authorized files landed

CI should re-run on the new commit.

@thomwebb
thomwebb force-pushed the fix/i18n-audit-false-positives branch from da025f7 to f5aa921 Compare July 20, 2026 20:27

@thomwebb thomwebb left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased + piggyback test fix

Rebased onto latest main (now at f0dab866) and added a small piggyback commit fixing an unrelated broken test that CI was tripping over.

Context

Upstream commit f0dab866 (feat: add GPT-5.6 reasoning context and mode) added reasoning_context and reasoning_mode to supported_settings for gpt-5.6* models in chatgpt_oauth/utils.py but did not update tests/plugins/test_chatgpt_oauth_utils.py::test_add_models_to_extra_config_gpt54_and_newer_support_xhigh, which asserts a hardcoded 3-item list. Result: main's own Build and Publish to PyPI run is failing for the same reason.

What the piggyback commit does

Splits the assertion so codex-gpt-5.6-* variants expect the extra fields:

expected_settings = ["reasoning_effort", "summary", "verbosity"]
if model_name.startswith("codex-gpt-5.6"):
    expected_settings.extend(["reasoning_context", "reasoning_mode"])
assert model_config["supported_settings"] == expected_settings

Verified locally: test passes on this branch.

Cleanup

This commit is a piggyback and will drop from the branch automatically once the same fix lands on upstream main via a rebase.

@mpfaffenberger mpfaffenberger left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed from a local worktree — all three fixes are legit and well-tested:

  • whitespace-only f-string segments no longer counted as raw: correct, f"{x}" / f" {x}" carry nothing translatable
  • recursing into FormattedValue.value so f"{'literal'}" stays classified as raw: nice edge case, would have been an easy blind spot
  • .py file argument support in _iter_py_files: obvious QoL win for python -m code_puppy.i18n.audit path/to/module.py

tests/i18n/ (126 tests) and ruff check pass locally. Two comments:

1. The "fail loud" comment doesn't fail loud

if not os.path.isdir(root) and not os.path.isfile(root):
    # Silent-zero is worse than useless — it tells CI that a typo'd
    # path has "100% coverage". Fail loud instead.
    print(f"warning: {root!r} does not exist", file=sys.stderr)
    return report

The comment diagnoses the problem perfectly and then the code commits it anyway: an empty Report has coverage == 100.0 (explicitly, per the coverage property), so --fail-under on a typo'd path still exits 0 — CI sees green, the warning scrolls by in stderr where nobody looks. That is precisely the silent-zero the comment decries, just with narration.

Suggest one of:

  • raise FileNotFoundError from audit_tree (it's a programming/config error, let it propagate), or
  • have main() detect the nonexistent root and return 1 before computing coverage.

Either way, add a test asserting the nonzero exit / raise — the current tests cover the happy paths of all three fixes but not this guard.

2. Drop the shared test(chatgpt): update supported_settings expectation for gpt-5.6 commit

Same as noted on #652: main already has the equivalent fix with startswith("codex-gpt-5.6-") (trailing dash) vs this branch's startswith("codex-gpt-5.6") — same lines, different content, guaranteed conflict, and it has nothing to do with the audit. Rebase onto main and it disappears.

Small aside: with fix 1, sites like emit_error(f"Traceback: {traceback.format_exc()}") remain correctly classified as raw (constant segment has text) — good, that matches the intent.

@thomwebb
thomwebb force-pushed the fix/i18n-audit-false-positives branch from f5aa921 to ae0ffd8 Compare July 23, 2026 19:16

@thomwebb thomwebb left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review feedback addressed — commit 4be0691e

Automated fix pass. Applied the "fail loud" fix.

Approach: raise FileNotFoundError from audit_tree

Went with the Pythonic option ("errors should never pass silently unless explicitly silenced" — Zen of Python). main() doesn't need to know; the exception propagates through SystemExit(main()) and Python emits exit code 1 + a real traceback pointing at the bogus path.

  • No swallowed warning
  • No fake "100% coverage" from an empty Report
  • No silent green CI on a typo'd path

What changed

  1. code_puppy/i18n/audit.pyaudit_tree now raises FileNotFoundError when root is neither a file nor a directory. Comment updated to match actual behavior. Report() construction moved after the guard (dead line removed).
  2. tests/i18n/test_i18n_audit.py — flipped the misnamed test_nonexistent_path_warns_and_returns_empty (which literally locked in the bug) into test_nonexistent_path_raises, which asserts both:
    • audit.audit_tree(missing) raises FileNotFoundError
    • audit.main([missing]) lets it propagate (no accidental swallow, no exit 0)

Smoke check

$ uv run python -m code_puppy.i18n.audit /tmp/nonexistent-path-xyz
FileNotFoundError: audit root does not exist: '/tmp/nonexistent-path-xyz'
exit=1

Tests

27 passed (tests/i18n/test_i18n_audit.py).

@thomwebb
thomwebb force-pushed the fix/i18n-audit-false-positives branch from 4be0691 to 8bb950e Compare July 23, 2026 19:33
@thomwebb

Copy link
Copy Markdown
Collaborator Author

Heads-up: quality job failure is a repo-wide ruff-version drift, not this PR

The quality CI job is red on all currently-open PRs (including this one) with ~5900 lint errors. Verified locally that the same errors are reported against untouched main:

$ uv tool run ruff check .   # ruff 0.16.0, the version CI now installs
Found 5902 errors.

Root cause

.github/workflows/ci.yml does an unpinned install:

- name: Install dev dependencies (ruff)
  run: pip install ruff        # ← no version pin

pyproject.toml only sets a floor (ruff>=0.11.11). Ruff 0.16.0 (released recently) enabled new default rules (SIM117, EXE001, BLE001, etc.) that main has never been linted against. Every open PR now fails quality on inherited-code lint errors from files it doesn't touch.

This PR's diffs are clean

Ran the older pinned ruff locally on the branch — All checks passed!.

Suggested one-line fix (whenever you get to it)

Pin ruff in ci.yml to a version that matches what main was last known-clean at:

- run: pip install ruff
+ run: pip install "ruff==0.15.13"

Or add an explicit upper bound in pyproject.toml's dev group so uv tool run ruff and CI agree.

Happy to open a separate cleanup PR for either the pin or a repo-wide ruff check --fix --unsafe-fixes . sweep — just say the word. Not folding it in here to keep the diff scope tight for review.

@mpfaffenberger

Copy link
Copy Markdown
Owner

CI follow-up: I retriggered the failed Quality Checks run — it failed again, and it will keep failing on rerun regardless of your changes. Root cause: this branch's merge snapshot predates main's ruff pin (pip install 'ruff>=0.15,<0.16' in ci.yml — added after ruff 0.16.0 changed the default rule set and produced 5,901 "new" errors repo-wide). Reruns reuse the original merge commit, so they keep installing unpinned ruff 0.16.

Fix is the rebase onto current main already requested in the review (which also drops the duplicated gpt-5.6 test commit). A fresh push will generate a new merge ref with the pinned ruff and the lint job should go green — your actual changes pass ruff check locally against main's config.

TJ Webb added 3 commits July 23, 2026 15:15
Two bugs fixed:

1. f-string false positives (_has_string_literal)
   Previously any ast.JoinedStr was unconditionally classified as 'raw',
   meaning pure-variable f-strings like f"{result}" or f"  {msg}" were
   counted as un-extracted literals even though they contain no translatable
   content. Fixed by walking the f-string's values list and only returning
   True when at least one ast.Constant part has non-whitespace text.
   Impact: 23 false positives reclassified as 'dynamic' (1302 -> 1279 raw).

2. Single-file path silently returning 0 sites (_iter_py_files)
   os.walk(file_path) on a regular file yields nothing, so
   'python -m code_puppy.i18n.audit path/to/module.py' always reported
   0 sites. Fixed by detecting a file path up front and yielding it
   directly if it ends in .py.

Tests added (5 new, 25 total):
  test_fstring_pure_variable_is_dynamic
  test_fstring_whitespace_only_literal_is_dynamic
  test_fstring_with_content_and_variable_is_raw
  test_fstring_with_only_arrow_is_raw
  test_single_file_path_is_accepted
  test_single_file_pure_variable_fstring_not_raw

All 25 audit tests pass. ruff clean.
@thomwebb
thomwebb force-pushed the fix/i18n-audit-false-positives branch from 8bb950e to 44f98c4 Compare July 23, 2026 22:15
@thomwebb

Copy link
Copy Markdown
Collaborator Author

@mpfaffenberger can you please take another look at this one? all checks are passing

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.

2 participants