@@ -312,9 +312,7 @@ def _iter_python_files(root: Path) -> list[Path]:
312312# Regex used in the ``--auto-apply`` import-path fix pass: matches the
313313# ``from adcp.types.generated_poc...`` prefix so it can be replaced with
314314# ``from adcp.types``.
315- _GENERATED_POC_MODULE_RE = re .compile (
316- r"from\s+adcp\.types\.generated_poc(?:\.[\w.]+)?\s+import"
317- )
315+ _GENERATED_POC_MODULE_RE = re .compile (r"from\s+adcp\.types\.generated_poc(?:\.[\w.]+)?\s+import" )
318316
319317# Union of symbol names that ``--auto-apply`` can safely reroute to
320318# ``adcp.types``: the explicit flag_private symbol map plus every public
@@ -359,6 +357,28 @@ def scan_file(
359357 auto_apply_hits = False # any numbered or private-import rewrites queued
360358
361359 for lineno , line in enumerate (original .splitlines (), start = 1 ):
360+ # Pre-pass: when this line is a single-line ``generated_poc``
361+ # import, decide whether the line as a whole is auto-apply-safe.
362+ # An import is *unsafe* when at least one of its symbols (after
363+ # the hypothetical numbered substitution) isn't in
364+ # ``_AUTO_APPLY_PUBLIC_SYMBOLS``; rewriting one symbol while
365+ # leaving another behind would leave the line importing a
366+ # public name from a private module — guaranteed ImportError.
367+ # The rewrite block (`updated.splitlines()` later) skips
368+ # unsafe-mixed lines; the per-symbol Finding emission below
369+ # also treats numbered references on those lines as
370+ # ``flag_numbered`` rather than ``auto_applied`` so the report
371+ # matches the file content.
372+ line_is_mixed_unsafe_import = False
373+ if "adcp.types.generated_poc" in line :
374+ from_match = _GENERATED_POC_FROM_IMPORT .search (line )
375+ if from_match :
376+ raw_syms = [s .strip () for s in from_match .group (1 ).split ("," )]
377+ pre_syms = [r .split (" as " )[0 ].strip () for r in raw_syms if r .strip ()]
378+ post_syms = [NUMBERED_ASSETS_RENAMES .get (s , s ) for s in pre_syms ]
379+ if pre_syms and not all (s in _AUTO_APPLY_PUBLIC_SYMBOLS for s in post_syms ):
380+ line_is_mixed_unsafe_import = True
381+
362382 for old , new in ASSET_CONTENT_RENAMES .items ():
363383 for match in _RENAME_PATTERNS [old ].finditer (line ):
364384 findings .append (
@@ -392,7 +412,7 @@ def scan_file(
392412 for match in NUMBERED_ASSETS_PATTERN .finditer (line ):
393413 symbol = match .group (0 )
394414 alias = NUMBERED_ASSETS_RENAMES .get (symbol )
395- if auto_apply and alias is not None :
415+ if auto_apply and alias is not None and not line_is_mixed_unsafe_import :
396416 findings .append (
397417 Finding (
398418 kind = "auto_applied" ,
@@ -470,8 +490,7 @@ def scan_file(
470490 continue
471491
472492 all_known = all (
473- repl is not None
474- or (auto_apply and symbol in NUMBERED_ASSETS_RENAMES )
493+ repl is not None or (auto_apply and symbol in NUMBERED_ASSETS_RENAMES )
475494 for symbol , repl in parsed
476495 )
477496
@@ -551,38 +570,45 @@ def scan_file(
551570 needs_write = True
552571
553572 if apply_changes and auto_apply and auto_apply_hits :
554- # Step 1: substitute Assets<N> → SemanticAlias everywhere
555- # (handles both usage sites and import symbols).
556- for old , new in NUMBERED_ASSETS_RENAMES .items ():
557- updated = _NUMBERED_RENAME_PATTERNS [old ].sub (new , updated )
558-
559- # Step 2: fix any generated_poc import whose symbols are now all
560- # resolvable to adcp.types. This covers two cases:
561- #
562- # a. ``from generated_poc.core.format import Assets81`` became
563- # ``from generated_poc.core.format import VideoFormatAsset``
564- # after step 1 — rewrite the module path.
565- #
566- # b. ``from generated_poc.core.x import ContextObject`` whose
567- # all-known flag_private findings were promoted to auto_applied
568- # — rewrite the module path here too.
569- #
570- # The check uses ``_AUTO_APPLY_PUBLIC_SYMBOLS`` (a frozen set of all
571- # known-safe names) so we never fix an import that still references
572- # an unknown symbol.
573+ # Process the file line-by-line so generated_poc imports get a
574+ # safety check against the post-numbered-substitution symbol set
575+ # before any rewrite happens. The earlier "Step 1: substitute
576+ # Assets<N> file-wide; Step 2: fix import paths only when safe"
577+ # ordering corrupted mixed lines like
578+ # ``from generated_poc.core.format import Assets81, Assets149``
579+ # — Assets81 became VideoFormatAsset while Assets149 stayed,
580+ # leaving VideoFormatAsset imported from a private module.
573581 new_lines : list [str ] = []
574582 for text_line in updated .splitlines (keepends = True ):
575- if "adcp.types.generated_poc" not in text_line :
583+ is_generated_poc_import = (
584+ "adcp.types.generated_poc" in text_line
585+ and _GENERATED_POC_FROM_IMPORT .search (text_line ) is not None
586+ )
587+ if is_generated_poc_import :
588+ m = _GENERATED_POC_FROM_IMPORT .search (text_line )
589+ assert m is not None # narrowed above
590+ raw_syms = [s .strip () for s in m .group (1 ).split ("," )]
591+ pre_syms = [r .split (" as " )[0 ].strip () for r in raw_syms if r .strip ()]
592+ # Apply the hypothetical numbered rename to each symbol
593+ # so we can check if the *post-rename* symbol set is
594+ # safe.
595+ post_syms = [NUMBERED_ASSETS_RENAMES .get (s , s ) for s in pre_syms ]
596+ if post_syms and all (s in _AUTO_APPLY_PUBLIC_SYMBOLS for s in post_syms ):
597+ # Whole import is safe — substitute numbered names
598+ # AND fix the module path.
599+ for old , new in NUMBERED_ASSETS_RENAMES .items ():
600+ text_line = _NUMBERED_RENAME_PATTERNS [old ].sub (new , text_line )
601+ text_line = _GENERATED_POC_MODULE_RE .sub ("from adcp.types import" , text_line )
602+ # Mixed line — leave it alone. The findings list still
603+ # carries the per-symbol flag_private and flag_numbered
604+ # entries so the adopter sees the work to do.
576605 new_lines .append (text_line )
577606 continue
578- m = _GENERATED_POC_FROM_IMPORT .search (text_line )
579- if m :
580- raw_syms = [s .strip () for s in m .group (1 ).split ("," )]
581- syms = [r .split (" as " )[0 ].strip () for r in raw_syms if r .strip ()]
582- if syms and all (sym in _AUTO_APPLY_PUBLIC_SYMBOLS for sym in syms ):
583- text_line = _GENERATED_POC_MODULE_RE .sub (
584- "from adcp.types import" , text_line
585- )
607+ # Non-import lines: substitute numbered names freely (the
608+ # semantic alias is already importable via adcp.types and
609+ # any local reference the line carries is a usage site).
610+ for old , new in NUMBERED_ASSETS_RENAMES .items ():
611+ text_line = _NUMBERED_RENAME_PATTERNS [old ].sub (new , text_line )
586612 new_lines .append (text_line )
587613 updated = "" .join (new_lines )
588614 needs_write = True
@@ -597,9 +623,7 @@ def run(root: Path, *, apply_changes: bool = False, auto_apply: bool = False) ->
597623 report = Report ()
598624 for path in _iter_python_files (root ):
599625 report .scanned_files += 1
600- findings , new_contents = scan_file (
601- path , apply_changes = apply_changes , auto_apply = auto_apply
602- )
626+ findings , new_contents = scan_file (path , apply_changes = apply_changes , auto_apply = auto_apply )
603627 for f in findings :
604628 report .add (f )
605629 if new_contents is not None :
@@ -691,9 +715,7 @@ def _format_text_report(report: Report, *, apply_changes: bool, auto_apply: bool
691715 lines .append (f"Rewrote { report .rewritten_files } files in place." )
692716 lines .append ("Review with `git diff` before committing." )
693717
694- if not auto_apply and any (
695- f .kind in ("flag_private" , "flag_numbered" ) for f in report .flagged
696- ):
718+ if not auto_apply and any (f .kind in ("flag_private" , "flag_numbered" ) for f in report .flagged ):
697719 lines .append ("" )
698720 lines .append (
699721 "Tip: rerun with --auto-apply to mechanically fix the "
0 commit comments