Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ wardline --version
```

```text
wardline, version 1.0.4
wardline, version 1.0.5
```

## 2. Run a first scan
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/legis-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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…",
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/wardline/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.0.4"
__version__ = "1.0.5"
69 changes: 37 additions & 32 deletions src/wardline/core/delta_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
37 changes: 28 additions & 9 deletions src/wardline/scanner/rules/_ast_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment on lines +194 to +195

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle except aliases when shadowing TYPE_CHECKING

When the shadowing binding is except ... as TYPE_CHECKING, this branch never runs because _own_statements() yields only ast.stmt children and recurses through ExceptHandler bodies rather than yielding the handler node itself. In a handler such as except ValueError as TYPE_CHECKING: if TYPE_CHECKING: raise ..., the module-level TYPE_CHECKING alias therefore remains in the scoped alias map, so the reachable raise is still treated as a dead typing-only branch and the boundary rules can report the wrong result for exception-handler validation paths. Collect handler names while visiting ast.Try/ast.TryStar instead.

Useful? React with 👍 / 👎.



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]]:
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/core/test_delta_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions tests/unit/scanner/rules/test_boundary_without_rejection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) == []
Loading