From cfacf8cbd14df1a0bb03813bdf392326c78076a8 Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Fri, 19 Jun 2026 08:15:06 +1000 Subject: [PATCH 1/2] fix: tighten review delta and type-checking scope bugs --- src/wardline/core/delta_resolve.py | 69 ++++++++++--------- src/wardline/scanner/rules/_ast_helpers.py | 37 +++++++--- tests/unit/core/test_delta_resolve.py | 27 ++++++++ .../rules/test_boundary_without_rejection.py | 18 +++++ 4 files changed, 110 insertions(+), 41 deletions(-) diff --git a/src/wardline/core/delta_resolve.py b/src/wardline/core/delta_resolve.py index db1044c2..35eccb24 100644 --- a/src/wardline/core/delta_resolve.py +++ b/src/wardline/core/delta_resolve.py @@ -49,12 +49,11 @@ from __future__ import annotations import ast -from collections.abc import Mapping, Sequence +from collections import deque +from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path -from typing import cast -from wardline.core.delta import get_affected_entities from wardline.core.delta_scope import AffectedEntity, AffectedScope from wardline.core.finding import ( _PROPERTY_ACCESSOR_QUALNAME_SUFFIXES, @@ -264,7 +263,7 @@ def resolve_affected_scope( # missed is unresolved, recorded in exactly one bucket). unresolved.append(entity) - files = _expand_callers(base_files, index) + files = _expand_callers(base_files, base_qualnames, index) return ResolvedScope( files=files, @@ -357,22 +356,32 @@ def _resolve_sei_qualname(sei_resolver: SeiResolver, sei: str) -> str | None: return canonical_qualname(locator_to_qualname(locator)) -def _expand_callers(base_files: frozenset[str] | set[str], index: QualnameIndex) -> frozenset[str]: - """Expand a base file set with the files of every caller of an affected entity. +def _expand_callers( + base_files: frozenset[str] | set[str], + base_qualnames: frozenset[str] | set[str], + index: QualnameIndex, +) -> frozenset[str]: + """Expand a base file set with files of callers of the affected entities. - Reuses :func:`wardline.core.delta.get_affected_entities` (the reverse callee→caller - BFS) over the index's structural call graph so a worklist naming a changed callee - pulls in the caller-side files that carry the taint finding (spec §5.3a). The base - files are always retained; only callers are added.""" + Seeds the reverse callee→caller BFS from ``base_qualnames`` rather than from every + entity in ``base_files``. A file can contain unrelated entities; callers of those + unrelated co-file entities must not inflate a "scan only these entities" delta.""" if not base_files: return frozenset(base_files) - entity_map = {qualname: _IndexEntity(_IndexLocation(path)) for qualname, path in index.entities.items()} - # get_affected_entities reads only ``entity.location.path``; _IndexEntity provides - # exactly that, so the cast bridges the concrete-Entity annotation without building - # real AST-backed Entity objects in this taint-free pass. - affected_qualnames = get_affected_entities( - set(base_files), cast("Mapping[str, Entity]", entity_map), index.project_edges - ) + affected_qualnames = _closure_seed_qualnames(base_qualnames, index) + reverse_edges: dict[str, set[str]] = {} + for caller, callees in index.project_edges.items(): + for callee in callees: + reverse_edges.setdefault(callee, set()).add(caller) + + queue = deque(affected_qualnames) + while queue: + current = queue.popleft() + for caller in reverse_edges.get(current, set()): + if caller not in affected_qualnames: + affected_qualnames.add(caller) + queue.append(caller) + files = set(base_files) for qualname in affected_qualnames: path = index.entities.get(qualname) @@ -381,22 +390,18 @@ def _expand_callers(base_files: frozenset[str] | set[str], index: QualnameIndex) return frozenset(files) -@dataclass(frozen=True, slots=True) -class _IndexLocation: - """The minimal ``.path`` surface :func:`get_affected_entities` reads off an entity.""" - - path: str - - -@dataclass(frozen=True, slots=True) -class _IndexEntity: - """A lightweight stand-in exposing only ``.location.path`` for the caller closure. - - :func:`wardline.core.delta.get_affected_entities` reads only ``entity.location.path``, - so the caller closure runs off the cheap structural index without constructing real - :class:`~wardline.scanner.index.Entity` objects (which would need an AST node).""" +def _closure_seed_qualnames(base_qualnames: frozenset[str] | set[str], index: QualnameIndex) -> set[str]: + """Return concrete entity qualnames to seed caller closure. - location: _IndexLocation + Exact function/method locators seed that entity. Class-level locators seed every + indexed method below the class prefix so callers of any class member are included.""" + seeds: set[str] = set() + for qualname in base_qualnames: + if qualname in index.entities: + seeds.add(qualname) + prefix = f"{qualname}." + seeds.update(candidate for candidate in index.entities if candidate.startswith(prefix)) + return seeds def _relpath(file: Path, root: Path) -> str: diff --git a/src/wardline/scanner/rules/_ast_helpers.py b/src/wardline/scanner/rules/_ast_helpers.py index 84078c59..7c3b52ff 100644 --- a/src/wardline/scanner/rules/_ast_helpers.py +++ b/src/wardline/scanner/rules/_ast_helpers.py @@ -167,10 +167,10 @@ def _is_type_checking_guard(test: ast.expr, alias_map: Mapping[str, str] | None def _local_binding_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[str]: """Yield names bound in *node*'s OWN scope that can shadow an outer binding — - parameters and non-import local assignment targets. ``from typing import - TYPE_CHECKING`` / ``import typing`` are deliberately EXCLUDED here: those bind - the real typing constant and are restored by :func:`_local_typing_imports`, - which wins over a shadow. Walks the own scope (skips nested def/class).""" + parameters, imports, and local assignment targets. ``from typing import + TYPE_CHECKING`` / ``import typing`` are yielded here first, then restored by + :func:`_local_typing_imports`, so only genuine typing bindings keep typing + semantics. Walks the own scope (skips nested def/class).""" args = node.args for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs): yield arg.arg @@ -179,12 +179,31 @@ def _local_binding_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterat if args.kwarg: yield args.kwarg.arg for stmt in _own_statements(node): - if isinstance(stmt, ast.Assign): + if isinstance(stmt, (ast.Import, ast.ImportFrom)): + for alias in stmt.names: + yield alias.asname or alias.name.split(".", 1)[0] + elif isinstance(stmt, ast.Assign): for target in stmt.targets: - if isinstance(target, ast.Name): - yield target.id - elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): - yield stmt.target.id + yield from _binding_target_names(target) + elif isinstance(stmt, (ast.AnnAssign, ast.AugAssign, ast.For, ast.AsyncFor)): + yield from _binding_target_names(stmt.target) + elif isinstance(stmt, (ast.With, ast.AsyncWith)): + for item in stmt.items: + if item.optional_vars is not None: + yield from _binding_target_names(item.optional_vars) + elif isinstance(stmt, ast.ExceptHandler) and stmt.name: + yield stmt.name + + +def _binding_target_names(target: ast.AST) -> Iterator[str]: + """Yield names bound by an assignment-like target, including destructuring.""" + if isinstance(target, ast.Name): + yield target.id + elif isinstance(target, ast.Starred): + yield from _binding_target_names(target.value) + elif isinstance(target, (ast.Tuple, ast.List)): + for elt in target.elts: + yield from _binding_target_names(elt) def _local_typing_imports(node: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[tuple[str, str]]: diff --git a/tests/unit/core/test_delta_resolve.py b/tests/unit/core/test_delta_resolve.py index 38ea0a5f..9a9177e5 100644 --- a/tests/unit/core/test_delta_resolve.py +++ b/tests/unit/core/test_delta_resolve.py @@ -294,6 +294,33 @@ def test_caller_closure_pulls_callers_file(tmp_path: Path) -> None: assert resolved.affected_qualnames == frozenset({"b.source"}) +def test_caller_closure_does_not_expand_from_unrelated_entities_in_same_file(tmp_path: Path) -> None: + # b.py defines the affected callee and an unrelated helper. Only callers of + # b.source should expand the analyzed files; callers of b.unrelated must not + # be swept in just because they share the base file. + b = _write( + tmp_path, + "b.py", + "def source():\n return input()\n\ndef unrelated():\n return 'noise'\n", + ) + a = _write( + tmp_path, + "a.py", + "from b import source\n\ndef sink():\n return source()\n", + ) + noise = _write( + tmp_path, + "noise.py", + "from b import unrelated\n\ndef caller():\n return unrelated()\n", + ) + index = build_qualname_index([a, b, noise], tmp_path) + + scope = _scope(AffectedEntity(sei=None, locator="python:function:b.source")) + resolved = resolve_affected_scope(scope, index=index, sei_resolver=None) + + assert resolved.files == frozenset({"a.py", "b.py"}) + + def test_caller_closure_self_method(tmp_path: Path) -> None: src = ( "class Svc:\n def sink(self):\n return self.source()\n def source(self):\n return input()\n" diff --git a/tests/unit/scanner/rules/test_boundary_without_rejection.py b/tests/unit/scanner/rules/test_boundary_without_rejection.py index bd35c3b0..107299f7 100644 --- a/tests/unit/scanner/rules/test_boundary_without_rejection.py +++ b/tests/unit/scanner/rules/test_boundary_without_rejection.py @@ -309,3 +309,21 @@ def test_function_local_type_checking_import_is_honored_as_dead_branch(tmp_path) }, ) assert [(f.rule_id, f.qualname) for f in _run(ctx)] == [("PY-WL-102", "m.v")] + + +def test_non_typing_local_import_shadowing_type_checking_is_runtime_reachable(tmp_path) -> None: + # A function-local import with alias ``TYPE_CHECKING`` shadows the module-level + # typing constant. The guard is runtime-reachable, so its ``raise`` is a real + # rejection and must not be treated as a dead typing-only branch. + ctx, _ = _analyze( + tmp_path, + { + "m.py": "from typing import TYPE_CHECKING\n" + "from wardline.decorators import trust_boundary\n" + "@trust_boundary(to_level='ASSURED')\n" + "def v(p):\n" + " import os as TYPE_CHECKING\n" + " if TYPE_CHECKING:\n raise ValueError\n x = p\n return x\n", + }, + ) + assert _run(ctx) == [] From 0752a28f8a9bbd4e62823ce4b8c2f7b1990b040c Mon Sep 17 00:00:00 2001 From: John Morrissey Date: Fri, 19 Jun 2026 08:15:51 +1000 Subject: [PATCH 2/2] chore: prepare wardline 1.0.5 --- CHANGELOG.md | 14 ++++++++++++++ docs/getting-started.md | 2 +- docs/guides/legis-handoff.md | 2 +- docs/reference/cli.md | 4 ++-- src/wardline/_version.py | 2 +- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa3d0ad2..4a04dfe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.5] - 2026-06-19 + +### Fixed +- **Python boundary reachability.** Function-local imports that shadow + `TYPE_CHECKING` are now treated as real runtime bindings, so reachable + rejection branches are honored instead of producing a false `PY-WL-102`. + Reviewed regression source: `19089461`; fix commit: `cfacf8cb`. +- **Warpline delta scope precision.** `wardline scan --affected` now expands + caller files from the resolved affected entities, not from every entity in + the same file, so callers of unrelated co-file helpers no longer inflate the + advisory delta scan. Reviewed regression source: `117649ca`; fix commit: + `cfacf8cb`. + ## [1.0.4] - 2026-06-19 ### Added @@ -1316,6 +1329,7 @@ for Python — enterprise-class trust-boundary analysis at small-team weight. - **Packaging** — MIT-licensed; optional extras `scanner` (config + CLI) and `weft` (HTTP integrations). +[1.0.5]: https://github.com/foundryside-dev/wardline/compare/v1.0.4...v1.0.5 [1.0.4]: https://github.com/foundryside-dev/wardline/compare/v1.0.3...v1.0.4 [1.0.3]: https://github.com/foundryside-dev/wardline/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/foundryside-dev/wardline/compare/v1.0.1...v1.0.2 diff --git a/docs/getting-started.md b/docs/getting-started.md index 59c83d4d..0601a412 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,7 +20,7 @@ wardline --version ``` ```text -wardline, version 1.0.4 +wardline, version 1.0.5 ``` ## 2. Run a first scan diff --git a/docs/guides/legis-handoff.md b/docs/guides/legis-handoff.md index 0671504d..ed811fd1 100644 --- a/docs/guides/legis-handoff.md +++ b/docs/guides/legis-handoff.md @@ -21,7 +21,7 @@ signature: ```json { - "scanner_identity": "wardline@1.0.4", + "scanner_identity": "wardline@1.0.5", "rule_set_version": "sha256:9f86d0…", "commit_sha": "0a4a00e…", "tree_sha": "4b825dc…", diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e164b461..b14e75f3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,6 +1,6 @@ # CLI reference -Complete reference for the `wardline` command-line interface, version `1.0.4`. +Complete reference for the `wardline` command-line interface, version `1.0.5`. Every `--help` block below is the verbatim output of the installed CLI; every example is a realistic invocation. @@ -66,7 +66,7 @@ Check the installed version: ```text $ wardline --version -wardline, version 1.0.4 +wardline, version 1.0.5 ``` Use `--version` in CI before a scan to pin the toolchain in your build log; the diff --git a/src/wardline/_version.py b/src/wardline/_version.py index 92192eed..68cdeee4 100644 --- a/src/wardline/_version.py +++ b/src/wardline/_version.py @@ -1 +1 @@ -__version__ = "1.0.4" +__version__ = "1.0.5"