diff --git a/CHANGELOG.md b/CHANGELOG.md index 906450518..6f5d8b2f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Changes +- `diff --application` reads bounded local factories returning OpenAI Agents SDK and Google ADK tool lists. It keeps the returned members and their construction locations, and reports unread constructor identity, import effects, escaping values, or unresolved callers as coverage gaps rather than treating them as established wiring. This is static source analysis; it does not execute a factory or establish runtime enforcement. (#874, #964) +- Constructor ownership still withholds canonical SDK Runner execution consumers and anonymous callable or generator carriers. These are static coverage limits; readable candidate tools and handoffs remain visible, and incomplete scans do not report them as removed. The direct `from . import agent` ADK package layout is supported. When a binding graph is incomplete, a capability comparison now withholds only what depends on absence (removed and narrowed capabilities, resolved findings) and still reports added, changed and unknown-direction changes, saying so in its notes; a remote Google ADK `McpToolset` whose tools cannot be listed is not itself incomplete binding evidence, so a changed endpoint, `os.environ[...]` credential reference or tool filter on it, nested stdio parameters included, is reported again. An ADK sub-agent behind a constructor gap stays a candidate with an incomplete edge instead of being reported as unmatched. A module that re-exports `eval` or `exec` from `builtins` is no longer treated as an unused import while another module imports it. The proof that no repository file provides a standard-library module reads the directory listing, so it is the same on case-sensitive and case-insensitive filesystems. (#964) - **An MCP row names the `env_vars` entries that changed.** Replaying openai/codex-security#1281, a `.mcp.json` that added `CODEX_SECURITY_PLUGIN_ROOT` to a server's `env_vars` array and changed nothing else was a `changed` row reading `no difference in the command name launch_codex_security_mcp, launch arguments, env key names or header key names; the change is in a detail this output does not show, such as the command's path or another setting`, which pointed at the command's path for a change to a variable name. An MCP server grant now publishes `env_var_names` (host-grants `0.9`, extended in place): the plain names its `env_vars` list declares, in declared order, never a `NAME=value` entry, an object, a token-shaped string or an entry the digest's redaction rewrites. The row reads `codex-security: env_vars names +CODEX_SECURITY_PLUGIN_ROOT` on `diff` (text and `--json`), `verify`, the PR comment and `check`, beside `env keys` and `header keys` (`env keys +API_BASE -DEBUG; env_vars names +X -Y`); the same names in another order read `env_vars names in a different order`, and an added or removed server's cell lists them. The label is the key the list is declared under, so no claim is made that a variable is set or read. No value is published or printed, and rotating an `env` value is still no row, because `config_sha256`'s input redacts every `env` value. The field is display-only, left out of grant equality, the inventory digests and saved baselines, so no row, direction, `expands`, severity, expansion signal or `check` decision moves; a change confined to an unpublished entry still says it is not shown. See [STABILITY.md](STABILITY.md#mcp-env-var-names-795). (#795) - **An OpenShell policy row names the declarations that changed.** Replaying openshift-kni/ai-sandbox#29, a selected policy that removed `/sandbox/.uv/python` from `filesystem_policy.read_only`, changed binary selectors and added GitHub release destinations printed `authored, OpenShell 0.1.2, 6 endpoint(s), facts → …7 endpoint(s), facts `, which names none of them. `diff` (text and `--json`), `verify`, `check` and the PR comment now print the difference: `filesystem read_only -/sandbox/.uv/python`, `destination aiplatform.googleapis.com:443 (l4) binaries +/tmp/**/node_modules/…/claude -/usr/local/bin/claude`, `removed destination oauth2.googleapis.com:443 (l4) via /usr/local/bin/claude` and `added destinations objects.githubusercontent.com:443, release-assets.githubusercontent.com:443 (l4) via …`, with changed request declarations, `include_workdir`, Landlock compatibility and process identity named the same way. Each list names its first entries and counts the rest. Only values the OpenShell inventory already publishes are named, and a value that would change under redaction prints as ``. Query and parameter constraints and endpoint options appear by field name, not value. A policy selected or removed whole lists what it declares and which sections and inspected endpoints took an upstream default, as a description and not a proof that authority grew. Display only: the published `rows[]`, direction, `expands`, severity, expansion signals and `check` decisions are unchanged, the existing unknown-direction and runtime-limit wording stays in each row's reason, and no schema changes. (#968) - **`diff` classifies a large base tree about eight times faster.** Translating each boundary glob into a regular expression was repeated for every path in the tree and was 74–86% of first-run `diff` time on two large repositories (#698's measurement). The compiled pattern is now cached per pattern; which paths match is unchanged, and a test checks the cached matcher against the previous implementation on over 10,000 random pattern and path pairs. (#698) diff --git a/README.md b/README.md index 05015daf3..77cebbfa0 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,12 @@ Filename detection never establishes verified permissions. ## What your PR sees The same run writes `pr-comment.md`, the comment the GitHub Action posts. -Abridged from the artifact: +This abridged excerpt is from the published `v1.2.0` release. The current +source tree reports what a change added or changed but withholds what it +removed or narrowed when agent binding coverage is incomplete, and names that +limit; that coverage gap does not change the four blocking findings. + +Abridged from the released artifact: ```text ## Agents Shipgate diff --git a/ci_timing.py b/ci_timing.py new file mode 100644 index 000000000..9324fd3c5 --- /dev/null +++ b/ci_timing.py @@ -0,0 +1,164 @@ +"""Best-effort hosted timings for whole files, including canceled CI runs. + +This observer never selects tests or changes their result. A completed-file +record requires a real teardown for every selected case. Missing records are +unmeasured; an invalidation discards any earlier record for that file/session. +""" + +from __future__ import annotations + +import hashlib +import json +import math +from collections import Counter +from dataclasses import dataclass, field +from pathlib import PurePosixPath + +import pytest + +PREFIX = "SHIPGATE_TIMING " +SCHEMA = "shipgate.ci_file_timing/v1" +_PHASES = {"setup": 1, "call": 2, "teardown": 4} + + +@dataclass +class _File: + selected: int + completed: int = 0 + phases: dict[str, float] = field(default_factory=lambda: dict.fromkeys(_PHASES, 0.0)) + outcomes: Counter = field(default_factory=Counter) + invalid: bool = False + + +class FileTimings: + """One immutable selected census and distinct worker phase reports.""" + + def __init__(self) -> None: + self.ids: tuple[str, ...] | None = None + self.cases: dict[str, tuple[str, int, str | None]] = {} + self.files: dict[str, _File] = {} + self.invalid = False + + def collection(self, ids: list[str]) -> list[dict]: + if self.invalid: + return [] + current = tuple(ids) + if self.ids is not None: + if current != self.ids: + self.invalid = True + return [{"kind": "invalidate_session", "reason": "worker_collection_mismatch"}] + return [] + if not current or len(current) > 50000 or len(set(current)) != len(current): + self.invalid = True + return [{"kind": "invalidate_session", "reason": "unsupported_collection"}] + paths = [nodeid.split("::", 1)[0] for nodeid in current] + if any(not path.startswith("tests/") or not path.endswith(".py") + or len(path) > 1024 or ".." in PurePosixPath(path).parts + or "\\" in path for path in paths): + self.invalid = True + return [{"kind": "invalidate_session", "reason": "unsupported_collector"}] + counts = Counter(paths) + if len(counts) > 1000: + self.invalid = True + return [{"kind": "invalidate_session", "reason": "collection_limit"}] + census = {"kind": "census", "selected": len(current), + "files": dict(sorted(counts.items()))} + if len(json.dumps(census, ensure_ascii=True)) > 65536: + self.invalid = True + return [{"kind": "invalidate_session", "reason": "census_output_limit"}] + self.ids = current + self.cases = {nodeid: (path, 0, None) for nodeid, path in zip(current, paths, strict=True)} + self.files = {path: _File(count) for path, count in counts.items()} + digest = hashlib.sha256() + for nodeid in current: + value = nodeid.encode("utf-8", errors="surrogatepass") + digest.update(len(value).to_bytes(8, "big")) + digest.update(value) + return [{**census, "node_ids_sha256": digest.hexdigest(), + "node_digest_encoding": "length-prefixed-utf8-surrogatepass"}] + + def report(self, nodeid: str, phase: str, duration: float, outcome: str) -> list[dict]: + if self.invalid or self.ids is None: + return [] + case = self.cases.get(nodeid) + if case is None: + self.invalid = True + return [{"kind": "invalidate_session", "reason": "unknown_case"}] + path, seen, setup_outcome = case + timing = self.files[path] + if timing.invalid: + return [] + bit = _PHASES.get(phase) + if (bit is None or seen & bit or seen & _PHASES["teardown"] + or not isinstance(duration, int | float) or isinstance(duration, bool) + or not math.isfinite(duration) or duration < 0 + or outcome not in {"passed", "failed", "skipped"} + or (phase != "setup" and not seen & _PHASES["setup"]) + or (phase == "call" and setup_outcome != "passed") + or (phase == "teardown" and (setup_outcome == "passed") != bool(seen & _PHASES["call"]))): + timing.invalid = True + return [{"kind": "invalidate_file", "file": path, + "reason": "unsupported_or_duplicate_report"}] + self.cases[nodeid] = (path, seen | bit, outcome if phase == "setup" else setup_outcome) + timing.phases[phase] += duration + if not math.isfinite(timing.phases[phase]): + timing.invalid = True + return [{"kind": "invalidate_file", "file": path, "reason": "nonfinite_total"}] + timing.outcomes[outcome] += 1 + if phase != "teardown": + return [] + timing.completed += 1 + if timing.completed != timing.selected: + return [] + return [{"kind": "complete_file", "file": path, "selected": timing.selected, + "completed": timing.completed, "phase_seconds": timing.phases.copy(), + "seconds": math.fsum(timing.phases.values()), + "phase_outcomes": dict(sorted(timing.outcomes.items()))}] + + +class HostedTimings: + """Controller-only observer registered for the existing load-once CI.""" + + def __init__(self, config) -> None: # noqa: ANN001 + self.config = config + self.timings = FileTimings() + self.workers: set[str] = set() + self.disabled = False + + def _emit(self, events: list[dict]) -> None: + if self.disabled: + return + terminal = self.config.pluginmanager.getplugin("terminalreporter") + if terminal is None: + self.disabled = True + return + try: + if events: + terminal.ensure_newline() + if terminal._tw.width_of_current_line != 0: + terminal.write_line("") + for event in events: + payload = {"schema": SCHEMA, "workers_observed": len(self.workers), **event} + terminal.write_line(PREFIX + json.dumps(payload, ensure_ascii=True, allow_nan=False)) + terminal._tw.flush() + except Exception: + # Diagnostic output must not turn passing tests into a failing job. + self.disabled = True + + @pytest.hookimpl(optionalhook=True) + def pytest_xdist_node_collection_finished(self, node, ids) -> None: # noqa: ANN001 + if self.disabled: + return + try: + self.workers.add(node.gateway.id) + self._emit(self.timings.collection(ids)) + except Exception: + self.disabled = True + + def pytest_runtest_logreport(self, report) -> None: # noqa: ANN001 + if self.disabled: + return + try: + self._emit(self.timings.report(report.nodeid, report.when, report.duration, report.outcome)) + except Exception: + self.disabled = True diff --git a/conftest.py b/conftest.py index 9b9132b8e..8ea7f32b9 100644 --- a/conftest.py +++ b/conftest.py @@ -30,6 +30,20 @@ from ci_sharding import load_seconds, shard_assignment # noqa: E402 +def pytest_configure(config) -> None: # noqa: ANN001 + """Stream hosted file timings without affecting test selection or results.""" + if (os.environ.get("GITHUB_ACTIONS") != "true" or hasattr(config, "workerinput") + or config.getoption("collectonly") + or config.getoption("setuponly") + or not config.getoption("numprocesses", default=None) + or config.getoption("dist", default=None) != "load" + or config.pluginmanager.hasplugin("rerunfailures")): + return + from ci_timing import HostedTimings + + config.pluginmanager.register(HostedTimings(config), "shipgate_hosted_timings") + + @pytest.fixture(autouse=True) def _scrub_agent_environment(monkeypatch: pytest.MonkeyPatch) -> None: """Keep the suite hermetic when it runs inside a coding agent. @@ -78,9 +92,16 @@ def _shard_selection() -> tuple[int, int] | None: return count, index +@pytest.hookimpl(tryfirst=True) def pytest_collection_modifyitems(config, items) -> None: # noqa: ANN001 - """Keep only this shard's files when a shard is requested.""" + """Mark the nightly-only framework cases, then keep this shard's files. + + The marks go on before pytest applies ``-m``, which is why this runs first. + """ + + from tests.framework_matrix import mark_nightly_framework_cases + mark_nightly_framework_cases(items) selection = _shard_selection() if selection is None: return diff --git a/docs/distribution-surfaces.md b/docs/distribution-surfaces.md index 3b545031e..504a41d32 100644 --- a/docs/distribution-surfaces.md +++ b/docs/distribution-surfaces.md @@ -177,6 +177,13 @@ provenance and makes no claim about a runtime verdict or qualification. the `emitted_ci_workflow` row above covers how the installed engine uses its record. The top-level classifier records this distinction explicitly. +`ci_sharding.py`, `ci_timing.py` and `conftest.py` are repository test-run +mechanics. The timing observer records selected-file phase durations and +outcomes for CI diagnostics; it publishes no engine answer, release verdict +or adopter-facing command. These modules are classified outside distribution +surfaces, with observer behavior covered by `tests/test_ci_timing.py` and +selection covered by `tests/test_shard_partition.py`. + The main-tree `.well-known/agents-shipgate.json` integration enumeration is checked against `contract --json` from the same source build, including missing and unsupported entries. The site's discovery copy stays pinned to its released diff --git a/docs/engineering/caller-supplied-agent-lists.md b/docs/engineering/caller-supplied-agent-lists.md index 61fa5893e..2bfc61568 100644 --- a/docs/engineering/caller-supplied-agent-lists.md +++ b/docs/engineering/caller-supplied-agent-lists.md @@ -134,6 +134,12 @@ lifecycle hook through an unresolved class owner cannot prove a constructor leaves borrowed lists alone; unrelated namespace metadata retains its existing literal-field distinction. This is static evidence, not execution of a particular installed framework's constructor. +A saved `delattr` primitive remains an explicit constructor-read limit, +including when its receiver is a different source module. Deleting a function +binding is not a same-function write: it can release defaults, annotations, +function metadata or weak-reference callbacks. This reader has no deletion +ownership proof and does not treat a foreign receiver as one. Supported source +slot writes and mapping mutations do not imply deletion support. Class returns, conditional or multiple returns, opaque producers and unknown receiver writes to any capability field withhold constructor reads. Reflective access and ambiguous aliases retain their limits. No generic factory result @@ -149,12 +155,329 @@ returned agent handle also keeps that handle incomplete. Computed access, ambiguous imports and dynamic import machinery are explicit limits. Known direct callers remain partial evidence beside unresolved callers. -Foreign caller-local tool closures, list/dictionary-returning factories and -`self.tools` remain unread for subsequent #874 increments. ADK's followed-list +Foreign caller-local tool closures and `self.tools` remain unread for +subsequent #874 increments. ADK's followed-list observations retain the existing distinction between comparable application bindings and trusted scan coverage; this change creates no authority, effect or agent-binding declarations. +## Returned tool lists and dictionaries + +The next increment follows synchronous repository-local factories with one +final return. It uses the same argument binding and caller census as agent +builders, and the same list-expression membership reader. The supported prelude +is limited to imports, a docstring and `pass`; executable statements, nested +definitions and unresolved returned expressions remain unread. Literal list/tuple +returns of direct names and literal-string-key projections of returned literal dictionaries +are supported, including `groups["calendar"] + groups["email"]` and +`groups.get("memory")`. A missing subscription key is a limit, not an empty +list. Dictionary spreads, nonliteral keys and duplicate keys are limits. + +A `.get` default is evaluated eagerly even when the key exists. This entry +accepts only `None`, literal lists/tuples of established function references, +or an established function reference as that default; executable and unresolved +defaults remain unread. A selected caller default retains its caller scope. +Factory invocations contribute their own source locations and membership +conditions; inherited caller conditions are recorded once. + +This bounded entry proves ownership of fresh returned containers. It checks the +factory callable census, all eagerly evaluated member-name bindings, canonical +returned-callable identities across their +bounded borrower modules, and every retained container use, including projected +values at every established invocation. An opaque result from a different call +may retain the same functions and their globals, so it makes the selected +call incomplete too. The factory's own namespace is checked even when the +selected projection is empty: an opaque helper or class carrying its globals +cannot establish that a removed binding stays absent. Clean callers keep their own provenance. Caller and sibling aliases and retaining bridge/package namespaces +remain subject to the same ownership checks. Functions and class methods in a retaining module also carry its globals, so +opaque uses of those namespace holders stay unread through bounded re-exports. +Class values and constructors remain unread by this entry. This namespace mode has its own census cache; the +ordinary shared-list census keeps its narrower import rules. Executable text and +builtin execution machinery in a relevant borrower's import dependencies stay +unread, using the resolver's existing module and import-step evidence. Patching or escaping the factory, renaming or +handing on a returned callable, mutation, aliases, opaque consumers, dictionary +views/copies/unions, starred calls and helper identity changes retain named +limits. Strict helper proofs follow projections and wrappers instead of ending +at indexing. Copies retain callable objects even when their list membership is +fresh, so unknown uses of copied members remain incomplete. Equality, ordering, +membership tests, opaque addition operands and comparator arguments are unread; +identity tests and additions with established literal container shapes remain +readable. A retained dictionary projection can supply an addition operand only +from the same binding already under that ownership walk; unrelated retained +dictionaries stay unread. Fresh factory shapes require the same callable census +and whole-call binding proof. A builtin-shaped list's zero-argument `.copy()` +follows every use of the new container; copied members retain their namespace +obligations. Unshadowed `print(*TOOLS)` supports one starred container and no +other arguments. Arbitrary starred consumers remain unread. Other builtin +readers accept only a single container argument, so custom +`isinstance` metaclass dispatch cannot acquire the list through this route. + +Imported dictionaries and module-owned literal dictionary projections remain +unread. Returned members must be direct names in the factory's lexical module; +foreign returned-list members, dotted/wrapper members, nested tool closures, +foreign target-agent surfaces and runtime runner retention remain subsequent +increments. This entry does not establish Vesta acceptance or complete #874. + +## Constructor identity and retained namespaces + +Membership evidence is comparable only when the framework constructor is still +established. The SDK and ADK readers check its exact lexical framework import. +Repeated unconditional imports in that lexical body are accepted only when +every binding establishes the same supported canonical path and provider. +They follow repository-local re-exports and the import dependencies of its +callers to discover patches and retained namespaces; constructing through an +arbitrary re-export is not a supported positive. A local +module or regular package providing any prefix of the framework import cannot +prove the external class. An exact external SDK root import does not borrow +an application's namespace-only `agents/` or `openai_agents/` folder; local +providers and explicit child imports keep their ordinary checks. +Conditional imports, shadowing, class or namespace +patches, saved aliases, reflective access and unread import dependencies retain +named limits. The constructor's metadata, `__init__` and `__new__` belong to the +same proof. SDK `function_tool` identity is checked too. + +The protected identity set also covers inherited construction machinery: +ADK's BaseAgent, BaseNode and BaseTool, SDK's AgentBase and Generic, and the +Pydantic/ABC classes and schema helpers used by supported construction routes. +Known external reexports and repository-local carriers keep the same mutation, +opaque-retention and module-table checks. Attribute writes beneath the current +framework or its dependency roots remain unread even for an unenumerated helper. +Local dependency providers are checked in every bounded caller and import +context, even without an application-side import; the framework itself imports +those dependencies. A sibling provider beside a caller cannot borrow a +repository-root builder's provider proof. Startup-preloaded standard +modules keep their existing provider boundary. +Unused imports retain their existing boundary. These dependencies never +become supported Agent or tool receiving classes. The dependency policy is +conservative across known framework namespaces, including empty constructors; refusing a +dependency change does not assert that this particular call executed it. +An enclosing package or its transitive import closure above the selected scope +that imports a protected canonical namespace retains a named constructor +ownership limit, including unused imports and ancestor namespaces bound by a +dotted import without an alias. Its repository-relative AST cannot borrow an +in-scope ownership proof. Empty initialization and ordinary unrelated imports +keep their existing boundary. +Pydantic argument-model instance construction during a later tool call is not +reported as eager decorator execution. See the pinned +[ADK inheritance source](https://github.com/google/adk-python/blob/v2.4.0/src/google/adk/agents/llm_agent.py) +and [SDK base classes](https://github.com/openai/openai-agents-python/blob/v0.17.2/src/agents/agent.py). + +Protected namespaces include all known SDK and ADK framework roots; +the canonical Pydantic, typing, typing-extensions, abc and `_collections_abc` +namespaces, whose functions and classes carry shared ABC metaclass state; and ADK's Google +Gen AI namespace. These namespaces expose mutable class and schema-reader +aliases through public and private modules, so the boundary covers those +carriers without assuming a list of spelling aliases is exhaustive. +Annotation readers and SDK Field metadata use the +same boundary, including their real reexports and SDK Griffe documentation +parsers. An opaque consumer of such a +helper cannot establish the original name or schema. Unrecognized calls through +these internal modules remain unread, including internal `cast` calls and eager +framework-export annotations. Exact canonical public typing values such as +`typing.Any`, `typing.List` and `typing.Literal` retain their annotation-only +role; this permits no call, retained alias, metadata read or opaque export of +the type. Tested deferred type-value annotations and +public ADK `pydantic.Field(default=None)` / `pydantic.fields.Field(default=None)` +calls keep their existing behavior when the result is unused, discarded or only +compared by identity. A returned result must retain that ownership through every +known caller. Exporting the result, reading metadata, invoking its methods or +passing it to another consumer remains unread, as does retaining the Field +function or supplying a callback. Other Field-returning helper calls can retain +a named helper limit. Eager typing-extensions annotations retain the namespace limit; +deferred annotations retain their existing behavior. Supported exact +Agent, wrapper and SDK decorator paths retain only their existing owner routes. +A decorator from a different SDK root cannot borrow the selected constructor's +owner route, even when both imports use supported framework spellings. + +Two ADK value roles preserve the existing static extraction routes. An exact +canonical `ToolContext` from `google.adk.tools` or +`google.adk.tools.tool_context` may be the complete parameter annotation of an +ADK tool; calls, aliases, metadata, containers and foreign SDK annotations do +not gain that role. Exact MCP and OpenAPI toolset calls accept only the finite +literal configuration and local artifact forms the reader understands. Their +results may enter a literal list or tuple supplied to the actual ADK Agent's +`tools` field, with the complete owner-use census still required. They gain no +method, getter, index, callback, export or receiving-class permission. Static +artifact hints identify files to read; they do not validate a runtime ADK +constructor signature. The exact `Path("literal").read_text()` spec form +requires the canonical Path provider and grants no other Path result API. +ADK provider checks also include `mcp`, `json` and `yaml` in every bounded +caller and import context: local providers can run before toolset construction +and alter the shared construction dependencies. + +Only an actual direct `if TYPE_CHECKING:` or `if typing.TYPE_CHECKING:` guard +can establish a runtime-false body. The guard must resolve in its evaluation +scope to one unconditional canonical `typing` or `typing_extensions` import, +with its provider proved. Parameters, local/class/closure/nonlocal shadows, +mixed or conditional imports, wildcard imports and flag mutations retain the +executable import edges. Negated or compared flags grant no false-body role. +Import edges, recorded attribute/table stores, caller/reflection checks and +the primary constructor namespace walks use the same proved-dead node set; lexical +binding censuses keep every binding, because even a dead-branch assignment can +make a name local. The false boolean role grants no typing-object retention. +Above a scoped read, a canonical typing import can carry only these proved +boolean uses. An unused, shadowed or mixed import, or any retained typing +namespace, keeps the protected-import limit. + +A separate finite primary-stdlib table protects direct ABC reexports and +source-proved ABC ancestry, including `collections.abc`, `contextlib.abc`, +`numbers.ABCMeta`, `io.abc`, `dataclasses.abc`, `os.abc`, and their ancestor +namespace handles. It also covers the listed classes in contextlib, collections, +io, selectors and the other named primary modules. Ordinary sibling calls such +as `os.getenv`, `os.path.join`, `io.StringIO`, `pathlib.Path` and builtin-backed +collections containers retain their existing boundary; the table grants no +receiving-class or getter-result authority. + +An unread external imported handle used as a value or mutation target retains +a named ownership limit. Its private tag claims no canonical object identity, +and the obligation follows local reexports, namespace carriers and aliases. +Actual imported candidates remain obligations through conditional imports, +mixed bindings and a saved alias whose original name was later rebound. This +does not give the ambiguous name an identity or replace an ordinary lexical +data binding with a same-named outer import. +For example, handing `os.getenv` to an opaque consumer cannot borrow the +ordinary call's boundary: the function can carry shared ABC globals. Deferred +annotations keep their existing treatment. Ordinary external callees are not +executed or inspected, and their returned object graphs are outside this proof. +A plain synchronous source-local helper can preserve that ordinary call-result +boundary through an exact stable absolute-import callee, or a bounded sequence +of method calls directly on those call expressions. The full projected name +must remain outside the protected construction/dependency paths; local +providers, relative imports, mixed/rebound imports and stopped resolutions do +not gain the role. Saved results, properties, indexing, callable results, +parameters, generators, asynchronous helpers and reflective method calls do +not gain it either. Every +argument, namespace and other use remains checked. This permits the call only; +it neither proves the result graph nor registers a receiving owner route. +The enclosing-package check evaluates the same handle roles against its +repository snapshot in a separate bounded path namespace. Its reads never fall +back to the live checkout. A constructor, container or decorator ownership route +there remains a named limit: the in-scope list reader cannot finish that separate +ownership census. Plain local data and ordinary external calls retain their +existing boundary. +Every expanded import candidate is checked, including alternate providers, +links, unread blobs and package search-path changes. Discovered executable +modules inside the selected scope return to the original scope's import and +ownership census. A namespace portion alone cannot verify an otherwise unread +external imported handle; an exact literal in a known repository child module +keeps its ordinary data role. +This finite source/import boundary does not establish exhaustive stdlib heap +ownership, arbitrary third-party wiring or runtime safety. +Missing above-scope relative modules retain a named ownership limit even under +an ImportError guard. Readable literal version metadata can close that limit; +code that changes a shared constructor keeps it open. Reflective imports, +class-construction hooks and retained generators remain distinct limits. +Likewise, a reviewed inventory retires unresolved-symbol warnings, but cannot +prove the ownership of an unread imported object. That constructor warning and +binding evidence gap remain separate from the inventory repair. The ownership +gap names the actual constructor source and requests readable supported source +or removal of the opaque route. It offers no binding declaration template: +repeating a complete binding declaration cannot resolve that gap. + +A foreign framework's functions, classes and modules can carry the same mutable +dependencies in their globals or ancestry. Opaque consumers remain unread even +when the active agent belongs to another framework, and mixed framework +constructions can retain that conservative limit. The narrow unrelated-write +exception covers only a single ordinary attribute assignment, with a stable +module-level import explicitly naming the framework and resolving the exact foreign class: SDK AgentBase's +`__new__` / `__setattr__` for an ADK read, or ADK BaseAgent's `__init__` / +`__new__` for an SDK read. Its imports must also prove the foreign dependency +providers. Ancestor imports such as `import google`, local reexported class +imports, deletion, augmented assignment, aliases, inherited method-object +mutation, metadata reads and opaque retention do not gain that exception; the +assigned value is checked independently. + +Every constructed agent retains its class, including agents with empty tools. +Functions in a retaining module carry its globals. An unknown consumer of an +agent, a local callback, class method, returned container or projected member +can therefore invalidate another construction in that namespace. The reader +checks the complete bounded owner census before accepting any binding. A +constructor gap makes candidate tools incomplete and marks their binding +evidence uncertain; it cannot establish an addition, implementation change or +removal. Sub-agent and handoff targets whose constructors are uncertain are +unresolved too. + +Readable members preserve their names, definitions and candidate directions +beside the constructor limit. An unresolved mutable named list is not enumerated +by this entry and can instead publish only the list and constructor gaps. +Original callable/import diagnostics remain beside constructor diagnostics. +An unresolved repository-local plain function can keep its binding gap scoped +to its receiving agent. An unread external object handed to an Agent or +FunctionTool retains a constructor ownership limit for other constructions in +the same namespace: its callable globals, ancestry and metadata getters have +not been read. The other agents' readable candidate changes and definitions +remain visible beside that limit. +Free-form output findings remain available for readable possible bindings, +with unknown binding evidence; they do not put those tools in the reachable +inventory or establish their action authority. + +Frame-inspection namespaces, including an inspector handed to an opaque +consumer, remain unread. A lexically bound data name `stack` is not +`inspect.stack`, and a function-local or formal `vars` shadows the builtin; +actual inspector imports, method aliases and saved builtin references keep +their refusal. The exact immediate `score = FunctionTool(func=score)` following +an undecorated same-module definition reads the prior function, then follows +every use of the actual wrapper handle. Aliases, intervening statements and +opaque wrapper consumers do not get that exception. + +Eager defaults, annotations and a comprehension's first iterable use their +outer evaluation scope. The binding census also records eager-header stores +and comprehension walruses in that scope, as uncertain bindings rather than +plain assignments. Comprehension iteration targets, lambda bodies and ordinary +local/class cells keep their lexical scope. A generic definition's type-parameter +cell can shadow an imported constructor, including in a method. Constructor +calls in generic annotations, lazy type-parameter bounds and constraints, or +deferred type-alias values remain unread. Postponed annotations and unevaluated +function-local variable annotations also cannot establish constructors; +defaults and decorators still use their outer scope. See +[Python's annotation scopes](https://docs.python.org/3.12/reference/executionmodel.html#annotation-scopes) +[assignment-expression scope](https://peps.python.org/pep-0572/#scope-of-the-target), +and [variable annotations](https://docs.python.org/3.12/reference/simple_stmts.html#annotated-assignment-statements). +Opaque decorators receive functions implicitly. The +unchanged SDK `function_tool` decorator, bare or with constant keyword values, +is supported. Its named `is_enabled` callback must resolve to an undecorated +function, and every use of the resulting tool handle must remain owned. The +six named ADK before/after agent, model and tool callbacks use the same actual +Agent receiving-handle proof. Other decorators remain unread. Class decorators, metaclasses, +base-class hooks and descriptor `__set_name__` calls can receive methods with +the same globals. Plain undecorated classes with no base or an unshadowed +builtin `object` base remain readable when their bodies contain only plain +method definitions, literal assignments, docstrings and `pass`, and their later +uses do not escape. Other class construction shapes retain a named limit. + +Canonical SDK execution consumers remain a precision limit in this increment: +`await Runner.run(agent, ...)` and `Runner.run_sync(agent, ...)`, including an +`asyncio.run(main())` dispatcher, do not establish constructor ownership. The +reader has no separate proof of source-declared wiring before the execution +entry and does not assume that Runner leaves retained capability handles +unchanged. Readable tool candidates and handoff edges remain in the report; +an incomplete comparison must not publish their disappearance as a removal. +This is a known unsupported form, not evidence that the application is unsafe. + +This deliberately narrows several former static positives. Anonymous lambda +and generator carriers, eager framework-class annotations, imported wrapper +instances, opaque handoff wrappers and unproved list mutations carrying callbacks +stay unread until their retained handles are followed explicitly. Readable +candidate names and membership conditions remain evidence beside that limit; their +presence does not make the binding established. Dynamic import or builtin +execution machinery in a relevant dependency also prevents constructor proof. +Replacing the module's `__builtins__` namespace prevents lexical builtin +proofs too, including imports, definitions or pattern captures bound under that +special name and a function's explicit global rebinding. Ordinary local and +class bindings keep their lexical scope. +No application code is imported or run to recover these cases. + +Constructor identity and list membership are separate proofs. Discarded +`append`, `insert`, `extend`, `clear` and `reverse` calls on a proven builtin +list, or replacement of an owned capability field with another such list, can +preserve contained callable objects. The receiver's source, replacement +history and every downstream handle use must be read; method spelling alone +proves nothing. Ordinary membership evidence still names the changed list as +incomplete. Imported literal lists use the bounded shared-list borrower census +and strict member ownership, including namespace, function and class carriers, +reexports and their forward dependencies. Indexing, iteration, opaque consumers, +custom receivers, metadata and unfinished ownership still retain named limits. + ## Read bounds and evidence One index has at most 2,000 Python files, 100,000 directory entries, 32 MiB of @@ -162,6 +485,11 @@ source text and 32 candidate modules. Alias expansion has four rounds, forwarding four caller levels and construction expansion 128 contexts. Exceeding any bound produces a named limit. +The constructor owner census has at most 1,024 owners, 128 active retained +edges and 4,096 ownership traversals. Completed proofs are memoized within that +census; adding an owner invalidates the completed-owner cache. A recursive or +unfinished proof never becomes complete through a cache hit. + The census reads file bytes and directory listings through the static input snapshot. Noncandidate Python files are evidence too: changing one can introduce a caller. New files, removed files, selected import dependencies and virtualenv diff --git a/docs/quickstart.md b/docs/quickstart.md index d6ed622cb..399a8480f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -521,7 +521,10 @@ absolute directory. ### 3. What changed Open `pr-comment.md` — the same text the GitHub Action posts on a pull -request. It leads with the capability delta, by subject: +request. The published `v1.2.0` release leads with the capability delta, +by subject. The current source tree still reports what was added or changed +when agent binding coverage is incomplete, withholds what was removed or +narrowed, and names that limit: ```text - Capability delta (analysed surface): 2 subjects across 6 changes (+1 added, 2 modified, -0 removed) @@ -569,7 +572,9 @@ effect evidence are compatible answers; see [effect projections and evidence](effect-evidence.md) for the existing JSON fields and review meaning. `report.md` carries its own coverage limit, and that limit is part of the -answer: +answer. The published `v1.2.0` release renders this excerpt; the current +source tree can report different reachability and pass-eligibility counts +when binding coverage is incomplete. Read the counts from the run you made: ```text Evidence coverage: static (2/2 catalog tools reachable; 1 semantic review diff --git a/pyproject.toml b/pyproject.toml index d90d0fedc..6a9eecf33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -188,7 +188,7 @@ pythonpath = ["src", "."] addopts = "-q" markers = [ "perf: latency-budget / benchmark tests. Skip with `pytest -m 'not perf'`.", - "slow: tests kept off the per-PR path. Pull-request CI runs `-m 'not perf and not slow'`; the nightly Slow Tests workflow and release verification run them. Skip with `pytest -m 'not slow'`.", + "slow: tests kept off the per-PR path, such as the redundant framework-matrix cases (tests/framework_matrix.py). Pull-request CI runs `-m 'not perf and not slow'`; the nightly Slow Tests workflow and release verification run them. Skip with `pytest -m 'not slow'`.", ] [tool.ruff] diff --git a/samples/large_multi_framework_agent/README.md b/samples/large_multi_framework_agent/README.md index fedb19674..ce2afa8ff 100644 --- a/samples/large_multi_framework_agent/README.md +++ b/samples/large_multi_framework_agent/README.md @@ -9,8 +9,10 @@ risk-enrichment, and release-decision paths under realistic load. ## What it scans Six tool sources, all loaded statically. The reviewed inventory overlaps the -five SDK functions intentionally: it proves the complete binding and semantic -surface while the Python source still exercises conservative AST extraction. +five SDK functions intentionally: it supplies reviewed interface and semantic +facts while the Python source exercises conservative AST extraction. Reviewed +tool declarations do not establish the application's constructor identity or +prove which tools its agent can reach. | Source | Adapter | Tools | Risk shape | | ---------------------------------------- | -------------------- | ----- | -------------------------------------------------------------- | @@ -19,46 +21,39 @@ surface while the Python source still exercises conservative AST extraction. | [`mcp/crm-tools.json`](mcp/crm-tools.json) | `mcp` | 15 | Customer comms (email/sms/in-app) + GDPR compliance ops. | | [`mcp/internal-tools.json`](mcp/internal-tools.json) | `mcp` | 10 | Warehouse inventory reads/writes + admin (`drain_warehouse`). | | [`agents/ops_assistant.py`](agents/ops_assistant.py) | `openai_agents_sdk` | 5 | SDK function tools: previews, computations, escalation. | -| [`inventories/ops-sdk-tools.json`](inventories/ops-sdk-tools.json) | `mcp` | 5 | Reviewed inventory for the same bound SDK tools; closes AST-only completeness gaps. | +| [`inventories/ops-sdk-tools.json`](inventories/ops-sdk-tools.json) | `mcp` | 5 | Reviewed declarations for the same SDK tool candidates; binding still requires source evidence. | ## What it intentionally exercises -The manifest declares **partial** governance coverage so the scan surfaces a -realistic mix of findings rather than a clean pass: +The manifest declares partial governance coverage. It includes approval, +confirmation and idempotency policies, permission scopes, severity/risk +overrides, and suppressions so the pipeline can inspect these declarations +alongside the catalog and binding evidence. -- **Approval policy** covers 5 of the ~10 tools that earn approval-required risk - tags. The other ~5 fire `SHIP-POLICY-APPROVAL-MISSING` at critical severity. -- **Confirmation policy** covers shipping cancellations and external comms but - not subscription cancels or destructive customer-data ops. -- **Idempotency policy** covers `create_charge` / `create_refund` / - `create_shipment` but not `internal.reserve_inventory` or - `internal.adjust_inventory`. -- **`permissions.scopes`** lists ~18 scopes but is missing several - destructive-admin scopes (e.g. `payments:customers:admin`, - `crm:customers:admin`, `inventory:admin`) so `SHIP-AUTH-SCOPE-COVERAGE-MISSING` - fires for each uncovered tool. -- **Severity override**: one `SHIP-DOC-INJECTION-RISK` downgrade with a reason - exercises the `policy_audit.severity_overrides_applied` envelope. -- **Risk overrides**: three manual hints (two downgrades, one owner attribution) - exercise the manual-risk path. -- **Suppressions**: two `SHIP-DOC-MISSING-DESCRIPTION` ignores exercise the - `manifest_consistency` check at scale. +The current Python read cannot establish constructor-namespace ownership +through the anonymous generator at `agents/ops_assistant.py:40`. +The graph contains **five possible SDK tools and zero established reachable +tools**. The reviewed inventory cannot clear that source-level uncertainty. +Catalog entries without established binding do not create approval or scope +coverage findings; unused declared scopes can still be reported. Interface +findings for possible tools carry their unknown binding status. -The release decision is **blocked** by ~10 critical findings; review items run -into the 70s. This is intentional — a clean sample wouldn't exercise the gate. +The release decision is **insufficient_evidence**. This sample exercises a +large catalog, deterministic merge receipts, audit metadata and conservative +binding limits; it does not demonstrate a complete application binding or a +blocked runtime capability. ## Why no committed goldens Most samples ship `expected/report.md` and `expected/report.json` so a golden test catches rendering drift. This one **doesn't**, on purpose: the goal is to exercise the pipeline at scale, not to pin every line of output. Pinning -50+ findings × 20+ report sections through every schema bump (the schema -moves several minor versions per release window) would be high-cost, -low-signal regression noise. +every finding and report section through output evolution would require +updates unrelated to this sample's binding and scale guarantees. Instead, [`tests/test_large_sample.py`](../../tests/test_large_sample.py) -asserts the **structural** shape — decision, finding count band, key rule -firings — and enforces a **latency budget** so the gate stays fast on the CI +asserts the **structural** shape — decision, possible binding identities, +source uncertainty, finding count bands and audit metadata — and enforces a **latency budget** so the gate stays fast on the CI critical path. ## Running locally diff --git a/scripts/measure_shard_seconds.py b/scripts/measure_shard_seconds.py index aacd34d2f..ab80ca46f 100644 --- a/scripts/measure_shard_seconds.py +++ b/scripts/measure_shard_seconds.py @@ -20,6 +20,7 @@ from __future__ import annotations import argparse +import hashlib import json import subprocess import sys @@ -31,11 +32,12 @@ OUTPUT = REPO_ROOT / "tests" / "shard_seconds.json" -def file_seconds(report: Path, root: Path = REPO_ROOT) -> dict[str, float]: - """Seconds per test file: each test case's setup, call and teardown summed.""" +def file_seconds(report: Path, root: Path = REPO_ROOT, *, report_bytes: bytes | None = None) -> dict[str, float]: + """Sum each mapped JUnit testcase time per test file.""" totals: dict[str, float] = defaultdict(float) - for case in ET.parse(report).getroot().iter("testcase"): + tree = ET.parse(report).getroot() if report_bytes is None else ET.fromstring(report_bytes) + for case in tree.iter("testcase"): name = case.get("file") or _file_from_classname(case.get("classname", ""), root) if name: totals[name] += float(case.get("time") or 0.0) @@ -63,7 +65,8 @@ def main(argv: list[str] | None = None) -> int: help="the checkout the report was made in (default: this one)", ) args = parser.parse_args(argv) - files = file_seconds(args.report, args.root.resolve()) + report_bytes = args.report.read_bytes() + files = file_seconds(args.report, args.root.resolve(), report_bytes=report_bytes) if not files: print(f"{args.report} holds no test cases", file=sys.stderr) return 1 @@ -74,7 +77,13 @@ def main(argv: list[str] | None = None) -> int: text=True, check=False, ).stdout.strip() - payload = {"measured_at": commit or None, "files": files} + payload = {"measured_at": commit or None, "files": files, "measurement": { + "source": "junit", + "report_sha256": hashlib.sha256(report_bytes).hexdigest(), + "case_count": sum(1 for _ in ET.fromstring(report_bytes).iter("testcase")), + "file_count": len(files), + "time_basis": "JUnit testcase time; phase accounting is selected by the runner", + }} OUTPUT.write_text(json.dumps(payload, indent=1, sort_keys=True) + "\n", encoding="utf-8") print(f"wrote {len(files)} files, {sum(files.values()):.0f}s in total, to {OUTPUT}") return 0 diff --git a/src/agents_shipgate/checks/schema.py b/src/agents_shipgate/checks/schema.py index f334cbcf9..103983525 100644 --- a/src/agents_shipgate/checks/schema.py +++ b/src/agents_shipgate/checks/schema.py @@ -15,7 +15,12 @@ def run(context: ScanContext): findings = [] - for tool in context.tools: + # A named possible binding preserves interface evidence while wiring is + # unresolved. Proven unbound catalog entries stay outside this check. + reachable = {tool.id for tool in context.tools} + possible = set(context.binding_graph.possible_tool_ids) if context.binding_graph is not None else set() + interfaces = [tool for tool in context.tool_catalog if tool.id in reachable | possible] or context.tools + for tool in interfaces: if _has_freeform_output(tool): findings.append( tool_finding( @@ -24,7 +29,13 @@ def run(context: ScanContext): title=f"{tool.name} returns free-form text output", severity="medium", category="schema", - evidence={"output_schema": tool.output_schema or {"type": "string"}}, + evidence={ + "output_schema": tool.output_schema or {"type": "string"}, + **({"binding_status": tool.binding_assessment.status, + "binding_established": False} + if tool.binding_assessment is not None + and tool.binding_assessment.status == "unknown" else {}), + }, # The judged schema may have been preserved from a bound # member, in which case the reviewer needs the artifact that # declares it, not the primary's (#386 review). @@ -38,6 +49,7 @@ def run(context: ScanContext): provenance_kind="static_declaration", ) ) + for tool in context.tools: for parameter in tool.parameters: if _is_broad_free_text(parameter) and _action_like_tool(tool): findings.append( diff --git a/src/agents_shipgate/ci/release_decision.py b/src/agents_shipgate/ci/release_decision.py index 51aa385be..8672eb879 100644 --- a/src/agents_shipgate/ci/release_decision.py +++ b/src/agents_shipgate/ci/release_decision.py @@ -11,7 +11,11 @@ effective_fail_on, exit_code_for_report, ) -from agents_shipgate.core.agent_bindings import TOOL_SOURCE_BINDING_DECLARATION +from agents_shipgate.core.agent_bindings import ( + FRAMEWORK_CONSTRUCTOR_OWNERSHIP, + TOOL_SOURCE_BINDING_DECLARATION, +) +from agents_shipgate.core.binding_comparison import comparison_withholds_absence from agents_shipgate.core.control_packs import is_mandatory_current_control from agents_shipgate.core.declaration_questions import ( ANSWERABLE_ISSUE_KINDS, @@ -668,6 +672,9 @@ def _binding_declaration_template( for source_id in source_ids ] } + if issue.source == FRAMEWORK_CONSTRUCTOR_OWNERSHIP: + # A declaration cannot establish the constructor or its dependencies. + return None if issue.source == TOOL_SOURCE_BINDING_DECLARATION: # A reviewed source binding that reached no tool. It is raised against # the surface node, which is the graph's root whenever it is the only @@ -897,6 +904,16 @@ def _agent_subject(issue: AgentBindingIssue) -> str: "tools, or remove the binding declaration, then rerun " "verification." ) + elif issue.source == FRAMEWORK_CONSTRUCTOR_OWNERSHIP: + action_kind = "provide_source" + path = issue.source_pointer + accepted_values = [] + expects = ( + "Provide readable supported source for the constructor's " + "unread ownership route, or remove that route, then rerun " + "verification. A tool inventory or binding declaration " + "cannot establish constructor ownership." + ) elif issue.kind in {"missing_binding_evidence", "unresolved_bound_tool"}: action_kind = "declare_agent_bindings" path = "shipgate.yaml#agent_bindings.declarations" @@ -953,6 +970,8 @@ def _agent_subject(issue: AgentBindingIssue) -> str: command=_SEMANTIC_RERUN_COMMAND, path=path, why=( + "The constructor and its ownership route must be established before this can pass." + if issue.source == FRAMEWORK_CONSTRUCTOR_OWNERSHIP else "Every tool has to be traceable to an agent that can " "call it before this can pass." ), @@ -1132,6 +1151,18 @@ def _newly_excluded_tool_gaps(report: ReadinessReport) -> list[EvidenceGap]: diff = report.binding_surface_diff graph = report.binding_surface_facts + # Existing persisted coverage fields keep this route in the frozen 1.0 + # report. A requested comparison with neither graph nor tool comparison + # established stays incomplete even when no tool id could be named. + # The tool comparison alone can be enabled and still withhold what depends + # on absence while a binding graph is incomplete; its notes say so, and so + # do the binding diff's. Either keeps the requested comparison unproven. + if diff.base_comparison_requested and not diff.enabled and ( + not report.tool_surface_diff.enabled + or comparison_withholds_absence(report.tool_surface_diff.notes) + or comparison_withholds_absence(diff.notes) + ): + return [_incomplete_binding_comparison_gap(report)] if not diff.enabled: # A comparison was asked for and could not be performed, so "this tool # was already excluded before the change" is a claim about evidence @@ -1195,6 +1226,29 @@ def _newly_excluded_tool_gaps(report: ReadinessReport) -> list[EvidenceGap]: return gaps +def _incomplete_binding_comparison_gap(report: ReadinessReport) -> EvidenceGap: + return EvidenceGap( + kind="missing_binding_evidence", + subject=unavailable_base_subject(report), + source_ref="--diff-from", + why=( + "The requested capability comparison has incomplete binding evidence " + "on the head or base. It cannot establish removals, narrowings or " + "whether an excluded capability predates this change." + ), + next_action=EvidenceGapAction( + kind="provide_source", + path="--diff-from", + why="Both compared binding surfaces need readable evidence.", + expects=( + "Resolve the named head/base binding coverage gaps; regenerate " + "the base report in its source workspace and rerun the head " + "comparison with --diff-from. Keep the requested comparison." + ), + ), + ) + + def _unavailable_base_gap(report: ReadinessReport) -> EvidenceGap: excluded = len(report.binding_surface_facts.unbound_tool_ids) + len( report.binding_surface_facts.possible_tool_ids @@ -1730,6 +1784,16 @@ def _semantic_gap( ] action_why = "Free-form source labels are not evidence of policy eligibility." expects = "Use a typed first-party evidence producer and rerun verification." + elif kind == "partial_binding_evidence" and issue_source == FRAMEWORK_CONSTRUCTOR_OWNERSHIP: + action_kind = "provide_source" + accepted_values = [] + action_why = "The constructor and its ownership route must be established before this can pass." + expects = ( + "Provide readable supported source for the constructor's unread " + "ownership route, or remove that route, then rerun verification. " + "A tool inventory or binding declaration cannot establish " + "constructor ownership." + ) elif kind in { "missing_binding_evidence", "partial_binding_evidence", @@ -2216,7 +2280,8 @@ def _semantic_gap( next_action=EvidenceGapAction( kind=action_kind, # type: ignore[arg-type] command=_SEMANTIC_RERUN_COMMAND, - path=_semantic_gap_path(kind, tool, issue_source), + path=(source_ref if kind == "partial_binding_evidence" and issue_source == FRAMEWORK_CONSTRUCTOR_OWNERSHIP + else _semantic_gap_path(kind, tool, issue_source)), why=action_why, # Decided by what the template says, never by which branch above # built it. The two are the same judgement — "did the scan fill diff --git a/src/agents_shipgate/cli/application_diff.py b/src/agents_shipgate/cli/application_diff.py index 358527f37..1a21a389a 100644 --- a/src/agents_shipgate/cli/application_diff.py +++ b/src/agents_shipgate/cli/application_diff.py @@ -1106,6 +1106,16 @@ def _observe_source(result: Observations, root: Path, source: ToolSourceConfig) if source in ambiguous_agents or target in ambiguous_agents: continue key = (*source, f"handoff:{target[0]}:{target[1]}") + if not edge.complete: + # A readable candidate survives an incomplete construction. Its + # uncertainty belongs to this binding even when both sides list + # the same handoff, so the comparator cannot call it unchanged. + result.gap( + "This handoff binding was read from an incomplete construction.", + source=source[0], + agent=source[1], + tool=key[2], + ) result.bindings[key] = { "agent": source[1], "agent_source": source[0], diff --git a/src/agents_shipgate/cli/scan/sanitization.py b/src/agents_shipgate/cli/scan/sanitization.py index c348c7b6e..6980481e4 100644 --- a/src/agents_shipgate/cli/scan/sanitization.py +++ b/src/agents_shipgate/cli/scan/sanitization.py @@ -15,6 +15,7 @@ baseline_resolved_fingerprints, verify_baseline, ) +from agents_shipgate.core.binding_comparison import binding_comparison_limits from agents_shipgate.core.domain import Agent, LoadedToolSource, SourceSurfaceOmission from agents_shipgate.core.errors import InputParseError from agents_shipgate.core.findings.identity import assign_finding_ids @@ -187,7 +188,11 @@ def _sanitize_for_output( stats=privacy_stats, path="binding_surface_facts", ) - base_binding = diffs.diff_reference.binding_facts if diffs.diff_reference else None + raw_base_binding = diffs.diff_reference.binding_facts if diffs.diff_reference else None + base_binding = sanitize_model( + raw_base_binding, AgentBindingGraphAssessment, stats=privacy_stats, + path="binding_surface_diff.base.facts", + ) if raw_base_binding is not None else None verification = decision.context.verification # Asked for, by any route: a reference loaded cleanly, a reference was # supplied and failed to parse, or verify resolved a base ref and could not @@ -202,7 +207,17 @@ def _sanitize_for_output( or diffs.diff_reference_error is not None or bool(verification is not None and verification.base_comparison_unavailable) ) - if base_binding is None: + comparison_limits = binding_comparison_limits(public_binding_graph, base_binding) + if base_comparison_requested and comparison_limits: + public_binding_diff = BindingSurfaceDiff( + enabled=False, + base_comparison_requested=True, + base_report_schema_version=( + diffs.diff_reference.report_schema_version if diffs.diff_reference else None + ), + notes=comparison_limits, + ) + elif base_binding is None: public_binding_diff = BindingSurfaceDiff( enabled=False, base_comparison_requested=base_comparison_requested, @@ -402,6 +417,7 @@ def _sanitize_for_output( public_manifest=public_manifest, public_tools=public_tools, public_findings=public_findings, + public_binding_graph=public_binding_graph, public_api_artifacts=public_api_artifacts, public_anthropic_artifacts=public_anthropic_artifacts, public_diff_reference=public_diff_reference, @@ -616,6 +632,7 @@ def _public_tool_surfaces( public_manifest: AgentsShipgateManifest, public_tools: list, public_findings: list, + public_binding_graph: AgentBindingGraphAssessment, public_api_artifacts: OpenAIApiArtifacts | None, public_anthropic_artifacts: AnthropicArtifacts | None, public_diff_reference, @@ -682,6 +699,7 @@ def _public_tool_surfaces( public_diff_reference.facts if public_diff_reference else None, public_findings, reference=public_diff_reference, + head_binding_facts=public_binding_graph, ) public_tool_surface_diff.operation_comparisons = compare_operations(public_operations, operation_base) # One canonical per-finding statement over both comparison lists, built diff --git a/src/agents_shipgate/cli/scan/surface_redaction.py b/src/agents_shipgate/cli/scan/surface_redaction.py index 355b463d8..36936ad84 100644 --- a/src/agents_shipgate/cli/scan/surface_redaction.py +++ b/src/agents_shipgate/cli/scan/surface_redaction.py @@ -17,6 +17,7 @@ ) from agents_shipgate.core.lenses.tool_surface import ToolSurfaceDiffReference, _stable_hash from agents_shipgate.core.privacy import RedactionStats, redact_data, sanitize_model +from agents_shipgate.schemas.bindings import AgentBindingGraphAssessment from agents_shipgate.schemas.codex_plugin import CodexPluginSurface from agents_shipgate.schemas.manifest import AgentsShipgateManifest from agents_shipgate.schemas.report import Finding @@ -236,6 +237,11 @@ def _sanitize_diff_reference( baseline_schema_version=reference.baseline_schema_version, action_facts=action_facts, declaration_facts=declaration_facts, + binding_facts=( + sanitize_model(reference.binding_facts, AgentBindingGraphAssessment, + stats=stats, path="tool_surface_diff.base.binding_facts") + if reference.binding_facts is not None else None + ), findings=findings, finding_evidence=( tuple(sanitize_model(row, Finding, stats=stats, path="tool_surface_diff.base.finding_evidence[]") diff --git a/src/agents_shipgate/cli/verify/capability_review.py b/src/agents_shipgate/cli/verify/capability_review.py index 32f5f1add..b3a02c7f2 100644 --- a/src/agents_shipgate/cli/verify/capability_review.py +++ b/src/agents_shipgate/cli/verify/capability_review.py @@ -1,5 +1,6 @@ from __future__ import annotations +from agents_shipgate.core.binding_comparison import comparison_withholds_absence from agents_shipgate.core.findings.verifier_blocks import build_capability_change from agents_shipgate.core.policy_reason_codes import ( POLICY_BASE_ABSENT_CHECK_ID, @@ -76,6 +77,7 @@ def build_capability_review( notes: list[str] = [] if not capability_change.enabled: notes.extend(_disabled_diff_notes(report)) + notes.extend(note for note in _withheld_absence_notes(report) if note not in notes) return VerifierCapabilityReview( added=len(capability_change.added), @@ -207,6 +209,18 @@ def _disabled_diff_notes(report: ReadinessReport) -> list[str]: return notes +def _withheld_absence_notes(report: ReadinessReport) -> list[str]: + """The limits of a comparison that still reports additions and changes. + + The removed and narrowed counts above are then not "none": they were + withheld while a binding graph is incomplete, and the rollup says so. + """ + return [ + note for note in report.tool_surface_diff.notes + if comparison_withholds_absence((note,)) + ] + + # ``POLICY_BASE_ABSENT_CHECK_ID`` is re-exported so callers that already import # the weakening id from here keep one import site for the pair. __all__ = [ diff --git a/src/agents_shipgate/core/agent_bindings.py b/src/agents_shipgate/core/agent_bindings.py index 3437afe03..0bfcc7493 100644 --- a/src/agents_shipgate/core/agent_bindings.py +++ b/src/agents_shipgate/core/agent_bindings.py @@ -37,6 +37,25 @@ #: manifest location and the prose be rewritten freely (#329). TOOL_SOURCE_BINDING_DECLARATION = "tool_source_binding_declaration" +#: A reader proved that construction ownership, independently of the tool +#: inventory or declared wiring, remains unread. Only reader observations +#: can emit this marker; warning wording and catalog annotations cannot. +FRAMEWORK_CONSTRUCTOR_OWNERSHIP = "framework_constructor_ownership" + +#: A remote framework toolset whose own binding a reader fully read, but whose +#: tool inventory no static source can enumerate (an ADK ``McpToolset`` with no +#: ``inventory_path``). The graph stays ``partial``, as for any unenumerated +#: surface; but nothing about the *binding* is unread, so a capability +#: comparison does not treat it as incomplete binding evidence (#538). Only a +#: reader observation can emit this marker, never warning text. +FRAMEWORK_TOOLSET_INVENTORY = "framework_toolset_inventory" + +#: ``GoogleAdkToolsetConnection.limitations`` code for a toolset constructor +#: whose spelling is not proven to be ADK's own (``inputs.google_adk``'s +#: ``LIMIT_SHADOWED_TOOLSET_CONSTRUCTOR``, pinned equal by a test). Such a +#: toolset is not a fully read binding, whatever its inventory. +_UNPROVEN_TOOLSET_CONSTRUCTOR = "shadowed_toolset_constructor" + _TOOL_EDGE_TYPES = { "direct_tool", "tool_node", @@ -122,6 +141,7 @@ def resolve_agent_binding_graph( raw_handoffs, partials, invalid_annotations, + structured_partials, ) = _observations(tools, artifacts, loaded_sources or []) declarations = manifest.agent_bindings.declarations if manifest is not None else [] # A declaration may introduce an agent the extractors never saw — a @@ -507,9 +527,14 @@ def resolve_agent_binding_graph( ) ) + issues.extend(structured_partials) + tool_edges = _dedupe_tool_edges(tool_edges) handoff_edges = _dedupe_handoff_edges(handoff_edges) reachable_agents, paths = _reachable_agents(root, handoff_edges, surface_agent_ids) + candidate_agents, _ = _reachable_agents( + root, handoff_edges, surface_agent_ids, include_incomplete=True, + ) for edge in handoff_edges: if edge.source_agent_id in reachable_agents and not edge.complete: issues.append( @@ -535,7 +560,7 @@ def resolve_agent_binding_graph( { edge.tool_id for edge in tool_edges - if edge.agent_id in reachable_agents and not edge.complete + if edge.agent_id in candidate_agents } - set(reachable_ids) ) @@ -547,7 +572,7 @@ def resolve_agent_binding_graph( incomplete_by_tool = { edge.tool_id: edge for edge in tool_edges - if edge.agent_id in reachable_agents and not edge.complete + if edge.agent_id in candidate_agents and edge.tool_id not in reachable_ids } for tool_id in possible_ids: edge = incomplete_by_tool[tool_id] @@ -555,7 +580,7 @@ def resolve_agent_binding_graph( AgentBindingIssue( kind="partial_binding_evidence", message=( - "A possibly reachable tool has an incomplete binding edge; " + "A possibly reachable tool has an incomplete binding path; " "provide a complete static graph or reviewed declaration." ), agent_id=edge.agent_id, @@ -652,12 +677,15 @@ def _observations( list[_RawHandoffEdge], set[str], set[str], + list[AgentBindingIssue], ]: agents: list[_AgentObservation] = [] edges: list[_RawToolEdge] = [] handoffs: list[_RawHandoffEdge] = [] partials: set[str] = set() invalid_annotations: set[str] = set() + constructor_partials: dict[tuple[str, str, str], AgentBindingIssue] = {} + inventory_partials: dict[str, AgentBindingIssue] = {} for loaded in loaded_sources: for observation in loaded.binding_observations: @@ -705,7 +733,19 @@ def _observations( observation.handoffs_complete, ) ) - partials.update(observation.issues) + constructor_messages = set(observation.constructor_issues.values()) + partials.update(message for message in observation.issues if message not in constructor_messages) + for pointer, message in observation.constructor_issues.items(): + agent_id = _AgentObservation( + observation.agent, observation.source_id, + observation.source, observation.source_pointer, complete, + ).agent_id + key = (agent_id, pointer, message) + constructor_partials[key] = AgentBindingIssue( + kind="partial_binding_evidence", message=message, + agent_id=agent_id, source=FRAMEWORK_CONSTRUCTOR_OWNERSHIP, + source_pointer=pointer, + ) for tool in tools: for reason in _string_list(tool.annotations.get("binding_surface_partial")): @@ -723,7 +763,7 @@ def _observations( ) continue source_id = raw.get("source_id") if isinstance(raw.get("source_id"), str) else tool.source_id - source = str(raw.get("source") or tool.source_ref or tool.source_type) + source = _annotation_source(raw, tool) pointer = raw.get("source_pointer") if isinstance(raw.get("source_pointer"), str) else tool.source_location raw_complete = raw.get("complete", True) if type(raw_complete) is not bool: @@ -775,7 +815,7 @@ def _observations( target_agent, _str(raw.get("source_id")) or tool.source_id, str(raw.get("edge_type") or "handoff"), - str(raw.get("source") or tool.source_ref or tool.source_type), + _annotation_source(raw, tool), _str(raw.get("source_pointer")) or tool.source_location, raw_complete, ) @@ -864,7 +904,7 @@ def _observations( for target in _string_list(record.get("sub_agents")): if source_name: agents.append(_AgentObservation(target, _str(record.get("source_id")), _str(record.get("source_ref")), _str(record.get("source_ref")), True)) - handoffs.append(_RawHandoffEdge(source_name, target, _str(record.get("source_id")), "subagent", _str(record.get("source_ref")) or "google_adk", _str(record.get("source_ref")), True)) + handoffs.append(_RawHandoffEdge(source_name, target, _str(record.get("source_id")), "subagent", _str(record.get("source_ref")) or "google_adk", _str(record.get("source_ref")), not bool(record.get("unread")))) # Only Python-entrypoint records carry ``sub_agent_count``; Agent # Config records describe sub-agents by config path instead. if "sub_agent_count" not in record: @@ -898,7 +938,15 @@ def _observations( partials.add(adk_unnamed_sub_agents(source_name, record)) for toolset in adk.toolsets: if toolset.dynamic or not toolset.resolved: - partials.add(f"Google ADK toolset {toolset.name or toolset.kind!r} is not statically enumerable.") + message = f"Google ADK toolset {toolset.name or toolset.kind!r} is not statically enumerable." + if _binding_fully_read(toolset): + # The binding was read; only its leaves cannot be listed. + inventory_partials[message] = AgentBindingIssue( + kind="partial_binding_evidence", message=message, + source=FRAMEWORK_TOOLSET_INVENTORY, + ) + else: + partials.add(message) n8n = artifacts.get("n8n", N8nArtifacts) if n8n: @@ -912,7 +960,33 @@ def _observations( "Conductor workflow tool bindings include dynamic or unresolved surfaces." ) - return agents, edges, handoffs, partials, invalid_annotations + return agents, edges, handoffs, partials, invalid_annotations, [ + *(constructor_partials[key] for key in sorted(constructor_partials)), + *(inventory_partials[key] for key in sorted(inventory_partials)), + ] + + +def _binding_fully_read(toolset: Any) -> bool: + """Whether a toolset's own construction was read, leaving only its leaves open. + + A named remote toolset read through a proven framework constructor has an + endpoint, credential reference and filter that the remote-binding facts + compare (#538). A toolset the reader could not read at all (``kind`` + ``dynamic``), or whose constructor is not proven to be the framework's, + is not a read binding. + """ + connection = getattr(toolset, "connection", None) + return ( + toolset.kind == "mcp" + and connection is not None + and _UNPROVEN_TOOLSET_CONSTRUCTOR not in connection.limitations + ) + + +def _annotation_source(raw: dict[str, Any], tool: Tool) -> str: + source = str(raw.get("source") or tool.source_ref or tool.source_type) + # Catalog metadata cannot claim the internal constructor-cause producer. + return tool.source_type if source == FRAMEWORK_CONSTRUCTOR_OWNERSHIP else source def adk_unnamed_sub_agents(agent: str | None, record: dict[str, Any]) -> str: @@ -1165,6 +1239,8 @@ def _reachable_agents( root: AgentBindingNode | None, handoffs: list[AgentHandoffBindingEdge], surface_agent_ids: frozenset[str] = frozenset(), + *, + include_incomplete: bool = False, ) -> tuple[set[str], dict[str, list[str]]]: """Walk the complete handoff graph from every declared entry point. @@ -1182,7 +1258,7 @@ def _reachable_agents( return set(), {} outgoing: dict[str, list[str]] = defaultdict(list) for edge in handoffs: - if edge.complete: + if edge.complete or include_incomplete: outgoing[edge.source_agent_id].append(edge.target_agent_id) reachable = set(seeds) paths = {seed: [seed] for seed in seeds} @@ -1204,8 +1280,15 @@ def _attach_binding_assessments( agent_paths: dict[str, list[str]], ) -> None: edges_by_tool: dict[str, list[AgentToolBindingEdge]] = defaultdict(list) + reachable_owners_by_tool: dict[str, set[str]] = defaultdict(set) + incomplete_tool_ids: set[str] = set() for edge in graph.tool_edges: - if edge.tool_id in graph.reachable_tool_ids: + if edge.agent_id in agent_paths: + reachable_owners_by_tool[edge.tool_id].add(edge.agent_id) + if not edge.complete: + incomplete_tool_ids.add(edge.tool_id) + if (edge.tool_id in graph.reachable_tool_ids and edge.complete + and edge.agent_id in agent_paths): edges_by_tool[edge.tool_id].append(edge) graph_issue_by_tool: dict[str, list[AgentBindingIssue]] = defaultdict(list) graph_issue_by_agent: dict[str, list[AgentBindingIssue]] = defaultdict(list) @@ -1219,6 +1302,14 @@ def _attach_binding_assessments( edges_by_tool.get(tool.id, []), key=lambda edge: (edge.agent_id, edge.source, edge.source_pointer or ""), ) + # Refusal causes belong to actual reachable owners, including an + # incomplete edge. Only complete edges below become binding claims. + constructor_owner_issues = [ + issue + for agent_id in sorted(reachable_owners_by_tool.get(tool.id, set())) + for issue in graph_issue_by_agent.get(agent_id, []) + if issue.source == FRAMEWORK_CONSTRUCTOR_OWNERSHIP + ] if edges: edge = edges[0] claims = [ @@ -1238,6 +1329,11 @@ def _attach_binding_assessments( ) for item in edges ] + owner_issues = [ + issue for issue in graph_issue_by_agent.get(edge.agent_id, []) + if issue.source != FRAMEWORK_CONSTRUCTOR_OWNERSHIP + ] + owner_issues.extend(constructor_owner_issues) issues = [ SemanticIssue( kind=issue.kind, @@ -1248,7 +1344,7 @@ def _attach_binding_assessments( ) for issue in [ *graph_issue_by_tool.get(tool.id, []), - *graph_issue_by_agent.get(edge.agent_id, []), + *owner_issues, ] ] status = "declared" if any(item.provenance_kind == "static_declaration" for item in edges) else "structural" @@ -1259,13 +1355,22 @@ def _attach_binding_assessments( reachable_path=[*agent_paths.get(edge.agent_id, [edge.agent_id]), tool.id], claims=claims, issues=issues, - pass_eligible=not issues and all(item.complete for item in edges), + # Proven claims do not erase the same tool's unread owners. + # Keep the conservative eligibility boundary independently. + pass_eligible=not issues and tool.id not in incomplete_tool_ids, ) else: tool.binding_assessment = BindingSemanticAssessment( status="unknown", confidence="low", root_agent_id=graph.root_agent_id, + issues=[SemanticIssue( + kind=issue.kind, dimension="binding", message=issue.message, + source=issue.source, source_pointer=issue.source_pointer, + ) for issue in [ + *graph_issue_by_tool.get(tool.id, []), + *constructor_owner_issues, + ]], pass_eligible=False, ) diff --git a/src/agents_shipgate/core/binding_comparison.py b/src/agents_shipgate/core/binding_comparison.py new file mode 100644 index 000000000..613489429 --- /dev/null +++ b/src/agents_shipgate/core/binding_comparison.py @@ -0,0 +1,96 @@ +"""Static graph coverage required before comparing capability absence.""" +from __future__ import annotations + +from collections.abc import Iterable + +from agents_shipgate.core.agent_bindings import FRAMEWORK_TOOLSET_INVENTORY +from agents_shipgate.schemas.bindings import AgentBindingGraphAssessment, AgentBindingIssue + +_UNCERTAIN_ISSUES = frozenset({ + "partial_binding_evidence", "incomplete_handoff_graph", "unresolved_agent_binding", + "unresolved_bound_tool", "conflicting_binding_evidence", "invalid_binding_annotation", +}) + +#: The issue kinds that make a resolved graph ``partial`` (see +#: ``resolve_agent_binding_graph``). The status is the sum of these. +_PARTIAL_STATUS_ISSUES = frozenset({ + "partial_binding_evidence", "incomplete_handoff_graph", "unresolved_agent_binding", + "unresolved_bound_tool", +}) + +#: Every limit note starts with this, and the persisted notes are the only +#: channel a later reader of a report has. Consumers ask +#: :func:`comparison_withholds_absence` rather than matching the text. +_NOTE_PREFIX = "Capability comparison incomplete on " + +_WITHHELD_WHOLE = "Capability additions, removals and changes were not established." +_WITHHELD_ABSENCE = ( + "Removed or narrowed capabilities and resolved findings were not established; " + "additions and changes of unknown direction are still reported." +) + + +def _inventory_unknown(issue: AgentBindingIssue) -> bool: + """A remote toolset whose binding was read and whose leaves cannot be listed. + + Not incomplete binding evidence: the toolset's endpoint, credential + reference and filter are compared by their own facts, and no leaf tool is + claimed on either side, so it can neither forge nor hide a removal. + """ + return issue.kind == "partial_binding_evidence" and issue.source == FRAMEWORK_TOOLSET_INVENTORY + + +def binding_comparison_limits( + head: AgentBindingGraphAssessment | None, + base: AgentBindingGraphAssessment | None, + *, + absence_only: bool = False, +) -> list[str]: + """Name incomplete sides without treating a catalog as an agent graph. + + Missing optional graphs preserve legacy fact-only comparisons. Proven + presence is not proven absence when a reader left part of a graph unread. + + A toolset marked :data:`FRAMEWORK_TOOLSET_INVENTORY` is a surface nobody + can enumerate, not a part of the binding the reader left unread: it is + neither a limit itself nor the reason a graph reads ``partial``. + ``absence_only`` words the note for a comparison that still reports + what is added or changed and withholds only what depends on absence. + """ + notes = [] + for side, graph in (("head", head), ("base", base)): + if graph is None: + continue + reasons = set() + status_causes = [issue for issue in graph.issues if issue.kind in _PARTIAL_STATUS_ISSUES] + inventory_only = ( + graph.status == "partial" and bool(status_causes) + and all(_inventory_unknown(issue) for issue in status_causes) + ) + if graph.status in {"partial", "conflicting"} and not inventory_only: + reasons.add("graph_status:" + graph.status) + if graph.possible_tool_ids: + reasons.add("possible_tool_bindings") + if any(not edge.complete for edge in (*graph.tool_edges, *graph.handoff_edges)): + reasons.add("incomplete_binding_edges") + actual_agent = any(node.kind == "agent" for node in graph.agents) + for issue in graph.issues: + if _inventory_unknown(issue): + continue + if issue.kind in _UNCERTAIN_ISSUES or ( + actual_agent and graph.status == "unknown" + and issue.kind in {"missing_binding_evidence", "ambiguous_root_agent"} + ): + location = ":".join(part for part in (issue.source, issue.source_pointer) if part) + reasons.add(issue.kind + (" at " + location if location else "")) + if reasons: + notes.append( + f"{_NOTE_PREFIX}{side}: " + "; ".join(sorted(reasons)) + ". " + + (_WITHHELD_ABSENCE if absence_only else _WITHHELD_WHOLE) + ) + return notes + + +def comparison_withholds_absence(notes: Iterable[str]) -> bool: + """Whether persisted diff notes say this comparison's absence claims were withheld.""" + return any(note.startswith(_NOTE_PREFIX) for note in notes) diff --git a/src/agents_shipgate/core/domain.py b/src/agents_shipgate/core/domain.py index 9a72bf3fa..888b94033 100644 --- a/src/agents_shipgate/core/domain.py +++ b/src/agents_shipgate/core/domain.py @@ -727,6 +727,9 @@ class AgentBindingObservation(BaseModel): tools_complete: bool = True handoffs_complete: bool = True issues: list[str] = Field(default_factory=list) + #: Reader-owned constructor causes by construction pointer. Internal + #: routing provenance, never inferred from warning prose or annotations. + constructor_issues: dict[str, str] = Field(default_factory=dict, exclude=True) class SourceSurfaceOmission(BaseModel): diff --git a/src/agents_shipgate/core/findings/verifier_blocks.py b/src/agents_shipgate/core/findings/verifier_blocks.py index acaf98749..a5ca1b2eb 100644 --- a/src/agents_shipgate/core/findings/verifier_blocks.py +++ b/src/agents_shipgate/core/findings/verifier_blocks.py @@ -28,6 +28,7 @@ import hashlib +from agents_shipgate.core.binding_comparison import comparison_withholds_absence from agents_shipgate.core.capability_delta import ( CapabilityDeltaRow, CapabilityFactContext, @@ -424,6 +425,13 @@ def build_capability_change( ) ) + if comparison_withholds_absence(getattr(tsd, "notes", None) or ()): + # An incomplete binding graph on either side: a capability missing from + # the head may only be unread there. Removed and narrowed members are + # absence claims, whichever surface produced them; added and broadened + # members (a possible widening included) are still reported. + removed, narrowed = [], [] + return CapabilityChangeBlock( enabled=True, added=_dedup_members(added), diff --git a/src/agents_shipgate/core/lenses/tool_surface.py b/src/agents_shipgate/core/lenses/tool_surface.py index 6e4dd095d..8d081a053 100644 --- a/src/agents_shipgate/core/lenses/tool_surface.py +++ b/src/agents_shipgate/core/lenses/tool_surface.py @@ -12,6 +12,7 @@ AnthropicArtifacts, OpenAIApiArtifacts, ) +from agents_shipgate.core.binding_comparison import binding_comparison_limits from agents_shipgate.core.domain import AgentRemoteBinding, Tool, ToolkitScopeBound from agents_shipgate.core.errors import InputParseError from agents_shipgate.core.findings.identity import _canonicalize_for_fingerprint @@ -218,13 +219,31 @@ def compute_tool_surface_diff( findings: list[Finding], *, reference: ToolSurfaceDiffReference | None = None, + head_binding_facts: AgentBindingGraphAssessment | None = None, ) -> ToolSurfaceDiff: + # Coverage limits constrain a requested comparison. A fresh scan with + # no base/reference has no absence or resolution claim to withhold. + # + # An incomplete binding graph on either side means a tool, scope, effect + # or finding missing from the head may only be unread there. Everything + # that depends on absence is withheld (removed and narrowed rows, the + # control rows of a tool whose only evidence is its absence, resolved + # findings). What presence proves is still reported: an added tool, a + # changed row or a removed control is never hidden, because a possible + # widening must reach the reviewer even when the graph is partial. + limits = binding_comparison_limits( + head_binding_facts, reference.binding_facts if reference else None, + absence_only=True, + ) if base is not None or reference is not None else [] + if base is None: finding_deltas = ( _finding_deltas(findings, reference.findings) if reference else ToolSurfaceFindingDeltas() ) + if limits: + finding_deltas = _without_resolved(finding_deltas) diff_base = _diff_base(reference) notes = list(reference.notes) if reference else [] if not notes: @@ -247,16 +266,34 @@ def compute_tool_surface_diff( base=diff_base, summary=_summary_from_diff_parts(finding_deltas=finding_deltas), finding_deltas=finding_deltas, - notes=[*notes, *finding_comparison_notes(findings, reference.finding_evidence)] if reference else notes, + notes=[ + *notes, + *(finding_comparison_notes(findings, reference.finding_evidence) if reference else ()), + *limits, + ], ) tool_changes = _diff_tools(current.tools, base.tools) high_risk_effects = _diff_high_risk_effects(current.tools, base.tools) scopes = _diff_scopes(current.scopes, base.scopes) controls = _diff_controls(current.controls, base.controls) - metadata_changes = _metadata_changes(tool_changes) policy_drift = _diff_policies(current.policies, base.policies) finding_deltas = _finding_deltas(findings, reference.findings if reference else None) + if limits: + gone = [row for row in tool_changes if row.kind == "removed"] + absent_ids = {row.tool_id for row in gone if row.tool_id} + absent_names = {row.name for row in gone} # For a control that names no tool id. + tool_changes = [row for row in tool_changes if row.kind != "removed"] + high_risk_effects = [row for row in high_risk_effects if row.kind != "removed"] + scopes = [row for row in scopes if row.kind != "removed"] + controls = [ + row for row in controls + if row.kind != "added" + and not (row.tool_id in absent_ids if row.tool_id else row.tool in absent_names) + ] + policy_drift = [row for row in policy_drift if row.kind != "removed"] + finding_deltas = _without_resolved(finding_deltas) + metadata_changes = _metadata_changes(tool_changes) summary = _summary_from_diff_parts( tool_changes=tool_changes, high_risk_effects=high_risk_effects, @@ -270,6 +307,7 @@ def compute_tool_surface_diff( notes.append("Tool renames are reported as one removed tool plus one added tool.") if reference: notes.extend(reference.notes) + notes.extend(limits) return ToolSurfaceDiff( enabled=True, guard_comparisons=compare_guard_dependencies(current.guard_dependencies, base.guard_dependencies), @@ -918,6 +956,11 @@ def _finding_deltas( ) +def _without_resolved(deltas: ToolSurfaceFindingDeltas) -> ToolSurfaceFindingDeltas: + """A finding missing from the head may only be unread there, not fixed.""" + return deltas.model_copy(update={"resolved_findings": []}) + + def _finding_item(finding: Finding) -> ToolSurfaceFindingDeltaItem | None: if finding.suppressed: return None diff --git a/src/agents_shipgate/core/surface_exclusions.py b/src/agents_shipgate/core/surface_exclusions.py index 20a4bba55..b82e905f2 100644 --- a/src/agents_shipgate/core/surface_exclusions.py +++ b/src/agents_shipgate/core/surface_exclusions.py @@ -503,7 +503,7 @@ def _accounting(tool_id: str) -> tuple[str, str | None]: if introduced else ( "The tool is in the catalog and no edge binds it to " - "the root agent, and the base comparison that would " + "the root agent, and the requested binding comparison that would " "say whether this change introduced it could not be " "performed." ) diff --git a/src/agents_shipgate/inputs/builder_calls.py b/src/agents_shipgate/inputs/builder_calls.py index 2f29a7a88..9f9d84b37 100644 --- a/src/agents_shipgate/inputs/builder_calls.py +++ b/src/agents_shipgate/inputs/builder_calls.py @@ -10,24 +10,38 @@ import ast import re import stat +from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from agents_shipgate.core.errors import InputParseError from agents_shipgate.core.static_inputs import active_static_input_snapshot -from agents_shipgate.inputs.common import list_input_directory, load_text_file +from agents_shipgate.core.trust_roots import IdentityBoundReadSession +from agents_shipgate.inputs.common import MAX_INPUT_FILE_BYTES, list_input_directory, load_text_file from agents_shipgate.inputs.list_expressions import evaluation_site from agents_shipgate.inputs.python_imports import ( + _PRELOADED, + IMPORT_SEARCH_PATCH, LOCAL_BINDING, MODULE_NOT_FOUND, + MODULE_TABLE_COMPUTED, NAME_NOT_DEFINED, NOT_A_FUNCTION, NOT_BOUND, + PATH_PATCH, + SELF_PATCH, ImportResolver, PythonModule, + RepositoryLayout, Resolution, ScopeIndex, + _attribute_patches, + _CapturedImportSearchResolver, + _external_constructor_paths, + _external_decorator_paths, + _external_wrapper_paths, _Stop, + _type_checking_only, reference_spelling, reflective_access, ) @@ -39,8 +53,10 @@ MAX_ALIAS_ROUNDS = 4 MAX_DEPTH = 4 MAX_CONTEXTS = 128 +MAX_NAMESPACE_CONTEXT_NODES = 20000 _SKIP = frozenset({"__pycache__", "node_modules", "site-packages"}) _ELSEWHERE = frozenset({NOT_BOUND, LOCAL_BINDING, NAME_NOT_DEFINED, NOT_A_FUNCTION}) +_SOURCE_MAPPING_MUTATORS = frozenset({"update", "__init__"}) class CallLimit(Exception): @@ -57,6 +73,258 @@ def location(self) -> str: return f"{self.module.ref}:{self.call.lineno}" +@dataclass(frozen=True) +class NamespaceSourceContext: + """Parsed source and terminal imports, never a namespace ownership proof. + + Reflection, module hooks, mutation and data confinement remain separate + obligations even when every repository source in this context was read. + """ + + modules: tuple[PythonModule, ...] + external_imports: tuple[str, ...] + + +@dataclass(frozen=True) +class _FreshPrimitiveDictionaryReceipt: + """Local exact Function-data nodes and their still-open identity ledger.""" + + proof: BuilderCalls + values: frozenset[ast.expr] + + def reconfirm(self) -> None: + if not self.proof._fresh_receipt_active or self.proof._fresh_receipt_finished: + raise CallLimit("the fresh dictionary receipt is no longer active") + self.proof._namespace_directory_currency() + self.proof._fresh_identity_session.finish() + self.proof._fresh_receipt_finished = True + + +@dataclass(frozen=True) +class _AbsentFieldDictionaryRoles: + """Actual unread-field roles, distinct from same-slot identity evidence.""" + + proof: BuilderCalls + home: PythonModule + function: ast.FunctionDef + value: ast.expr + namespace_nodes: frozenset[ast.expr] + getter_metadata: ast.Constant | None = None + intrinsic_projection: ast.Attribute | None = None + + +@dataclass(frozen=True) +class _AbsentFieldDictionaryReceipt: + proof: BuilderCalls + roles: _AbsentFieldDictionaryRoles + + def census(self) -> CallerCensus: + return self.proof._census(self.roles.home, self.roles.function, allow_empty=True, + absent_field_roles=self.roles) + + def reconfirm(self) -> None: + if not self.proof._fresh_receipt_active or self.proof._fresh_receipt_finished: + raise CallLimit("the absent-field receipt is no longer active") + self.proof._namespace_directory_currency() + self.proof._fresh_identity_session.finish() + self.proof._fresh_receipt_finished = True + + +@dataclass(frozen=True) +class _SavedSourceVarsGetter: + """Actual bare getter assignment and its sole saved mapping projection.""" + + assignment: ast.Assign + target: ast.Name + initializer: ast.Name + projection: ast.Call + callee: ast.Name + + +@dataclass(frozen=True) +class _SavedSourceMapping: + """Actual saved projection and its sole lexical Store receiver.""" + + declaration: ast.Assign + target: ast.Name + receiver: ast.Name + projection: ast.Attribute | ast.Call + namespace: ast.Name + saved_getter: _SavedSourceVarsGetter | None = None + + +_SourceSlotParts = tuple[ast.Name, ast.Name, str, ast.Attribute | ast.Call | None, + ast.Constant | None, _SavedSourceMapping | None] +_SourceSlotStatement = ast.Assign | ast.Expr | ast.AugAssign + + +@dataclass(frozen=True) +class _SourceSavedBoundIor: + """Actual saved intrinsic method, separate from its mapping and Function.""" + + assignment: ast.Assign + target: ast.Name + initializer: ast.Attribute + receiver: ast.Name + callee: ast.Name + call: ast.Call + payload: ast.Dict + + +@dataclass(frozen=True) +class _SourceOperatorSetitem: + """Actual standard-operator tokens; no dictionary-method or setter fiction.""" + + statement: ast.Import + imported: ast.alias + root: ast.Name + callee: ast.Attribute + call: ast.Call + projection: ast.Attribute + key: ast.Constant + rhs: ast.Attribute + + +@dataclass(frozen=True) +class _SourceDirectOperatorIor: + """An actual inline module dictionary, with no invented saved binding.""" + + statement: ast.Import + imported: ast.alias + root: ast.Name + callee: ast.Attribute + call: ast.Call + projection: ast.Attribute + payload: ast.Dict + key: ast.Constant + rhs: ast.Attribute + + +@dataclass(frozen=True) +class _SourceOperatorIor: + """Actual operator call and its independently confined saved mapping.""" + + statement: ast.Import + imported: ast.alias + root: ast.Name + callee: ast.Attribute + call: ast.Call + mapping: _SavedSourceMapping + payload: ast.Dict + key: ast.Constant + rhs: ast.Attribute + + +@dataclass(frozen=True) +class _SourceFromOperatorIor: + """An actual from-import and Name callee, never an invented module root.""" + + statement: ast.ImportFrom + imported: ast.alias + callee: ast.Name + call: ast.Call + mapping: _SavedSourceMapping + payload: ast.Dict + key: ast.Constant + rhs: ast.Attribute + + +@dataclass(frozen=True) +class _SourceDirectBareSetter: + """The actual direct builtin callee, without an invented saved origin.""" + + callee: ast.Name + + +@dataclass(frozen=True) +class _SourceSlotEdge: + """Keep the actual Function value separate from its mutation syntax.""" + + statement: _SourceSlotStatement + parts: _SourceSlotParts + rhs: ast.Attribute + target: ast.Attribute | ast.Subscript | ast.Name | None = None + callee: ast.Attribute | ast.Name | None = None + call: ast.Call | None = None + payload: ast.Dict | ast.keyword | None = None + primitive: ast.Name | ast.Attribute | None = None + primitive_import: _SourcePrimitiveImport | None = None + setter_import: _SourceSetterImport | _SourceSavedSetterImport | _SourceFromSetterImport | None = None + bare_setter: _SourceBareSetter | None = None + direct_bare_setter: _SourceDirectBareSetter | None = None + saved_bound_ior: _SourceSavedBoundIor | None = None + operator_setitem: _SourceOperatorSetitem | None = None + operator_ior: _SourceOperatorIor | _SourceDirectOperatorIor | None = None + from_operator_ior: _SourceFromOperatorIor | None = None + + +@dataclass(frozen=True) +class _SourceSetterImport: + """Actual qualified setter tokens, distinct from dictionary primitives.""" + + statement: ast.Import + imported: ast.alias + root: ast.Name + callee: ast.Attribute + + +@dataclass(frozen=True) +class _SourceSavedSetterImport: + """Actual saved setter assignment and call, never a lexical alias grant.""" + + statement: ast.Import + imported: ast.alias + root: ast.Name + callee: ast.Name + initializer: ast.Attribute + assignment: ast.Assign + target: ast.Name + + +@dataclass(frozen=True) +class _SourceFromSetterImport: + """Actual from-import provenance and terminal, with no invented root token.""" + + statement: ast.ImportFrom + imported: ast.alias + callee: ast.Name + + +@dataclass(frozen=True) +class _SourceBareSetter: + """Actual bare builtin assignment and terminal; no import or root fiction.""" + + assignment: ast.Assign + target: ast.Name + initializer: ast.Name + callee: ast.Name + + +@dataclass(frozen=True) +class _SourcePrimitiveImport: + """Actual qualified primitive tokens, never an import admission alone.""" + + statement: ast.Import + imported: ast.alias + root: ast.Name + primitive: ast.Attribute + + +@dataclass(frozen=True) +class _OperatorMetadataWrite: + """An actual imported operator field write, never Function evidence.""" + + statement: ast.Assign + target: ast.Attribute + receiver: ast.Name + imported: ast.alias + declaration: ast.Import + + @property + def marker(self) -> str: + return f"operator.{self.target.attr}" + + @dataclass(frozen=True) class Argument: parameter: ast.arg @@ -150,28 +418,172 @@ class CallerCensus: limits: tuple[str, ...] = () +@dataclass(frozen=True) +class _DictionaryDataEdges: + """Exact, independently confined dictionary occurrences in one parsed tree.""" + + tree: ast.Module + values: frozenset[ast.expr] = frozenset() + keys: frozenset[ast.Constant] = frozenset() + + +@dataclass(frozen=True) +class _UnusedNamespaceEdges: + tree: ast.Module + values: frozenset[ast.Name] = frozenset() + + @dataclass(frozen=True) class ConstructionContext: invocation: Invocation | None = None limits: tuple[str, ...] = () +class _FreshPrimitiveResolver(ImportResolver): + """Receipt-only lookup reader; shared ASTs never share semantic caches.""" + + def __init__(self, source: ImportResolver, session: IdentityBoundReadSession) -> None: + super().__init__(source.scope_root) + self._fresh_session = session + self._fresh_source_layout = source._layout + self._fresh_live_texts: dict[Path, str] = {} + self._fresh_allowed_paths: frozenset[Path] | None = None + layout = source._layout + assert layout is not None and layout.disk_root == session.root + self._layout = RepositoryLayout(layout.scope, self._checked_entries, self._checked_links, + self._checked_read, disk_root=layout.disk_root) + self._modules = {path: module for path, module in source._modules.items() if isinstance(module, PythonModule)} + self._scanned = {path: module for path, module in source._scanned.items() if isinstance(module, PythonModule)} + self._parsed = source._parsed + + def _live_text(self, path: Path) -> str: + if path not in self._fresh_live_texts: + self._fresh_live_texts[path] = self._fresh_session.read_bytes( + path.relative_to(self._fresh_session.root), max_bytes=MAX_INPUT_FILE_BYTES, + ).decode("utf-8") + return self._fresh_live_texts[path] + + def _checked_module(self, module: PythonModule) -> PythonModule: + if self._fresh_allowed_paths is not None and module.path not in self._fresh_allowed_paths: + raise CallLimit(f"{module.ref} is outside the complete fresh dictionary context") + if module.text != self._live_text(module.path): + raise CallLimit(f"{module.ref} differs from its live fresh module bytes") + return module + + def module(self, path: Path) -> PythonModule: + return self._checked_module(super().module(path)) + + def _patch_scan(self, path: Path) -> PythonModule: + return self._checked_module(super()._patch_scan(path)) + + def _listing(self, directory: Path) -> frozenset[str] | None: + names = super()._listing(directory) + if not directory.is_relative_to(self.scope_root): + return names + live = frozenset(self._fresh_session.directory_entries(directory.relative_to(self._fresh_session.root))) + if names != live: + raise CallLimit(f"{directory} differs from its live fresh lookup inventory") + return names + + def _kind(self, directory: Path, name: str) -> str | None: + expected = super()._kind(directory, name) + names = self._listing(directory) + if names is None or name not in names: + return expected + live = self._fresh_session.directory_entry_kind((directory / name).relative_to(self._fresh_session.root)) + mapped = {"file": "file", "directory": "dir", "symlink": "link"}.get(live) + if mapped is None or mapped != expected: + raise CallLimit(f"{directory / name} has unread or changed fresh provider kind") + return expected + + def _checked_entries(self, ref: str) -> frozenset[str] | None: + relative = Path(ref) + if relative.is_absolute() or ".." in relative.parts: + raise CallLimit("a fresh repository lookup leaves its identity root") + layout = self._fresh_source_layout + root = self._fresh_session.root + parent = Path() + for part in relative.parts: + live = frozenset(self._fresh_session.directory_entries(parent)) + parent_ref = "" if parent == Path() else parent.as_posix() + if layout.entries(parent_ref) != live: + raise CallLimit(f"{root / parent} differs from its fresh repository lookup names") + if part not in live: + if layout.entries(ref) is not None: + raise CallLimit(f"{ref} has stale fresh repository directory evidence") + return None + parent /= part + kind = self._fresh_session.directory_entry_kind(parent) + if kind != "directory": + if layout.entries(ref) is not None: + raise CallLimit(f"{ref} changed its fresh repository directory kind") + return None + live = frozenset(self._fresh_session.directory_entries(relative)) + if layout.entries(ref) != live: + raise CallLimit(f"{ref} differs from its live fresh repository inventory") + return live + + def _checked_links(self, ref: str) -> frozenset[str]: + names = self._checked_entries(ref) + if names is None: + return frozenset() + actual = frozenset(name for name in names + if self._fresh_session.directory_entry_kind(Path(ref) / name) == "symlink") + if self._fresh_source_layout.links(ref) != actual: + raise CallLimit(f"{ref} differs from its fresh repository link evidence") + return actual + + def _checked_read(self, ref: str) -> str | None: + text = self._fresh_source_layout.read(ref) + if text is None: + return None + if text != self._live_text(self._fresh_session.root / ref): + raise CallLimit(f"{ref} differs from its live fresh repository source") + return text + + class BuilderCalls: """One caller index per resolver/read, including negative read evidence.""" def __init__(self, resolver: ImportResolver) -> None: self.resolver = resolver self._files: list[Path] | None = None + # Retain the topology consumed by the cached caller inventory. These + # obligations survive each source-context ancestry-map reset. + self._inventory_directories: dict[Path, frozenset[tuple[str, int]]] = {} + self._inventory_venv_selectors: dict[Path, tuple[bool, int | None]] = {} + self._namespace_directories: dict[Path, frozenset[str] | None] = {} self._texts: dict[Path, str] = {} self._read_bytes = 0 self._scopes: dict[int, ScopeIndex] = {} - self._censuses: dict[int, CallerCensus] = {} + self._censuses: dict[tuple[int, bool], CallerCensus] = {} self._exports: dict[int, bool] = {} - self._borrowers: dict[tuple[int, str], tuple[PythonModule, ...]] = {} - self._borrower_aliases: dict[tuple[int, str], tuple[str, ...]] = {} - self._borrower_words: dict[tuple[int, str], frozenset[str]] = {} - self._borrower_paths: dict[tuple[int, str], frozenset[Path]] = {} + self._export_limits: dict[int, str] = {} + self._export_routes: dict[int, set[str]] = {} + self._borrowers: dict[tuple[int, str, bool], tuple[PythonModule, ...]] = {} + self._borrower_aliases: dict[tuple[int, str, bool], tuple[str, ...]] = {} + self._borrower_words: dict[tuple[int, str, bool], frozenset[str]] = {} + self._borrower_paths: dict[tuple[int, str, bool], frozenset[Path]] = {} self._dynamic_imports: dict[int, ast.AST | None] = {} + self._constructors: dict[tuple[int, int], str | None] = {} + self._runtime_excluded: dict[int, frozenset[int]] = {} + self._dictionary_data: dict[ast.Module, _DictionaryDataEdges] = {} + self._source_operator_tokens: dict[ast.Module, tuple[frozenset[ast.Name], frozenset[ast.Constant]]] = {} + self._source_from_operator_tokens: dict[ + ast.Module, tuple[dict[str, frozenset[ast.Name]], dict[str, frozenset[ast.Constant]]] + ] = {} + self._source_operator_positions: dict[ast.Module, dict[ast.stmt, int]] = {} + self._unused_namespace_data: dict[ast.Module, _UnusedNamespaceEdges] = {} + self._fresh_identity_session: IdentityBoundReadSession | None = None + self._fresh_context_paths: frozenset[Path] | None = None + self._fresh_receipt_active = False + self._fresh_receipt_finished = False + + def runtime_exclusions(self, module: PythonModule) -> frozenset[int]: + key = id(module.tree) + if key not in self._runtime_excluded: + self._runtime_excluded[key] = frozenset(_type_checking_only(module, self.resolver, self.scopes(module))) + return self._runtime_excluded[key] def scopes(self, module: PythonModule) -> ScopeIndex: found = self._scopes.get(id(module.tree)) @@ -190,6 +602,48 @@ def function_at(self, module: PythonModule, node: ast.AST) -> Function | None: current = scopes.parents.get(current) return None + def cached_constructor_issue(self, module: PythonModule, call: ast.Call) -> str | None: + """Return only an already observed issue for this exact construction. + + Absence carries no proof or verdict and performs no constructor query. + """ + return self._constructors.get((id(module.tree), id(call))) + + def constructor_issue(self, module: PythonModule, call: ast.Call) -> str | None: + """One external class is shared by every known caller of its builder. + + The complete caller set is used even when the capability list is a + literal and needs no argument substitution. Refuse an unfinished or + escaping census instead of caching a proof for just one invocation. + """ + key = (id(module.tree), id(call)) + if key in self._constructors: + return self._constructors[key] + modules: dict[Path, PythonModule] = {module.path: module} + pending = [(module, self.function_at(module, call), 0)] + seen: set[int] = set() + try: + while pending: + home, function, depth = pending.pop() + if function is None or id(function) in seen: + continue + if depth >= MAX_DEPTH: + raise CallLimit("the constructor caller chain exceeds its depth bound") + seen.add(id(function)) + census = self.callers(home, function, allow_empty=True) + if census.limits: + raise CallLimit("; ".join(census.limits)) + for site in census.sites: + modules[site.module.path] = site.module + pending.append((site.module, self.function_at(site.module, site.call), depth + 1)) + issue = self.resolver.imported_constructor_issue( + module, call.func, self.scopes(module), contexts=tuple(modules.values()) + ) + except CallLimit as exc: + issue = f"the constructor caller census is not established: {exc}" + self._constructors[key] = issue + return issue + def resolve(self, module: PythonModule, expr: ast.expr) -> Resolution: spelling = reference_spelling(expr) if spelling is None: @@ -347,7 +801,10 @@ def dynamic_importer(self, module: PythonModule) -> ast.AST | None: if key in self._dynamic_imports: return self._dynamic_imports[key] found = None + excluded = self.runtime_exclusions(module) for node in ast.walk(module.tree): + if id(node) in excluded: + continue if isinstance(node, ast.Import) and any( alias.name.split(".", 1)[0] == "importlib" for alias in node.names ): @@ -381,110 +838,2593 @@ def _expand( limits=(f"recursive argument flow or more than {MAX_DEPTH} caller levels",) ), ) - census = self.callers(module, function) - contexts = [ConstructionContext(limits=census.limits)] if census.limits else [] - for site in census.sites: - try: - arguments = bind_call(function, site.call) - needed = [ - value.expr - for value in arguments - if value.parameter.arg in parameters and not value.default - ] - parent_parameters = self._parameters(site.module, needed) - parent_function = self.function_at(site.module, site.call) - if parent_parameters: - if parent_function is None or parent_function not in site.module.tree.body: - raise CallLimit( - "capability arguments come from a nested or method caller, whose callers are not followed" - ) - parents = self._expand( - site.module, parent_function, parent_parameters, seen | {id(function)} - ) - else: - parents = (ConstructionContext(),) - for parent in parents: - if parent.limits: - contexts.append(ConstructionContext(limits=parent.limits)) - else: - contexts.append( - ConstructionContext( - self.invoke(module, function, site, parent.invocation) + census = self.callers(module, function) + contexts = [ConstructionContext(limits=census.limits)] if census.limits else [] + for site in census.sites: + try: + arguments = bind_call(function, site.call) + needed = [ + value.expr + for value in arguments + if value.parameter.arg in parameters and not value.default + ] + parent_parameters = self._parameters(site.module, needed) + parent_function = self.function_at(site.module, site.call) + if parent_parameters: + if parent_function is None or parent_function not in site.module.tree.body: + raise CallLimit( + "capability arguments come from a nested or method caller, whose callers are not followed" + ) + parents = self._expand( + site.module, parent_function, parent_parameters, seen | {id(function)} + ) + else: + parents = (ConstructionContext(),) + for parent in parents: + if parent.limits: + contexts.append(ConstructionContext(limits=parent.limits)) + else: + contexts.append( + ConstructionContext( + self.invoke(module, function, site, parent.invocation) + ) + ) + except CallLimit as exc: + contexts.append(ConstructionContext(limits=(f"{site.location}: {exc}",))) + if len(contexts) > MAX_CONTEXTS: + return ( + ConstructionContext( + limits=(f"argument flow exceeds {MAX_CONTEXTS} caller contexts",) + ), + ) + return tuple(contexts) + + def _parameters(self, module: PythonModule, expressions: list[ast.expr]) -> set[str]: + """List-value dependencies only; a factory's ordinary inputs are lazy. + + A call is projected by the membership resolver. Looking into all its + arguments here would demand callers for e.g. a database handle that + only the nested tool body uses, although the returned list is literal. + Unread deeper forwarding remains a named membership limit. + """ + scopes = self.scopes(module) + found: set[str] = set() + seen: set[int] = set() + pending: list[ast.AST] = list(expressions) + while pending: + node = pending.pop() + if id(node) in seen: + continue + seen.add(id(node)) + if len(seen) > 1000: + raise CallLimit("capability dependency flow exceeds 1000 expressions") + if isinstance(node, ast.Name): + bindings = scopes.enclosing_bindings(evaluation_site(scopes, node), node.id) + if len(bindings) == 1: + binding = bindings[0] + if isinstance(binding, ast.arg): + found.add(binding.arg) + else: + statement = scopes.statement_of(binding) + if ( + isinstance(statement, ast.Assign | ast.AnnAssign) + and statement.value is not None + ): + pending.append(statement.value) + elif isinstance(node, ast.Lambda): + continue + elif isinstance(node, ast.IfExp): + pending.extend((node.body, node.orelse)) + elif isinstance(node, ast.ListComp | ast.GeneratorExp): + pending.extend(generator.iter for generator in node.generators) + elif isinstance(node, ast.Call): + if isinstance(node.func, ast.Attribute) and node.func.attr == "get": + pending.append(node.func.value) + elif isinstance(node.func, ast.Name) and node.func.id in { + "list", + "tuple", + "sorted", + "filter", + }: + pending.extend(node.args[1:2] if node.func.id == "filter" else node.args[:1]) + else: + pending.extend(ast.iter_child_nodes(node)) + return found + + def callers( + self, module: PythonModule, function: Function, *, allow_empty: bool = False, + confined_dictionary_data: bool = False, + unused_namespace_data: bool = False, + ) -> CallerCensus: + if confined_dictionary_data or unused_namespace_data: + # A refined empty-caller proof is never cached as an ordinary + # caller answer, nor reused across invocation/projection contexts. + # Its immutable data edges are proved before inspecting value uses. + try: + return self._census(module, function, allow_empty=allow_empty, + confined_dictionary_data=confined_dictionary_data, + unused_namespace_data=unused_namespace_data) + except CallLimit as exc: + return CallerCensus(limits=(str(exc),)) + key = (id(function), allow_empty) + cached = self._censuses.get(key) + if cached is not None: + return cached + try: + result = self._census(module, function, allow_empty=allow_empty) + except CallLimit as exc: + result = CallerCensus(limits=(str(exc),)) + self._censuses[key] = result + return result + + def _fresh_unbound_dictionary_edge(self, module: PythonModule, value: ast.expr): + """Exact empty allocation and one positional receiver; syntax only.""" + scopes = self.scopes(module) + keyword = scopes.parents.get(value) + call = scopes.parents.get(keyword) + terminal = scopes.parents.get(call) + if not (isinstance(value, ast.Name | ast.Attribute) and isinstance(value.ctx, ast.Load) + and isinstance(keyword, ast.keyword) and keyword.value is value and keyword.arg is not None + and isinstance(call, ast.Call) and call.keywords == [keyword] and len(call.args) == 1 + and isinstance(terminal, ast.Expr) and terminal.value is call and terminal in module.tree.body + and scopes.parents.get(terminal) is module.tree + and isinstance(callee := call.func, ast.Attribute) and isinstance(callee.ctx, ast.Load) + and callee.attr == "__init__" + and isinstance(receiver := call.args[0], ast.Name) and isinstance(receiver.ctx, ast.Load)): + return None + primitive = callee.value + primitive_import = None + if isinstance(primitive, ast.Name) and isinstance(primitive.ctx, ast.Load) and primitive.id == "dict": + if (scopes.enclosing_bindings(evaluation_site(scopes, primitive), "dict") + or module.bindings.get("dict")): + return None + else: + primitive_import = self._source_slot_primitive_import(module, primitive) + if primitive_import is None: + return None + if isinstance(value, ast.Attribute) and not (isinstance(value.value, ast.Name) and isinstance(value.value.ctx, ast.Load)): + return None + rows = module.bindings.get(receiver.id, []) + if len(rows) != 1: + return None + row = rows[0] + if not (row.top_level and isinstance(target := row.node, ast.Name) and isinstance(target.ctx, ast.Store) + and isinstance(allocation := row.statement, ast.Assign) and allocation.targets == [target] + and scopes.parents.get(allocation) is module.tree and allocation.lineno < terminal.lineno + and isinstance(allocation.value, ast.Dict) and not allocation.value.keys and not allocation.value.values + and not module.bindings.get("__all__")): + return None + for node in ast.walk(module.tree): + if isinstance(node, ast.Constant) and node.value == receiver.id: + return None + if isinstance(node, ast.Name) and node.id == receiver.id and node not in {receiver, target}: + lexical = scopes.enclosing_bindings(evaluation_site(scopes, node), node.id) + if not lexical or target in lexical: + return None + return allocation, receiver, call, keyword, primitive_import + + @contextmanager + def fresh_unbound_dictionary_receipt(self, module: PythonModule, value: ast.expr, family: str): + """One local data receipt, never a cached primitive or caller grant.""" + if family not in {"agents", "google.adk"} or self._fresh_unbound_dictionary_edge(module, value) is None: + yield None + return + if self.resolver._checking_fresh_dictionary_primitive: + raise CallLimit("the fresh dictionary primitive receipt was reentered") + self.resolver._checking_fresh_dictionary_primitive = True + proof = BuilderCalls(self.resolver) + try: + layout = self.resolver._layout + if layout is None or layout.disk_root is None: + raise CallLimit("the fresh dictionary receipt has no bound disk currency") + if layout.disk_root / layout.scope != self.resolver.scope_root: + raise CallLimit("the fresh dictionary receipt's disk ancestry does not match the read scope") + session = IdentityBoundReadSession(layout.disk_root, max_entries=200000, max_total_bytes=MAX_TOTAL_BYTES) + private = _FreshPrimitiveResolver(self.resolver, session) + private._checking_fresh_dictionary_primitive = True + proof = BuilderCalls(private) + proof._fresh_identity_session = session + proof._fresh_receipt_active = True + context = proof.namespace_source_context(family) + if not any(current is module for current in context.modules): + raise CallLimit("the fresh dictionary's actual module is outside the captured context") + proof._fresh_context_paths = frozenset(current.path for current in context.modules) + private._fresh_allowed_paths = proof._fresh_context_paths + from agents_shipgate.inputs.list_expressions import ( + _namespace_call_reference, + _qualified_attribute_patches, + _unprovided_root, + _View, + bindings_at, + ) + + values = set() + machinery = { + "eval", "exec", "compile", "__import__", "globals", "locals", "vars", "getattr", "setattr", "delattr", + "type", "object", "ModuleType", "__class__", "__dict__", "__builtins__", "__globals__", "__closure__", + "__getattr__", "__getattribute__", "__setattr__", "__delattr__", "__code__", "__defaults__", + "__kwdefaults__", "__annotations__", "cell_contents", "_getframe", "currentframe", "f_globals", "f_locals", + "modules", "meta_path", "path_hooks", "path", "__path__", "__loader__", "__spec__", + } + for current in context.modules: + if any(marker in {PATH_PATCH, IMPORT_SEARCH_PATCH} or marker.startswith(("*", SELF_PATCH)) for marker in current.attribute_patches): + raise CallLimit(f"{current.ref} has raw namespace or path mutation in the fresh dictionary context") + scopes = proof.scopes(current) + view = _View(current.ref, current.tree, scopes, current.bindings, current, set()) + view.lookup = bindings_at(scopes, current.bindings) + view.resolver = private + patches = _qualified_attribute_patches(view, raw_namespaces=True) + if view.namespace_builtins_changed or any(part in machinery for patch in patches for part in patch.split(".")): + raise CallLimit(f"{current.ref} has unstable primitives or hooks in the fresh dictionary context") + for node in ast.walk(current.tree): + if isinstance(node, ast.ClassDef | ast.Lambda | ast.GeneratorExp): + raise CallLimit(f"{current.ref}:{node.lineno} has an anonymous or class carrier in the fresh dictionary context") + spellings = ({node.id} if isinstance(node, ast.Name) else {node.attr} if isinstance(node, ast.Attribute) + else {node.name, node.asname or ""} if isinstance(node, ast.alias) + else {node.name} if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) + else {node.value} if isinstance(node, ast.Constant) and isinstance(node.value, str) else set()) + if any(part in machinery for spelling in spellings for part in spelling.split(".")): + raise CallLimit(f"{current.ref}:{node.lineno} has reflective machinery in the fresh dictionary context") + for terminal in current.tree.body: + if not (isinstance(terminal, ast.Expr) and isinstance(call := terminal.value, ast.Call) and len(call.keywords) == 1): + continue + candidate = call.keywords[0].value + edge = proof._fresh_unbound_dictionary_edge(current, candidate) + if edge is None: + continue + allocation, _, _, _, primitive_import = edge + if primitive_import is not None: + proof._namespace_import_disk_root(current, primitive_import.statement, "fresh dictionary primitive import") + if not _unprovided_root(view, "builtins") or not proof._namespace_import_root_currency( + current, primitive_import.statement, "fresh dictionary primitive import", + ): + raise CallLimit(f"{current.ref}:{call.lineno} has an unread fresh builtin import root") + for other in context.modules: + if other is current: + continue + _, exported = proof._expanding_imports( + other, {primitive_import.root.id}, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, + ) + if exported: + raise CallLimit(f"{current.ref} has an imported fresh dictionary primitive namespace") + if _namespace_call_reference(view, call) != "builtins.dict.__init__": + raise CallLimit(f"{current.ref}:{call.lineno} has an unread fresh dictionary initialization primitive") + for other in context.modules: + if other is current: + continue + _, exported = proof._expanding_imports( + other, {allocation.targets[0].id}, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, + ) + if exported: + raise CallLimit(f"{current.ref} has an imported fresh dictionary allocation") + values.add(candidate) + if len(values) > MAX_CONTEXTS: + raise CallLimit("the fresh dictionary receipt exceeds its exact value bound") + if value not in values: + yield None + return + receipt = _FreshPrimitiveDictionaryReceipt(proof, frozenset(values)) + yield receipt + except (InputParseError, OSError, ValueError) as exc: + raise CallLimit(f"the fresh dictionary currency could not be established: {exc}") from None + finally: + proof._fresh_receipt_active = False + proof.resolver._checking_fresh_dictionary_primitive = False + self.resolver._checking_fresh_dictionary_primitive = False + + def fresh_dictionary_primitive_import(self, module: PythonModule, node: ast.AST, family: str) -> bool: + """Discharge only one actual Import with an independently uncalled data Function.""" + if not isinstance(node, ast.Import): + return False + candidates = [] + for terminal in module.tree.body: + if not (isinstance(terminal, ast.Expr) and isinstance(call := terminal.value, ast.Call) + and len(call.keywords) == 1): + continue + value = call.keywords[0].value + edge = self._fresh_unbound_dictionary_edge(module, value) + if edge is not None and edge[4] is not None and edge[4].statement is node: + candidates.append(value) + if len(candidates) != 1: + return False + value = candidates[0] + with self.fresh_unbound_dictionary_receipt(module, value, family) as receipt: + if receipt is None: + return False + resolution = receipt.proof.resolve(module, value) + if (not resolution.resolved or resolution.caveats or resolution.module is None + or not isinstance(resolution.definition, ast.FunctionDef | ast.AsyncFunctionDef)): + return False + census = receipt.proof._census( + resolution.module, resolution.definition, allow_empty=True, + confined_dictionary_data=True, unused_namespace_data=True, + confined_primitive_dictionary_values=receipt.values, + ) + if census.sites or census.limits: + return False + receipt.reconfirm() + return True + + def _absent_field_dictionary_edge(self, module: PythonModule, value: ast.expr): + """One direct source import and exact saved dictionary getter; syntax only.""" + scopes = self.scopes(module) + keyword = scopes.parents.get(value) + call = scopes.parents.get(keyword) + terminal = scopes.parents.get(call) + if not (isinstance(value, ast.Attribute) and isinstance(value.ctx, ast.Load) + and isinstance(root := value.value, ast.Name) and isinstance(root.ctx, ast.Load) + and isinstance(keyword, ast.keyword) and keyword.value is value and keyword.arg is not None + and not keyword.arg.startswith("_") and keyword.arg != value.attr + and isinstance(call, ast.Call) and call.keywords == [keyword] + and isinstance(callee := call.func, ast.Attribute) and isinstance(callee.ctx, ast.Load) + and callee.attr in {"__init__", "update"} + and isinstance(terminal, ast.Expr) and terminal.value is call and terminal in module.tree.body): + return None + primitive = None + primitive_import = None + if not call.args and isinstance(callee.value, ast.Name): + receiver = callee.value + elif (callee.attr == "__init__" and len(call.args) == 1 and isinstance(call.args[0], ast.Name) + and isinstance(callee.value, ast.Name) and isinstance(callee.value.ctx, ast.Load) + and callee.value.id == "dict" and not module.bindings.get("dict") + and not scopes.enclosing_bindings(evaluation_site(scopes, callee.value), "dict")): + receiver, primitive = call.args[0], callee.value + elif (callee.attr == "__init__" and len(call.args) == 1 and isinstance(call.args[0], ast.Name) + and (primitive_import := self._source_slot_primitive_import(module, callee.value)) is not None): + receiver, primitive = call.args[0], callee.value + else: + return None + if not ((mapping := self._source_slot_mapping(module, terminal, receiver)) is not None + and ((isinstance(projection := mapping.projection, ast.Call) + and isinstance(getter := projection.func, ast.Name) + and (getter.id == "vars" or getter.id == "getattr" and callee.attr == "update" and not call.args)) + or (isinstance(projection, ast.Attribute) and isinstance(projection.ctx, ast.Load) + and projection.attr == "__dict__" and projection.value is mapping.namespace + and isinstance(mapping.namespace.ctx, ast.Load) + and callee.attr == "update" and not call.args)) + and mapping.namespace.id == root.id and not module.bindings.get("__all__")): + return None + rows = module.bindings.get(root.id, []) + if len(rows) != 1: + return None + row = rows[0] + if not (row.top_level and isinstance(imported := row.node, ast.alias) + and imported.name == root.id and imported.asname is None and "." not in imported.name + and isinstance(statement := row.statement, ast.Import) and statement.names == [imported] + and scopes.parents.get(statement) is module.tree and statement.lineno < mapping.declaration.lineno + and not scopes.enclosing_bindings(evaluation_site(scopes, root), root.id)): + return None + for node in ast.walk(module.tree): + if isinstance(node, ast.Constant) and node.value in {root.id, receiver.id}: + return None + if isinstance(node, ast.Name) and node.id == root.id and node not in {root, mapping.namespace}: + return None + return keyword, call, mapping, statement, imported, root, primitive, primitive_import + + @contextmanager + def absent_field_dictionary_receipt(self, module: PythonModule, value: ast.expr, family: str): + """Independent unread-field data proof; never a same-slot assertion.""" + if family not in {"agents", "google.adk"} or self._absent_field_dictionary_edge(module, value) is None: + yield None + return + if self.resolver._checking_fresh_dictionary_primitive: + raise CallLimit("the absent-field dictionary receipt was reentered") + self.resolver._checking_fresh_dictionary_primitive = True + proof = BuilderCalls(self.resolver) + try: + layout = self.resolver._layout + if layout is None or layout.disk_root is None or layout.disk_root / layout.scope != self.resolver.scope_root: + raise CallLimit("the absent-field dictionary receipt has no bound disk ancestry") + session = IdentityBoundReadSession(layout.disk_root, max_entries=200000, max_total_bytes=MAX_TOTAL_BYTES) + private = _FreshPrimitiveResolver(self.resolver, session) + private._checking_fresh_dictionary_primitive = True + proof = BuilderCalls(private) + proof._fresh_identity_session = session + proof._fresh_receipt_active = True + context = proof.namespace_source_context(family) + if not any(current is module for current in context.modules): + raise CallLimit("the absent-field write module is outside the captured context") + proof._fresh_context_paths = frozenset(current.path for current in context.modules) + private._fresh_allowed_paths = proof._fresh_context_paths + edge = proof._absent_field_dictionary_edge(module, value) + if edge is None: + yield None + return + keyword, call, mapping, statement, _, root, primitive, primitive_import = edge + metadata = proof._source_slot_projection_metadata(mapping.projection) + intrinsic = mapping.projection if isinstance(mapping.projection, ast.Attribute) else None + getter = mapping.projection.func if isinstance(mapping.projection, ast.Call) else None + if isinstance(getter, ast.Name) and getter.id == "getattr" and metadata is None: + raise CallLimit("the selected source getter metadata is not an exact dictionary attribute") + paths, missing = private._imported_paths(module, statement) + if missing or len(paths) != 1 or not private.contains(paths[0]): + raise CallLimit("the absent-field owner is not one readable source module") + home = proof._module(paths[0]) + if not any(current is home for current in context.modules) or keyword.arg in home.bindings: + raise CallLimit("the selected field is present or its source home is unread") + resolution = proof.resolve(module, value) + if (not resolution.resolved or resolution.caveats or resolution.module is not home + or not isinstance(function := resolution.definition, ast.FunctionDef) + or function not in home.tree.body or function.decorator_list or getattr(function, "type_params", [])): + raise CallLimit("the absent-field RHS is not the actual original source Function") + from agents_shipgate.inputs.list_expressions import ( + _namespace_call_reference, + _qualified_attribute_patches, + _unprovided_root, + _View, + bindings_at, + ) + + machinery = { + "eval", "exec", "compile", "__import__", "globals", "locals", "vars", "getattr", "setattr", "delattr", + "type", "object", "ModuleType", "__class__", "__dict__", "__builtins__", "__globals__", "__closure__", + "__getattr__", "__getattribute__", "__setattr__", "__delattr__", "__code__", "__defaults__", + "__kwdefaults__", "__annotations__", "cell_contents", "_getframe", "currentframe", "f_globals", "f_locals", + "modules", "meta_path", "path_hooks", "path", "__path__", "__loader__", "__spec__", "dict", + } + if keyword.arg in machinery: + raise CallLimit("the absent field is primitive or reflective metadata") + for current in context.modules: + if any(marker in {PATH_PATCH, IMPORT_SEARCH_PATCH} or marker.startswith(("*", SELF_PATCH)) for marker in current.attribute_patches): + raise CallLimit(f"{current.ref} has raw namespace mutation in the absent-field context") + scopes = proof.scopes(current) + view = _View(current.ref, current.tree, scopes, current.bindings, current, set()) + view.lookup = bindings_at(scopes, current.bindings) + view.resolver = private + patches = _qualified_attribute_patches(view, raw_namespaces=True) + if view.namespace_builtins_changed or any(part in machinery for patch in patches for part in patch.split(".")): + raise CallLimit(f"{current.ref} has unstable primitives in the absent-field context") + if current is module and primitive_import is not None: + proof._namespace_import_disk_root(current, primitive_import.statement, "absent-field initializer import") + if not _unprovided_root(view, "builtins") or not proof._namespace_import_root_currency( + current, primitive_import.statement, "absent-field initializer import", + ): + raise CallLimit("the absent-field builtin import root is unread") + for other in context.modules: + if other is current: + continue + _, shared = proof._expanding_imports( + other, {primitive_import.root.id}, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, + ) + if shared: + raise CallLimit(f"{other.ref} imports the absent-field builtin namespace") + if current is module and getter is not None and _namespace_call_reference(view, mapping.projection) != f"builtins.{getter.id}": + raise CallLimit("the selected source getter is not canonical") + if current is module and primitive is not None and _namespace_call_reference(view, call) != "builtins.dict.__init__": + raise CallLimit("the selected absent-field initializer is not canonical dict.__init__") + for node in ast.walk(current.tree): + if isinstance(node, ast.ClassDef | ast.Lambda | ast.GeneratorExp): + raise CallLimit(f"{current.ref} has an opaque carrier in the absent-field context") + spellings = ({node.id} if isinstance(node, ast.Name) else {node.attr} if isinstance(node, ast.Attribute) + else {node.name, node.asname or ""} if isinstance(node, ast.alias) + else {node.name} if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) + else {node.arg} if isinstance(node, ast.arg | ast.keyword) + else set(node.names) if isinstance(node, ast.Global | ast.Nonlocal) + else {node.value} if isinstance(node, ast.Constant) and isinstance(node.value, str) else set()) + if node is not keyword and keyword.arg in spellings: + raise CallLimit(f"{current.ref} observes or writes the selected absent field") + if node is not getter and node is not primitive and node is not metadata and node is not intrinsic and any( + part in machinery for spelling in spellings if isinstance(spelling, str) for part in spelling.split(".") + ): + raise CallLimit(f"{current.ref} has reflective machinery in the absent-field context") + if current is not module: + _, writer_shared = proof._expanding_imports( + current, {mapping.receiver.id, root.id}, _module_words(module.path), {module.path}, + subject=module.path, namespace_carriers=True, + ) + if writer_shared: + raise CallLimit(f"{current.ref} imports the writer's mapping or namespace carrier") + for imported in ast.walk(current.tree): + if not isinstance(imported, ast.Import | ast.ImportFrom) or imported is statement: + continue + _, shared = proof._expanding_imports( + current, {function.name, mapping.receiver.id}, _module_words(home.path), {home.path}, + (imported,), subject=home.path, namespace_carriers=True, + ) + if shared: + raise CallLimit(f"{current.ref} imports the changed source namespace") + roles = _AbsentFieldDictionaryRoles( + proof, home, function, value, + frozenset({mapping.namespace, root, mapping.receiver, call.func} + | ({getter} if getter is not None else set()) + | ({primitive} if primitive is not None else set())), + getter_metadata=metadata, intrinsic_projection=intrinsic, + ) + yield _AbsentFieldDictionaryReceipt(proof, roles) + except (InputParseError, OSError, ValueError) as exc: + raise CallLimit(f"the absent-field currency could not be established: {exc}") from None + finally: + proof._fresh_receipt_active = False + proof.resolver._checking_fresh_dictionary_primitive = False + self.resolver._checking_fresh_dictionary_primitive = False + + def _absent_field_dictionary_role( + self, module: PythonModule, node: ast.AST, family: str, *, getter: bool, intrinsic: bool = False, + ) -> bool: + """Admit only the exact getter after its own zero-call data proof.""" + candidates = [] + for terminal in module.tree.body: + if isinstance(terminal, ast.Expr) and isinstance(call := terminal.value, ast.Call) and len(call.keywords) == 1: + value = call.keywords[0].value + edge = self._absent_field_dictionary_edge(module, value) + if edge is not None: + projection = edge[2].projection + selected = (projection if intrinsic and isinstance(projection, ast.Attribute) + else projection.func if getter and isinstance(projection, ast.Call) + else edge[2].namespace if not getter and not intrinsic else None) + if selected is node: + candidates.append(value) + if len(candidates) != 1: + return False + with self.absent_field_dictionary_receipt(module, candidates[0], family) as receipt: + if receipt is None: + return False + census = receipt.census() + if census.sites or census.limits: + return False + receipt.reconfirm() + return True + + def absent_field_dictionary_getter(self, module: PythonModule, node: ast.AST, family: str) -> bool: + return self._absent_field_dictionary_role(module, node, family, getter=True) + + def absent_field_dictionary_namespace(self, module: PythonModule, node: ast.AST, family: str) -> bool: + return self._absent_field_dictionary_role(module, node, family, getter=False) + + def absent_field_dictionary_projection(self, module: PythonModule, node: ast.AST, family: str) -> bool: + """Admit only the selected intrinsic Attribute after its own complete data proof.""" + if not isinstance(node, ast.Attribute) or not isinstance(node.ctx, ast.Load) or node.attr != "__dict__": + return False + return self._absent_field_dictionary_role(module, node, family, getter=False, intrinsic=True) + + def absent_field_dictionary_primitive_import(self, module: PythonModule, node: ast.AST, family: str) -> bool: + """Admit one actual Import only after its own unread-field zero-census.""" + if not isinstance(node, ast.Import): + return False + candidates = [] + for terminal in module.tree.body: + if isinstance(terminal, ast.Expr) and isinstance(call := terminal.value, ast.Call) and len(call.keywords) == 1: + value = call.keywords[0].value + edge = self._absent_field_dictionary_edge(module, value) + if edge is not None and edge[7] is not None and edge[7].statement is node: + candidates.append(value) + if len(candidates) != 1: + return False + with self.absent_field_dictionary_receipt(module, candidates[0], family) as receipt: + if receipt is None: + return False + census = receipt.census() + if census.sites or census.limits: + return False + receipt.reconfirm() + return True + + def confined_dictionary_value(self, module: PythonModule, value: ast.expr) -> bool: + return value in self._confined_dictionary_data(module).values + + def unused_namespace_value(self, module: PythonModule, value: ast.expr) -> bool: + return value in self._unused_namespaces(module).values + + def _source_slot_statement(self, module: PythonModule, node: ast.AST) -> _SourceSlotStatement | None: + scopes = self.scopes(module) + if isinstance(node, ast.Expr | ast.AugAssign) and self._source_slot_edge(module, node) is not None: + return node + ancestor = node + for _ in range(5): + parent = scopes.parents.get(ancestor) + if isinstance(parent, ast.AugAssign) and ancestor in (parent.target, parent.value): + if self._source_slot_edge(module, parent) is not None: + return parent + break + if isinstance(parent, ast.Expr) and parent.value is ancestor: + if self._source_slot_edge(module, parent) is not None: + return parent + break + if not isinstance(parent, ast.Attribute | ast.keyword | ast.Dict | ast.Call): + break + ancestor = parent + statement = node if isinstance(node, ast.Assign) else scopes.parents.get(node) + if (isinstance(statement, ast.Assign) and isinstance(statement.value, ast.Attribute) + and self._source_slot_parts(module, statement) is not None): + return statement + if isinstance(statement, ast.Subscript) and statement.value is node: + owner = scopes.parents.get(statement) + if (isinstance(owner, ast.Assign) and owner.targets == [statement] + and (mapping := self._source_slot_mapping(module, owner)) is not None + and mapping.receiver is node): + return owner + if isinstance(node, ast.Name) and isinstance(scopes.parents.get(node), ast.Attribute): + terminals = [terminal for terminal in module.tree.body if isinstance(terminal, ast.Expr) + and (saved := self._source_saved_bound_ior(module, terminal)) is not None + and saved.receiver is node and self._source_slot_edge(module, terminal) is not None] + if len(terminals) == 1: + return terminals[0] + projection = node if isinstance(node, ast.Attribute | ast.Call) else scopes.parents.get(node) + if (isinstance(projection, ast.Call) + and isinstance(declaration := scopes.parents.get(projection), ast.Assign) + and declaration.value is projection and scopes.parents.get(declaration) is module.tree + and len(module.tree.body) <= MAX_NAMESPACE_CONTEXT_NODES): + terminals = [terminal for terminal in module.tree.body if isinstance(terminal, ast.Assign) + and (mapping := self._source_slot_mapping(module, terminal)) is not None + and mapping.saved_getter is not None and mapping.projection is projection] + if len(terminals) == 1: + return terminals[0] + if (isinstance(projection, ast.Attribute | ast.Call) + and self._source_slot_projection(module, projection) is not None): + target = scopes.parents.get(projection) + owner = scopes.parents.get(target) + if (isinstance(target, ast.Subscript) and target.value is projection + and isinstance(owner, ast.Assign) and owner.targets == [target]): + return owner + if (isinstance(target, ast.Assign) and target.value is projection + and owner is module.tree and len(owner.body) <= MAX_NAMESPACE_CONTEXT_NODES): + terminals = [terminal for terminal in owner.body if isinstance(terminal, ast.Assign | ast.Expr | ast.AugAssign) + and (edge := self._source_slot_edge(module, terminal)) is not None + and (mapping := edge.parts[5]) is not None + and mapping.declaration is target and mapping.projection is projection] + return terminals[0] if len(terminals) == 1 else None + if not (isinstance(node, ast.Name) and isinstance(statement, ast.Assign | ast.AnnAssign) + and statement.value is node): + return None + targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] + body = scopes.parents.get(statement) + if not (len(targets) == 1 and isinstance(targets[0], ast.Name) + and isinstance(body, ast.Module | ast.FunctionDef)): + return None + if isinstance(body, ast.Module) and isinstance(statement, ast.Assign): + names = self._source_from_operator_token_index(module)[0].get(targets[0].id, frozenset()) + loads = [name for name in names if name is not targets[0]] + if len(loads) == 1: + call = scopes.parents.get(loads[0]) + terminal = scopes.parents.get(call) + if (isinstance(call, ast.Call) and call.args and call.args[0] is loads[0] + and isinstance(terminal, ast.Expr) and terminal.value is call + and (receivers := self._source_direct_bare_receivers(module, terminal)) is not None + and receivers[0][1] is node): + return terminal + for terminal in body.body: + if (isinstance(terminal, ast.Assign) + and (parts := self._source_slot_parts(module, terminal)) is not None + and parts[0].id == targets[0].id): + receiver = self._source_slot_receiver(module, parts[0], terminal) + if receiver is not None and receiver[1] is node: + return terminal + return None + + def _source_slot_receiver( + self, module: PythonModule, receiver: ast.Name, terminal: _SourceSlotStatement, + ) -> tuple[ast.Name, ast.Name | None, ast.Name | None] | None: + """One lexical Import, optionally through one confined plain alias.""" + if isinstance(terminal, ast.Expr) and self._source_direct_bare_setter(module, terminal) is not None: + receivers = self._source_direct_bare_receivers(module, terminal) + if receivers is None: + return None + assert isinstance(terminal.value, ast.Call) and isinstance(terminal.value.args[2], ast.Attribute) + if receiver is terminal.value.args[0]: + return receivers[0] + if receiver is terminal.value.args[2].value: + return receivers[1] + return None + scopes = self.scopes(module) + def bindings(node: ast.Name) -> list[ast.AST]: + return scopes.enclosing_bindings(evaluation_site(scopes, node), node.id) or [ + binding.node for binding in module.bindings.get(node.id, []) + ] + found = bindings(receiver) + if len(found) != 1: + return None + original = receiver + initializer = annotation = None + if isinstance(found[0], ast.Name): + if not isinstance(terminal, ast.Assign): + return None # Saved mapping mutation cannot compose a module alias. + target = found[0] + declaration = scopes.parents.get(target) + body = scopes.parents.get(declaration) + if not (isinstance(declaration, ast.Assign | ast.AnnAssign) + and isinstance(declaration.value, ast.Name) + and isinstance(body, ast.Module | ast.FunctionDef) + and scopes.parents.get(terminal) is body and declaration.lineno < terminal.lineno): + return None + if isinstance(declaration, ast.Assign): + if declaration.targets != [target]: + return None + else: + if not (declaration.simple and isinstance(declaration.annotation, ast.Name) + and declaration.annotation.id == "object" + and not bindings(declaration.annotation)): + return None + annotation = declaration.annotation + if isinstance(body, ast.FunctionDef): + _, global_names, nonlocal_names = scopes._scope(body) + if {receiver.id, declaration.value.id} & (global_names | nonlocal_names): + return None + parts = self._source_slot_parts(module, terminal) + if parts is None: + return None + admitted_receivers = set(parts[:2]) + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value == receiver.id: + return None + if isinstance(use, ast.Name) and use.id == receiver.id and bindings(use) == [target]: + if use is not target and use not in admitted_receivers: + return None + initializer = original = declaration.value + found = bindings(original) + if len(found) != 1 or not isinstance(found[0], ast.alias): + return None + imported = scopes.statement_of(found[0]) + if not (isinstance(imported, ast.Import) and imported.lineno < original.lineno + and isinstance(scopes.parents.get(imported), ast.Module | ast.FunctionDef)): + return None + return original, initializer, annotation + + def _source_slot_projection( + self, module: PythonModule, projection: ast.AST, + ) -> ast.Name | None: + """The exact intrinsic getter shape, without an ownership proof.""" + if (isinstance(projection, ast.Attribute) and isinstance(projection.ctx, ast.Load) + and projection.attr == "__dict__" and isinstance(projection.value, ast.Name)): + return projection.value + if (isinstance(projection, ast.Call) and isinstance(projection.func, ast.Name) + and projection.func.id == "vars" and len(projection.args) == 1 and not projection.keywords + and isinstance(projection.args[0], ast.Name) + and not self.scopes(module).enclosing_bindings(evaluation_site(self.scopes(module), projection.func), "vars") + and not module.bindings.get("vars")): + return projection.args[0] + if (isinstance(projection, ast.Call) and isinstance(projection.func, ast.Name) + and isinstance(projection.func.ctx, ast.Load) and projection.func.id == "getattr" + and len(projection.args) == 2 and not projection.keywords + and isinstance(projection.args[0], ast.Name) and isinstance(projection.args[0].ctx, ast.Load) + and isinstance(projection.args[1], ast.Constant) and projection.args[1].value == "__dict__" + and not self.scopes(module).enclosing_bindings(evaluation_site(self.scopes(module), projection.func), "getattr") + and not module.bindings.get("getattr")): + return projection.args[0] + return None + + @staticmethod + def _source_slot_projection_metadata(projection: ast.AST | None) -> ast.Constant | None: + """Exact getter metadata token, separate from the mutation's slot key.""" + if (isinstance(projection, ast.Call) and isinstance(projection.func, ast.Name) + and projection.func.id == "getattr" and len(projection.args) == 2 and not projection.keywords + and isinstance(metadata := projection.args[1], ast.Constant) and metadata.value == "__dict__"): + return metadata + return None + + def _source_slot_primitive_import( + self, module: PythonModule, primitive: ast.AST, + ) -> _SourcePrimitiveImport | None: + scopes = self.scopes(module) + if not (isinstance(primitive, ast.Attribute) and isinstance(primitive.ctx, ast.Load) + and primitive.attr == "dict" and isinstance(root := primitive.value, ast.Name) + and isinstance(root.ctx, ast.Load) and root.id == "builtins" + and not scopes.enclosing_bindings(evaluation_site(scopes, root), root.id)): + return None + rows = module.bindings.get(root.id, []) + if len(rows) != 1: + return None + row = rows[0] + if not (row.top_level and isinstance(imported := row.node, ast.alias) + and imported.name == "builtins" and imported.asname is None + and isinstance(statement := row.statement, ast.Import) and statement.names == [imported] + and scopes.parents.get(imported) is statement and scopes.parents.get(statement) is module.tree + and statement.lineno < primitive.lineno): + return None + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value == root.id: + return None + if isinstance(use, ast.Name) and use.id == root.id and use is not root: + lexical = scopes.enclosing_bindings(evaluation_site(scopes, use), use.id) + if not lexical or imported in lexical: + return None + return _SourcePrimitiveImport(statement, imported, root, primitive) + + def _source_slot_setter_import(self, module: PythonModule, callee: ast.AST) -> _SourceSetterImport | None: + """One exact imported builtin setter spelling; no canonical authority yet.""" + scopes = self.scopes(module) + if not (isinstance(callee, ast.Attribute) and isinstance(callee.ctx, ast.Load) + and callee.attr == "setattr" and isinstance(root := callee.value, ast.Name) + and isinstance(root.ctx, ast.Load) and root.id == "builtins" + and not scopes.enclosing_bindings(evaluation_site(scopes, root), root.id)): + return None + rows = module.bindings.get(root.id, []) + if len(rows) != 1: + return None + row = rows[0] + if not (row.top_level and isinstance(imported := row.node, ast.alias) + and imported.name == "builtins" and imported.asname is None + and isinstance(statement := row.statement, ast.Import) and statement.names == [imported] + and scopes.parents.get(imported) is statement and scopes.parents.get(statement) is module.tree + and statement.lineno < callee.lineno): + return None + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value == root.id: + return None + if isinstance(use, ast.Name) and use.id == root.id and use is not root: + lexical = scopes.enclosing_bindings(evaluation_site(scopes, use), use.id) + if not lexical or imported in lexical: + return None + return _SourceSetterImport(statement, imported, root, callee) + + def _source_slot_saved_setter_import(self, module: PythonModule, callee: ast.AST) -> _SourceSavedSetterImport | None: + """One earlier plain setter handle and its sole terminal call; syntax only.""" + scopes = self.scopes(module) + if not (isinstance(callee, ast.Name) and isinstance(callee.ctx, ast.Load) + and not scopes.enclosing_bindings(evaluation_site(scopes, callee), callee.id)): + return None + rows = module.bindings.get(callee.id, []) + if len(rows) != 1: + return None + row = rows[0] + if not (row.top_level and isinstance(target := row.node, ast.Name) and isinstance(target.ctx, ast.Store) + and isinstance(assignment := row.statement, ast.Assign) and assignment.targets == [target] + and scopes.parents.get(target) is assignment and scopes.parents.get(assignment) is module.tree + and assignment.lineno < callee.lineno + and (record := self._source_slot_setter_import(module, assignment.value)) is not None): + return None + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value == callee.id: + return None + if isinstance(use, ast.Name) and use.id == callee.id and use not in {target, callee}: + return None + return _SourceSavedSetterImport(record.statement, record.imported, record.root, callee, + record.callee, assignment, target) + + def _source_slot_from_setter_import(self, module: PythonModule, callee: ast.AST) -> _SourceFromSetterImport | None: + """One actual absolute setter alias and its sole call; syntax only.""" + scopes = self.scopes(module) + if not (isinstance(callee, ast.Name) and isinstance(callee.ctx, ast.Load) + and not callee.id.startswith("_") and callee.id not in {"setattr", "vars", "getattr", "dict", "builtins"} + and not scopes.enclosing_bindings(evaluation_site(scopes, callee), callee.id)): + return None + rows = module.bindings.get(callee.id, []) + if len(rows) != 1: + return None + row = rows[0] + if not (row.top_level and isinstance(imported := row.node, ast.alias) + and imported.name == "setattr" and imported.asname == callee.id + and isinstance(statement := row.statement, ast.ImportFrom) and statement.level == 0 + and statement.module == "builtins" and statement.names == [imported] + and scopes.parents.get(imported) is statement and scopes.parents.get(statement) is module.tree + and statement in module.tree.body and statement.lineno < callee.lineno): + return None + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value == callee.id: + return None + if isinstance(use, ast.Name) and use.id == callee.id and use is not callee: + return None + return _SourceFromSetterImport(statement, imported, callee) + + def _source_slot_bare_setter(self, module: PythonModule, callee: ast.AST) -> _SourceBareSetter | None: + """One unshadowed bare setter initializer and its sole saved terminal.""" + scopes = self.scopes(module) + if not (isinstance(callee, ast.Name) and isinstance(callee.ctx, ast.Load) + and not scopes.enclosing_bindings(evaluation_site(scopes, callee), callee.id)): + return None + rows = module.bindings.get(callee.id, []) + if len(rows) != 1: + return None + row = rows[0] + if not (row.top_level and isinstance(target := row.node, ast.Name) and isinstance(target.ctx, ast.Store) + and isinstance(assignment := row.statement, ast.Assign) and assignment.targets == [target] + and scopes.parents.get(target) is assignment and scopes.parents.get(assignment) is module.tree + and assignment in module.tree.body and assignment.lineno < callee.lineno + and isinstance(initializer := assignment.value, ast.Name) and isinstance(initializer.ctx, ast.Load) + and initializer.id == "setattr" and not module.bindings.get("setattr") + and not scopes.enclosing_bindings(evaluation_site(scopes, initializer), initializer.id)): + return None + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value in {callee.id, initializer.id}: + return None + if isinstance(use, ast.Name) and use.id == callee.id and use not in {target, callee}: + return None + if isinstance(use, ast.Name) and use.id == initializer.id and use is not initializer: + return None + return _SourceBareSetter(assignment, target, initializer, callee) + + def _source_direct_bare_setter(self, module: PythonModule, statement: ast.AST) -> _SourceDirectBareSetter | None: + """Exact physical syntax; builtin identity and Function ownership remain owed.""" + if len(module.tree.body) > MAX_NAMESPACE_CONTEXT_NODES: + return None + positions = self._source_operator_positions.get(module.tree) + if positions is None: + positions = self._source_operator_positions[module.tree] = { + node: index for index, node in enumerate(module.tree.body) + } + scopes = self.scopes(module) + if not (isinstance(statement, ast.Expr) and scopes.parents.get(statement) is module.tree + and statement in positions and isinstance(call := statement.value, ast.Call) + and len(call.args) == 3 and not call.keywords + and isinstance(callee := call.func, ast.Name) and isinstance(callee.ctx, ast.Load) + and callee.id == "setattr" and not module.bindings.get(callee.id) + and not scopes.enclosing_bindings(evaluation_site(scopes, callee), callee.id) + and isinstance(receiver := call.args[0], ast.Name) and isinstance(receiver.ctx, ast.Load) + and isinstance(key := call.args[1], ast.Constant) and isinstance(key.value, str) + and isinstance(value := call.args[2], ast.Attribute) and isinstance(value.ctx, ast.Load) + and isinstance(value.value, ast.Name) and isinstance(value.value.ctx, ast.Load) + and key.value == value.attr and not value.attr.startswith("_") + and not module.bindings.get("__all__")): + return None + return _SourceDirectBareSetter(callee) + + def _source_direct_bare_receivers( + self, module: PythonModule, terminal: ast.Expr, + ) -> tuple[tuple[ast.Name, ast.Name | None, None], tuple[ast.Name, None, None]] | None: + """Confine only this direct setter's two actual source namespace reads.""" + if self._source_direct_bare_setter(module, terminal) is None: + return None + call = terminal.value + assert isinstance(call, ast.Call) and isinstance(call.args[2], ast.Attribute) + left, right = call.args[0], call.args[2].value + assert isinstance(left, ast.Name) and isinstance(right, ast.Name) + scopes = self.scopes(module) + positions = self._source_operator_positions[module.tree] + rows = module.bindings.get(right.id, []) + if not (len(rows) == 1 and rows[0].top_level + and isinstance(imported := rows[0].node, ast.alias) + and imported.name == right.id and imported.asname is None and "." not in right.id + and isinstance(origin := rows[0].statement, ast.Import) and origin.names == [imported] + and scopes.parents.get(origin) is module.tree and origin in positions + and positions[origin] < positions[terminal] and origin.lineno < terminal.lineno + and not scopes.enclosing_bindings(evaluation_site(scopes, right), right.id)): + return None + tokens = self._source_from_operator_token_index(module) + initializer = None + imported_receiver = False + if left.id != right.id: + rows = module.bindings.get(left.id, []) + if len(rows) != 1 or not rows[0].top_level: + return None + if isinstance(receiver_alias := rows[0].node, ast.alias): + receiver_origin = rows[0].statement + if not (receiver_alias.name == imported.name and receiver_alias.asname == left.id + and left.id not in {right.id, "setattr"} and not left.id.startswith("_") + and isinstance(receiver_origin, ast.Import) and receiver_origin.names == [receiver_alias] + and scopes.parents.get(receiver_origin) is module.tree and receiver_origin in positions + and positions[receiver_origin] < positions[terminal] + and receiver_origin.lineno < terminal.lineno + and tokens[0].get(left.id) == {left} and not tokens[1].get(left.id)): + return None + imported_receiver = True + elif not (isinstance(target := rows[0].node, ast.Name) and isinstance(target.ctx, ast.Store) + and isinstance(declaration := rows[0].statement, ast.Assign) and declaration.targets == [target] + and scopes.parents.get(target) is declaration and scopes.parents.get(declaration) is module.tree + and declaration in positions and positions[origin] < positions[declaration] < positions[terminal] + and origin.lineno < declaration.lineno < terminal.lineno + and isinstance(initializer := declaration.value, ast.Name) and isinstance(initializer.ctx, ast.Load) + and initializer.id == right.id + and not scopes.enclosing_bindings(evaluation_site(scopes, initializer), initializer.id) + and tokens[0].get(left.id) == {target, left} and not tokens[1].get(left.id)): + return None + expected_source_reads = {right} if imported_receiver else {initializer or left, right} + if (scopes.enclosing_bindings(evaluation_site(scopes, left), left.id) + or tokens[0].get(right.id) != expected_source_reads or tokens[1].get(right.id)): + return None + return (initializer or left, initializer, None), (right, None, None) + + def _source_slot_setter_edge(self, module: PythonModule, statement: ast.Expr) -> _SourceSlotEdge | None: + """Direct source same-slot setter syntax, separate from dictionary methods.""" + scopes = self.scopes(module) + if not (scopes.parents.get(statement) is module.tree and statement in module.tree.body + and isinstance(call := statement.value, ast.Call) and len(call.args) == 3 and not call.keywords + and (record := (self._source_slot_setter_import(module, call.func) + or self._source_slot_saved_setter_import(module, call.func) + or self._source_slot_from_setter_import(module, call.func) + or self._source_slot_bare_setter(module, call.func))) is not None + and isinstance(receiver := call.args[0], ast.Name) and isinstance(receiver.ctx, ast.Load) + and isinstance(key := call.args[1], ast.Constant) and isinstance(key.value, str) + and isinstance(value := call.args[2], ast.Attribute) and isinstance(value.ctx, ast.Load) + and isinstance(root := value.value, ast.Name) and isinstance(root.ctx, ast.Load) + and root.id == receiver.id and not value.attr.startswith("_") and key.value == value.attr + and not module.bindings.get("__all__")): + return None + rows = module.bindings.get(receiver.id, []) + if len(rows) != 1: + return None + row = rows[0] + if not (row.top_level and isinstance(imported := row.node, ast.alias) + and imported.name == receiver.id and imported.asname is None and "." not in imported.name + and isinstance(source_import := row.statement, ast.Import) and source_import.names == [imported] + and scopes.parents.get(source_import) is module.tree and source_import.lineno < statement.lineno + and not scopes.enclosing_bindings(evaluation_site(scopes, receiver), receiver.id)): + return None + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value == receiver.id: + return None + if isinstance(use, ast.Name) and use.id == receiver.id and use not in {receiver, root}: + return None + parts = (receiver, root, value.attr, None, key, None) + return _SourceSlotEdge(statement, parts, value, target=receiver, callee=record.callee, call=call, + setter_import=None if isinstance(record, _SourceBareSetter) else record, + bare_setter=record if isinstance(record, _SourceBareSetter) else None) + + def _source_slot_setitem_parts( + self, module: PythonModule, call: ast.Call, + ) -> tuple[ast.Name, ast.Constant, ast.Attribute] | None: + """Only the bare positional spelling; canonical identity is still unproved.""" + if not (isinstance(callee := call.func, ast.Attribute) and isinstance(callee.ctx, ast.Load) + and callee.attr == "__setitem__" and isinstance(primitive := callee.value, ast.Name) + and isinstance(primitive.ctx, ast.Load) and primitive.id == "dict" + and len(call.args) == 3 and not call.keywords + and isinstance(receiver := call.args[0], ast.Name) and isinstance(receiver.ctx, ast.Load) + and isinstance(key := call.args[1], ast.Constant) and isinstance(key.value, str) + and isinstance(value := call.args[2], ast.Attribute) and isinstance(value.ctx, ast.Load)): + return None + scopes = self.scopes(module) + if (scopes.enclosing_bindings(evaluation_site(scopes, primitive), "dict") + or module.bindings.get("dict")): + return None + return receiver, key, value + + def _source_slot_mutator_receiver( + self, module: PythonModule, call: ast.Call, + ) -> tuple[ast.Name, ast.Name | ast.Attribute | None] | None: + """Recover syntax only; unbound primitive identity still needs raw proof.""" + if (positional := self._source_slot_setitem_parts(module, call)) is not None: + return positional[0], call.func.value + callee = call.func + if isinstance(callee, ast.Attribute) and callee.attr == "__ior__": + if (isinstance(callee.ctx, ast.Load) and isinstance(owner := callee.value, ast.Name) + and isinstance(owner.ctx, ast.Load) and len(call.args) == 1 and not call.keywords + and isinstance(payload := call.args[0], ast.Dict) + and len(payload.keys) == 1 and len(payload.values) == 1 + and isinstance(key := payload.keys[0], ast.Constant) and isinstance(key.value, str) + and isinstance(value := payload.values[0], ast.Attribute) and isinstance(value.ctx, ast.Load) + and isinstance(value.value, ast.Name) and isinstance(value.value.ctx, ast.Load) + and not value.attr.startswith("_") and key.value == value.attr): + return owner, None # Only a bound singleton literal; never an unbound primitive. + return None + if not (isinstance(callee, ast.Attribute) and isinstance(callee.ctx, ast.Load) + and callee.attr in _SOURCE_MAPPING_MUTATORS): + return None + owner = callee.value + if (callee.attr in _SOURCE_MAPPING_MUTATORS and call.args + and isinstance(receiver := call.args[0], ast.Name) and isinstance(receiver.ctx, ast.Load) + and self._source_slot_primitive_import(module, owner) is not None): + return receiver, owner + if not isinstance(owner, ast.Name) or not isinstance(owner.ctx, ast.Load): + return None + scopes = self.scopes(module) + if (owner.id == "dict" and callee.attr == "__init__" and call.args + and isinstance(receiver := call.args[0], ast.Name) and isinstance(receiver.ctx, ast.Load) + and not scopes.enclosing_bindings(evaluation_site(scopes, owner), "dict") + and not module.bindings.get("dict")): + return receiver, owner + return owner, None + + def _saved_source_vars_getter( + self, module: PythonModule, projection: ast.AST, declaration: ast.Assign, + ) -> _SavedSourceVarsGetter | None: + """Syntax only; restricted to a separate, saved mapping Store route.""" + scopes = self.scopes(module) + if not (isinstance(projection, ast.Call) and projection is declaration.value + and scopes.parents.get(projection) is declaration + and scopes.parents.get(declaration) is module.tree and declaration in module.tree.body + and isinstance(callee := projection.func, ast.Name) and isinstance(callee.ctx, ast.Load) + and len(projection.args) == 1 and not projection.keywords + and isinstance(namespace := projection.args[0], ast.Name) and isinstance(namespace.ctx, ast.Load) + and not scopes.enclosing_bindings(evaluation_site(scopes, callee), callee.id)): + return None + rows = module.bindings.get(callee.id, []) + if len(rows) != 1: + return None + row = rows[0] + if not (row.top_level and isinstance(target := row.node, ast.Name) and isinstance(target.ctx, ast.Store) + and isinstance(assignment := row.statement, ast.Assign) and assignment.targets == [target] + and scopes.parents.get(target) is assignment and scopes.parents.get(assignment) is module.tree + and assignment in module.tree.body and assignment.lineno < declaration.lineno + and isinstance(initializer := assignment.value, ast.Name) and isinstance(initializer.ctx, ast.Load) + and initializer.id == "vars" and not module.bindings.get("vars") + and not scopes.enclosing_bindings(evaluation_site(scopes, initializer), initializer.id)): + return None + source = module.bindings.get(namespace.id, []) + if not (len(source) == 1 and source[0].top_level + and isinstance(imported := source[0].node, ast.alias) + and imported.name == namespace.id and imported.asname is None and "." not in imported.name + and isinstance(statement := source[0].statement, ast.Import) and statement.names == [imported] + and scopes.parents.get(statement) is module.tree and statement.lineno < declaration.lineno): + return None + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value in {callee.id, initializer.id}: + return None + if isinstance(use, ast.Name) and use.id == callee.id and use not in {target, callee}: + return None + if isinstance(use, ast.Name) and use.id == initializer.id and use is not initializer: + return None + return _SavedSourceVarsGetter(assignment, target, initializer, projection, callee) + + def _source_saved_bound_ior( + self, module: PythonModule, terminal: ast.Expr, + ) -> _SourceSavedBoundIor | None: + """Syntax only: a physical discarded call through one saved method.""" + scopes = self.scopes(module) + if not (scopes.parents.get(terminal) is module.tree and terminal in module.tree.body + and isinstance(call := terminal.value, ast.Call) + and isinstance(callee := call.func, ast.Name) and isinstance(callee.ctx, ast.Load) + and len(call.args) == 1 and not call.keywords + and isinstance(payload := call.args[0], ast.Dict) + and len(payload.keys) == 1 and len(payload.values) == 1 + and isinstance(key := payload.keys[0], ast.Constant) and isinstance(key.value, str) + and isinstance(value := payload.values[0], ast.Attribute) and isinstance(value.ctx, ast.Load) + and isinstance(value.value, ast.Name) and isinstance(value.value.ctx, ast.Load) + and not value.attr.startswith("_") and key.value == value.attr + and not scopes.enclosing_bindings(evaluation_site(scopes, callee), callee.id)): + return None + rows = module.bindings.get(callee.id, []) + if len(rows) != 1: + return None + row = rows[0] + if not (row.top_level and isinstance(target := row.node, ast.Name) and isinstance(target.ctx, ast.Store) + and isinstance(assignment := row.statement, ast.Assign) and assignment.targets == [target] + and scopes.parents.get(target) is assignment and scopes.parents.get(assignment) is module.tree + and assignment in module.tree.body and assignment.lineno < terminal.lineno + and isinstance(initializer := assignment.value, ast.Attribute) and isinstance(initializer.ctx, ast.Load) + and initializer.attr == "__ior__" and isinstance(receiver := initializer.value, ast.Name) + and isinstance(receiver.ctx, ast.Load)): + return None + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value == callee.id: + return None + if isinstance(use, ast.Name) and use.id == callee.id and use not in {target, callee}: + return None + return _SourceSavedBoundIor(assignment, target, initializer, receiver, callee, call, payload) + + def _source_slot_mapping( + self, module: PythonModule, terminal: ast.Assign | ast.Expr, receiver: ast.Name | None = None, + ) -> _SavedSourceMapping | None: + """One plain saved mapping declaration, with exactly one mutation use.""" + scopes = self.scopes(module) + saved = None + if scopes.parents.get(terminal) is not module.tree: + return None + if isinstance(terminal, ast.Assign): + if not (receiver is None and len(terminal.targets) == 1 + and isinstance(target := terminal.targets[0], ast.Subscript) + and isinstance(target.ctx, ast.Store) and isinstance(receiver := target.value, ast.Name)): + return None + else: + saved = self._source_saved_bound_ior(module, terminal) + if saved is not None: + if saved.receiver is not receiver: + return None + elif not (isinstance(terminal.value, ast.Call) + and (mutator := self._source_slot_mutator_receiver(module, terminal.value)) is not None + and mutator[0] is receiver): + return None + if not isinstance(receiver, ast.Name) or not isinstance(receiver.ctx, ast.Load): + return None + def bindings(node: ast.Name) -> list[ast.AST]: + return scopes.enclosing_bindings(evaluation_site(scopes, node), node.id) or [ + binding.node for binding in module.bindings.get(node.id, []) + ] + found = bindings(receiver) + if len(found) != 1 or not isinstance(alias := found[0], ast.Name): + return None + declaration = scopes.parents.get(alias) + if not (isinstance(declaration, ast.Assign) and declaration.targets == [alias] + and scopes.parents.get(declaration) is module.tree and declaration.lineno < terminal.lineno + and isinstance(projection := declaration.value, ast.Attribute | ast.Call)): + return None + namespace = self._source_slot_projection(module, projection) + getter = None + if namespace is None and isinstance(terminal, ast.Assign): + getter = self._saved_source_vars_getter(module, projection, declaration) + if getter is not None: + namespace = getter.projection.args[0] + if namespace is None: + return None + if saved is not None: + if not (isinstance(projection, ast.Attribute) and projection.attr == "__dict__" + and declaration in module.tree.body + and module.tree.body.index(declaration) < module.tree.body.index(saved.assignment) + < module.tree.body.index(terminal) and declaration.lineno < saved.assignment.lineno): + return None + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value == receiver.id: + return None + if (saved is not None + and isinstance(use, ast.Name) and use.id == receiver.id and use not in {alias, receiver}): + return None + if isinstance(use, ast.Name) and use.id == receiver.id and bindings(use) == [alias]: + if use is not alias and use is not receiver: + return None + return _SavedSourceMapping(declaration, alias, receiver, projection, namespace, getter) + + def _source_slot_augmented_mapping(self, module: PythonModule, terminal: ast.AugAssign) -> _SavedSourceMapping | None: + """Exactly the declaration and this augmented target bind the mapping.""" + scopes = self.scopes(module) + receiver = terminal.target + if not (scopes.parents.get(terminal) is module.tree and terminal in module.tree.body + and isinstance(terminal.op, ast.BitOr) and isinstance(receiver, ast.Name) + and scopes.parents.get(receiver) is terminal + and isinstance(receiver.ctx, ast.Store) + and not scopes.enclosing_bindings(evaluation_site(scopes, receiver), receiver.id)): + return None + rows = module.bindings.get(receiver.id, []) + if len(rows) != 2: + return None + augmented = [row for row in rows if row.node is receiver and row.statement is terminal] + earlier = [row for row in rows if row.node is not receiver] + if len(augmented) != 1 or len(earlier) != 1: + return None + row = earlier[0] + alias, declaration = row.node, row.statement + if not (row.top_level and isinstance(alias, ast.Name) and isinstance(alias.ctx, ast.Store) + and isinstance(declaration, ast.Assign) and declaration.targets == [alias] + and scopes.parents.get(alias) is declaration and scopes.parents.get(declaration) is module.tree + and declaration.lineno < terminal.lineno + and isinstance(projection := declaration.value, ast.Attribute | ast.Call) + and (namespace := self._source_slot_projection(module, projection)) is not None): + return None + domain = {alias, receiver} + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value == receiver.id: + return None + if isinstance(use, ast.Name) and use.id == receiver.id and use not in domain: + lexical = scopes.enclosing_bindings(evaluation_site(scopes, use), use.id) + if not lexical or domain.intersection(lexical): + return None + return _SavedSourceMapping(declaration, alias, receiver, projection, namespace) + + def _source_slot_parts( + self, module: PythonModule, statement: ast.Assign, + ) -> _SourceSlotParts | None: + """Exact attribute slot, or intrinsic dictionary slot on direct imports. + + The intrinsic dictionary role is source-only and remains conditional + on the independent raw context and empty census in the caller. + """ + if not (len(statement.targets) == 1 and isinstance(value := statement.value, ast.Attribute) + and isinstance(value.ctx, ast.Load) and isinstance(value.value, ast.Name) + and not value.attr.startswith("_")): + return None + target = statement.targets[0] + if (isinstance(target, ast.Attribute) and isinstance(target.ctx, ast.Store) + and target.attr == value.attr and isinstance(target.value, ast.Name)): + return target.value, value.value, value.attr, None, None, None + if not (isinstance(target, ast.Subscript) and isinstance(target.ctx, ast.Store) + and isinstance(key := target.slice, ast.Constant) and key.value == value.attr + and self.scopes(module).parents.get(statement) is module.tree): + return None + projection = target.value + mapping = self._source_slot_mapping(module, statement) if isinstance(projection, ast.Name) else None + if mapping is not None: + projection = mapping.projection + namespace = mapping.namespace if mapping is not None else self._source_slot_projection(module, projection) + if namespace is None: + return None + if mapping is not None and mapping.saved_getter is not None and value.value.id != namespace.id: + return None # This getter composition uses one direct source Import on both sides. + if self._source_slot_projection_metadata(projection) is not None and mapping is None: + return None # This getter route requires the independently confined saved mapping. + assert isinstance(projection, ast.Attribute | ast.Call) + return namespace, value.value, value.attr, projection, key, mapping + + def _source_operator_setitem(self, module: PythonModule, statement: ast.AST) -> _SourceOperatorSetitem | None: + """Extract one discarded physical call; native provenance is not syntax.""" + if len(module.tree.body) > MAX_NAMESPACE_CONTEXT_NODES or not isinstance(statement, ast.Expr): + return None + positions = self._source_operator_positions.get(module.tree) + if positions is None: + positions = self._source_operator_positions[module.tree] = { + node: index for index, node in enumerate(module.tree.body) + } + scopes = self.scopes(module) + if not (len(module.tree.body) <= MAX_NAMESPACE_CONTEXT_NODES + and isinstance(statement, ast.Expr) and scopes.parents.get(statement) is module.tree + and statement in positions and isinstance(call := statement.value, ast.Call) + and not call.keywords and len(call.args) == 3 + and isinstance(callee := call.func, ast.Attribute) and isinstance(callee.ctx, ast.Load) + and callee.attr == "setitem" and isinstance(operator := callee.value, ast.Name) + and isinstance(operator.ctx, ast.Load) and operator.id == "operator" + and isinstance(projection := call.args[0], ast.Attribute) and isinstance(projection.ctx, ast.Load) + and projection.attr == "__dict__" and isinstance(namespace := projection.value, ast.Name) + and isinstance(namespace.ctx, ast.Load) + and isinstance(key := call.args[1], ast.Constant) and isinstance(key.value, str) + and isinstance(rhs := call.args[2], ast.Attribute) and isinstance(rhs.ctx, ast.Load) + and isinstance(rhs.value, ast.Name) and isinstance(rhs.value.ctx, ast.Load) + and rhs.value.id == namespace.id and key.value == rhs.attr and not rhs.attr.startswith("_")): + return None + origin = None + for name in (operator.id, namespace.id): + rows = module.bindings.get(name, []) + if not (len(rows) == 1 and rows[0].top_level and isinstance(imported := rows[0].node, ast.alias) + and imported.name == name and imported.asname is None and "." not in name + and isinstance(declaration := rows[0].statement, ast.Import) + and declaration.names == [imported] and scopes.parents.get(declaration) is module.tree + and declaration in positions and positions[declaration] < positions[statement] + and declaration.lineno < statement.lineno): + return None + if name == operator.id: + origin = (declaration, imported) + if namespace.id == operator.id or origin is None: + return None + tokens = self._source_operator_tokens.get(module.tree) + if tokens is None: + # Structural evidence only, shared across candidate extraction in + # this immutable tree. Native/owner verdicts are never cached here. + names: set[ast.Name] = set() + strings: set[ast.Constant] = set() + for node in ast.walk(module.tree): + if isinstance(node, ast.Name) and node.id == operator.id: + names.add(node) + elif isinstance(node, ast.Constant) and node.value == operator.id: + strings.add(node) + tokens = self._source_operator_tokens[module.tree] = (frozenset(names), frozenset(strings)) + if tokens[0] != {operator} or tokens[1] - {key}: + return None + return _SourceOperatorSetitem(*origin, operator, callee, call, projection, key, rhs) + + def _source_direct_operator_ior(self, module: PythonModule, statement: ast.AST) -> _SourceDirectOperatorIor | None: + """Recognize exact inline syntax; the independent slot proof grants its role.""" + if len(module.tree.body) > MAX_NAMESPACE_CONTEXT_NODES or not isinstance(statement, ast.Expr): + return None + scopes = self.scopes(module) + if not (scopes.parents.get(statement) is module.tree + and isinstance(call := statement.value, ast.Call) and not call.keywords and len(call.args) == 2 + and isinstance(callee := call.func, ast.Attribute) and isinstance(callee.ctx, ast.Load) + and callee.attr == "ior" and isinstance(operator := callee.value, ast.Name) + and isinstance(operator.ctx, ast.Load) and operator.id == "operator" + and isinstance(projection := call.args[0], ast.Attribute) and isinstance(projection.ctx, ast.Load) + and projection.attr == "__dict__" and isinstance(namespace := projection.value, ast.Name) + and isinstance(namespace.ctx, ast.Load) and namespace.id != "operator" + and isinstance(payload := call.args[1], ast.Dict) and len(payload.keys) == len(payload.values) == 1 + and isinstance(key := payload.keys[0], ast.Constant) and isinstance(key.value, str) + and isinstance(rhs := payload.values[0], ast.Attribute) and isinstance(rhs.ctx, ast.Load) + and isinstance(rhs.value, ast.Name) and isinstance(rhs.value.ctx, ast.Load) + and namespace.id == rhs.value.id and key.value == rhs.attr and not rhs.attr.startswith("_")): + return None + tokens = self._source_operator_tokens.get(module.tree) + if tokens is None: + names: set[ast.Name] = set() + strings: set[ast.Constant] = set() + for node in ast.walk(module.tree): + if isinstance(node, ast.Name) and node.id == "operator": + names.add(node) + elif isinstance(node, ast.Constant) and node.value == "operator": + strings.add(node) + tokens = self._source_operator_tokens[module.tree] = (frozenset(names), frozenset(strings)) + if tokens[0] != {operator} or tokens[1] - {key}: + return None + origin = None + for name in ("operator", namespace.id): + bindings = module.bindings.get(name, []) + if not (len(bindings) == 1 and bindings[0].top_level + and isinstance(imported := bindings[0].node, ast.alias) + and imported.name == name and imported.asname is None and "." not in name + and isinstance(source := bindings[0].statement, ast.Import) and source.names == [imported] + and scopes.parents.get(source) is module.tree and source.lineno < statement.lineno): + return None + if name == "operator": + origin = (source, imported) + assert origin is not None + return _SourceDirectOperatorIor(*origin, operator, callee, call, projection, payload, key, rhs) + + def _source_operator_ior(self, module: PythonModule, statement: ast.AST) -> _SourceOperatorIor | _SourceDirectOperatorIor | None: + """One discarded native-operator syntax candidate, without a semantic grant.""" + if (direct := self._source_direct_operator_ior(module, statement)) is not None: + return direct + if len(module.tree.body) > MAX_NAMESPACE_CONTEXT_NODES or not isinstance(statement, ast.Expr): + return None + positions = self._source_operator_positions.get(module.tree) + if positions is None: + positions = self._source_operator_positions[module.tree] = { + node: index for index, node in enumerate(module.tree.body) + } + scopes = self.scopes(module) + if not (scopes.parents.get(statement) is module.tree and statement in positions + and isinstance(call := statement.value, ast.Call) and not call.keywords and len(call.args) == 2 + and isinstance(callee := call.func, ast.Attribute) and isinstance(callee.ctx, ast.Load) + and callee.attr == "ior" and isinstance(operator := callee.value, ast.Name) + and isinstance(operator.ctx, ast.Load) and operator.id == "operator" + and isinstance(receiver := call.args[0], ast.Name) and isinstance(receiver.ctx, ast.Load) + and isinstance(payload := call.args[1], ast.Dict) and len(payload.keys) == len(payload.values) == 1 + and isinstance(key := payload.keys[0], ast.Constant) and isinstance(key.value, str) + and isinstance(rhs := payload.values[0], ast.Attribute) and isinstance(rhs.ctx, ast.Load) + and isinstance(rhs.value, ast.Name) and isinstance(rhs.value.ctx, ast.Load) + and key.value == rhs.attr and not rhs.attr.startswith("_")): + return None + tokens = self._source_operator_tokens.get(module.tree) + if tokens is None: + names: set[ast.Name] = set() + strings: set[ast.Constant] = set() + for node in ast.walk(module.tree): + if isinstance(node, ast.Name) and node.id == "operator": + names.add(node) + elif isinstance(node, ast.Constant) and node.value == "operator": + strings.add(node) + tokens = self._source_operator_tokens[module.tree] = (frozenset(names), frozenset(strings)) + if tokens[0] != {operator} or tokens[1] - {key}: + return None + rows = module.bindings.get(receiver.id, []) + if not (len(rows) == 1 and rows[0].top_level + and isinstance(target := rows[0].node, ast.Name) and isinstance(target.ctx, ast.Store) + and isinstance(declaration := rows[0].statement, ast.Assign) and declaration.targets == [target] + and scopes.parents.get(target) is declaration and scopes.parents.get(declaration) is module.tree + and declaration in positions and positions[declaration] < positions[statement] + and declaration.lineno < statement.lineno + and isinstance(projection := declaration.value, ast.Attribute) and isinstance(projection.ctx, ast.Load) + and projection.attr == "__dict__" and isinstance(namespace := projection.value, ast.Name) + and isinstance(namespace.ctx, ast.Load) and namespace.id == rhs.value.id + and namespace.id != "operator" and receiver.id not in {namespace.id, "operator"}): + return None + origin = None + for name, before in (("operator", statement), (namespace.id, declaration)): + bindings = module.bindings.get(name, []) + if not (len(bindings) == 1 and bindings[0].top_level + and isinstance(imported := bindings[0].node, ast.alias) + and imported.name == name and imported.asname is None and "." not in name + and isinstance(source := bindings[0].statement, ast.Import) and source.names == [imported] + and scopes.parents.get(source) is module.tree and source in positions + and positions[source] < positions[before] and source.lineno < before.lineno): + return None + if name == "operator": + origin = (source, imported) + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value == receiver.id: + return None + if isinstance(use, ast.Name) and use.id == receiver.id and use not in {target, receiver}: + return None + assert origin is not None + mapping = _SavedSourceMapping(declaration, target, receiver, projection, namespace) + return _SourceOperatorIor(*origin, operator, callee, call, mapping, payload, key, rhs) + + def _source_from_operator_token_index( + self, module: PythonModule, + ) -> tuple[dict[str, frozenset[ast.Name]], dict[str, frozenset[ast.Constant]]]: + """Structural nodes only, shared without native or ownership verdicts.""" + tokens = self._source_from_operator_tokens.get(module.tree) + if tokens is None: + names: dict[str, set[ast.Name]] = {} + strings: dict[str, set[ast.Constant]] = {} + for node in ast.walk(module.tree): + if isinstance(node, ast.Name): + names.setdefault(node.id, set()).add(node) + elif isinstance(node, ast.Constant) and isinstance(node.value, str): + strings.setdefault(node.value, set()).add(node) + tokens = self._source_from_operator_tokens[module.tree] = ( + {key: frozenset(values) for key, values in names.items()}, + {key: frozenset(values) for key, values in strings.items()}, + ) + return tokens + + def _source_from_operator_ior(self, module: PythonModule, statement: ast.AST) -> _SourceFromOperatorIor | None: + """One discarded native-operator syntax candidate, without a semantic grant.""" + if len(module.tree.body) > MAX_NAMESPACE_CONTEXT_NODES or not isinstance(statement, ast.Expr): + return None + positions = self._source_operator_positions.get(module.tree) + if positions is None: + positions = self._source_operator_positions[module.tree] = { + node: index for index, node in enumerate(module.tree.body) + } + scopes = self.scopes(module) + if not (scopes.parents.get(statement) is module.tree and statement in positions + and isinstance(call := statement.value, ast.Call) and not call.keywords and len(call.args) == 2 + and isinstance(callee := call.func, ast.Name) and isinstance(callee.ctx, ast.Load) + and callee.id not in {"ior", "dict", "operator", "vars", "getattr", "setattr", "eval", "exec", "__import__"} + and not callee.id.startswith("_") + and isinstance(receiver := call.args[0], ast.Name) and isinstance(receiver.ctx, ast.Load) + and isinstance(payload := call.args[1], ast.Dict) and len(payload.keys) == len(payload.values) == 1 + and isinstance(key := payload.keys[0], ast.Constant) and isinstance(key.value, str) + and isinstance(rhs := payload.values[0], ast.Attribute) and isinstance(rhs.ctx, ast.Load) + and isinstance(rhs.value, ast.Name) and isinstance(rhs.value.ctx, ast.Load) + and key.value == rhs.attr and not rhs.attr.startswith("_")): + return None + tokens = self._source_from_operator_token_index(module) + if (tokens[0].get(callee.id) != {callee} or tokens[1].get(callee.id) + or callee.id in {receiver.id, rhs.value.id, rhs.attr}): + return None + origin_rows = module.bindings.get(callee.id, []) + if not (len(origin_rows) == 1 and origin_rows[0].top_level + and isinstance(imported := origin_rows[0].node, ast.alias) + and imported.name == "ior" and imported.asname == callee.id + and isinstance(origin := origin_rows[0].statement, ast.ImportFrom) + and origin.module == "operator" and origin.level == 0 and origin.names == [imported] + and scopes.parents.get(origin) is module.tree and origin in positions + and positions[origin] < positions[statement] and origin.lineno < statement.lineno): + return None + operator_origin = (origin, imported) + rows = module.bindings.get(receiver.id, []) + if not (len(rows) == 1 and rows[0].top_level + and isinstance(target := rows[0].node, ast.Name) and isinstance(target.ctx, ast.Store) + and isinstance(declaration := rows[0].statement, ast.Assign) and declaration.targets == [target] + and scopes.parents.get(target) is declaration and scopes.parents.get(declaration) is module.tree + and declaration in positions and positions[declaration] < positions[statement] + and declaration.lineno < statement.lineno + and isinstance(projection := declaration.value, ast.Attribute) and isinstance(projection.ctx, ast.Load) + and projection.attr == "__dict__" and isinstance(namespace := projection.value, ast.Name) + and isinstance(namespace.ctx, ast.Load) and namespace.id == rhs.value.id + and namespace.id != callee.id and receiver.id not in {namespace.id, callee.id}): + return None + source_rows = module.bindings.get(namespace.id, []) + if not (len(source_rows) == 1 and source_rows[0].top_level + and isinstance(source_alias := source_rows[0].node, ast.alias) + and source_alias.name == namespace.id and source_alias.asname is None and "." not in namespace.id + and isinstance(source := source_rows[0].statement, ast.Import) and source.names == [source_alias] + and scopes.parents.get(source) is module.tree and source in positions + and positions[source] < positions[declaration] and source.lineno < declaration.lineno): + return None + if tokens[0].get(receiver.id) != {target, receiver} or tokens[1].get(receiver.id): + return None + mapping = _SavedSourceMapping(declaration, target, receiver, projection, namespace) + return _SourceFromOperatorIor(*operator_origin, callee, call, mapping, payload, key, rhs) + + def _source_slot_edge(self, module: PythonModule, statement: _SourceSlotStatement) -> _SourceSlotEdge | None: + if (record := self._source_direct_bare_setter(module, statement)) is not None: + assert isinstance(statement, ast.Expr) and isinstance(call := statement.value, ast.Call) + if self._source_direct_bare_receivers(module, statement) is None: + return None + receiver, key, value = call.args + assert isinstance(receiver, ast.Name) and isinstance(value, ast.Attribute) + parts = (receiver, value.value, value.attr, None, key, None) + return _SourceSlotEdge(statement, parts, value, target=receiver, callee=record.callee, call=call, + direct_bare_setter=record) + if (record := self._source_from_operator_ior(module, statement)) is not None: + mapping = record.mapping + parts = (mapping.namespace, record.rhs.value, record.rhs.attr, mapping.projection, record.key, mapping) + return _SourceSlotEdge(statement, parts, record.rhs, callee=record.callee, call=record.call, + payload=record.payload, from_operator_ior=record) + if (record := self._source_operator_ior(module, statement)) is not None: + if isinstance(record, _SourceDirectOperatorIor): + parts = (record.projection.value, record.rhs.value, record.rhs.attr, record.projection, record.key, None) + return _SourceSlotEdge(statement, parts, record.rhs, callee=record.callee, call=record.call, + payload=record.payload, operator_ior=record) + mapping = record.mapping + parts = (mapping.namespace, record.rhs.value, record.rhs.attr, mapping.projection, record.key, mapping) + return _SourceSlotEdge(statement, parts, record.rhs, callee=record.callee, call=record.call, + payload=record.payload, operator_ior=record) + if (record := self._source_operator_setitem(module, statement)) is not None: + parts = (record.projection.value, record.rhs.value, record.rhs.attr, record.projection, record.key, None) + return _SourceSlotEdge(statement, parts, record.rhs, callee=record.callee, call=record.call, + operator_setitem=record) + if isinstance(statement, ast.Expr) and (setter := self._source_slot_setter_edge(module, statement)) is not None: + return setter + if isinstance(statement, ast.Expr) and (saved := self._source_saved_bound_ior(module, statement)) is not None: + mapping = self._source_slot_mapping(module, statement, saved.receiver) + if mapping is None: + return None + value = saved.payload.values[0] + key = saved.payload.keys[0] + assert isinstance(value, ast.Attribute) and isinstance(value.value, ast.Name) and isinstance(key, ast.Constant) + scopes = self.scopes(module) + rows = module.bindings.get(mapping.namespace.id, []) + if not (mapping.namespace.id == value.value.id and len(rows) == 1 and rows[0].top_level + and isinstance(imported := rows[0].node, ast.alias) and imported.name == mapping.namespace.id + and imported.asname is None and "." not in imported.name + and isinstance(source_import := rows[0].statement, ast.Import) and source_import.names == [imported] + and scopes.parents.get(source_import) is module.tree + and source_import in module.tree.body + and module.tree.body.index(source_import) < module.tree.body.index(mapping.declaration) + and source_import.lineno < mapping.declaration.lineno): + return None + parts = (mapping.namespace, value.value, value.attr, mapping.projection, key, mapping) + return _SourceSlotEdge(statement, parts, value, callee=saved.callee, call=saved.call, + payload=saved.payload, saved_bound_ior=saved) + if isinstance(statement, ast.Assign): + parts = self._source_slot_parts(module, statement) + if parts is None: + return None + return _SourceSlotEdge(statement, parts, statement.value, statement.targets[0]) + if isinstance(statement, ast.AugAssign): + if not (self.scopes(module).parents.get(statement) is module.tree and isinstance(statement.op, ast.BitOr) + and isinstance(payload := statement.value, ast.Dict) + and len(payload.keys) == 1 and len(payload.values) == 1 + and isinstance(key := payload.keys[0], ast.Constant) and isinstance(key.value, str) + and isinstance(value := payload.values[0], ast.Attribute) and isinstance(value.ctx, ast.Load) + and isinstance(value.value, ast.Name) and not value.attr.startswith("_") and key.value == value.attr + and (mapping := self._source_slot_augmented_mapping(module, statement)) is not None): + return None + parts = (mapping.namespace, value.value, value.attr, mapping.projection, key, mapping) + return _SourceSlotEdge(statement, parts, value, target=mapping.receiver, payload=payload) + if not (self.scopes(module).parents.get(statement) is module.tree + and isinstance(call := statement.value, ast.Call) + and isinstance(callee := call.func, ast.Attribute) + and (mutator := self._source_slot_mutator_receiver(module, call)) is not None): + return None + receiver, primitive = mutator + arguments = call.args[1:] if primitive is not None else call.args + key = None + if callee.attr == "__setitem__": + positional = self._source_slot_setitem_parts(module, call) + assert positional is not None + _, key, value = positional + field, payload = key.value, None + elif not arguments and len(call.keywords) == 1: + payload = call.keywords[0] + field, value = payload.arg, payload.value + elif (not call.keywords and len(arguments) == 1 and isinstance(payload := arguments[0], ast.Dict) + and len(payload.keys) == 1 and len(payload.values) == 1 + and isinstance(key := payload.keys[0], ast.Constant) and isinstance(key.value, str)): + field, value = key.value, payload.values[0] + else: + return None + if not (isinstance(value, ast.Attribute) and isinstance(value.ctx, ast.Load) + and isinstance(value.value, ast.Name) and not value.attr.startswith("_") + and field == value.attr and (mapping := self._source_slot_mapping(module, statement, receiver)) is not None): + return None + if callee.attr == "__ior__": + scopes = self.scopes(module) + rows = module.bindings.get(mapping.namespace.id, []) + if not (isinstance(mapping.projection, ast.Attribute) and mapping.projection.attr == "__dict__" + and mapping.projection.value is mapping.namespace + and mapping.namespace.id == value.value.id and len(rows) == 1 + and rows[0].top_level and isinstance(imported := rows[0].node, ast.alias) + and imported.name == mapping.namespace.id and imported.asname is None and "." not in imported.name + and isinstance(source_import := rows[0].statement, ast.Import) and source_import.names == [imported] + and scopes.parents.get(source_import) is module.tree + and source_import.lineno < mapping.declaration.lineno): + return None # Other getter/import compositions need their own declared scope. + parts = (mapping.namespace, value.value, field, mapping.projection, key, mapping) + return _SourceSlotEdge(statement, parts, value, callee=callee, call=call, payload=payload, + target=receiver if primitive is not None else None, primitive=primitive, + primitive_import=self._source_slot_primitive_import(module, primitive) + if primitive is not None else None) + + def _source_slot_candidate( + self, module: PythonModule, node: ast.AST, + ) -> tuple[PythonModule, ast.FunctionDef, _SourceSlotStatement] | None: + scopes = self.scopes(module) + statement = self._source_slot_statement(module, node) + if not (isinstance(statement, ast.Assign | ast.Expr | ast.AugAssign) and not module.star_import + and (edge := self._source_slot_edge(module, statement)) is not None): + return None + target, value, parts = edge.target, edge.rhs, edge.parts + left_name, right_name, slot, projection, key, mapping = parts + left_receiver = self._source_slot_receiver(module, left_name, statement) + right_receiver = self._source_slot_receiver(module, right_name, statement) + if left_receiver is None or right_receiver is None: + return None + if mapping is not None and (left_receiver[1] is not None or right_receiver[1] is not None): + return None + if projection is not None: + for receiver in (left_receiver, right_receiver): + found = self.scopes(module).enclosing_bindings(evaluation_site(scopes, receiver[0]), receiver[0].id) + found = found or [binding.node for binding in module.bindings.get(receiver[0].id, [])] + if len(found) != 1 or scopes.parents.get(scopes.statement_of(found[0])) is not module.tree: + return None + if right_receiver[1] is not None and right_receiver[1] is not left_receiver[1]: + return None # One declaration per edge; RHS-only aliases are outside this route. + if not (node is statement or node is target or node is value + or node is edge.callee or node is edge.call or node is edge.primitive + or edge.primitive_import is not None and node is edge.primitive_import.root + or edge.operator_setitem is not None and node is edge.operator_setitem.root + or edge.operator_ior is not None and node is edge.operator_ior.root + or edge.direct_bare_setter is not None and node is key + or edge.setter_import is not None and (node is key + or isinstance(edge.setter_import, _SourceSetterImport | _SourceSavedSetterImport) + and node is edge.setter_import.root) + or node is left_receiver[1] or node is right_receiver[1] + or projection is not None and (node is projection or node is left_name or node is key) + or mapping is not None and node is mapping.receiver + or node is self._source_slot_projection_metadata(projection) + or isinstance(projection, ast.Call) and node is projection.func): + return None + left = self.resolver._constructor_reference(module, left_receiver[0], scopes) + right = self.resolver._constructor_reference(module, right_receiver[0], scopes) + namespace = left.get("retained_namespace") + if not (isinstance(namespace, Path) and namespace == right.get("retained_namespace") + and self.resolver.contains(namespace)): + return None + home = self._module(namespace) + bindings = home.bindings.get(slot, []) + if not (len(bindings) == 1 and bindings[0].top_level + and isinstance(function := bindings[0].node, ast.FunctionDef) + and function in home.tree.body and not function.decorator_list + and not getattr(function, "type_params", [])): + return None + return home, function, statement + + @staticmethod + def _operator_metadata_statement(module: PythonModule) -> ast.Assign | None: + """Bound selection before the whole-tree confinement walk, with no grant.""" + found = None + for node in module.tree.body: + if (isinstance(node, ast.Assign) and len(node.targets) == 1 + and isinstance(target := node.targets[0], ast.Attribute) and isinstance(target.ctx, ast.Store) + and target.attr in {"vars", "dict", "getattr", "setattr"} + and isinstance(target.value, ast.Name) and target.value.id == "operator" + and isinstance(node.value, ast.Constant) and node.value.value is None): + if found is not None: + return None + found = node + return found + + def _operator_metadata_write(self, module: PythonModule, node: ast.AST | None) -> _OperatorMetadataWrite | None: + """Pure exact write/import confinement; standard owner proof comes later.""" + scopes = self.scopes(module) + if not (isinstance(node, ast.Assign) and scopes.parents.get(node) is module.tree + and node in module.tree.body and len(node.targets) == 1 + and isinstance(target := node.targets[0], ast.Attribute) and isinstance(target.ctx, ast.Store) + and scopes.parents.get(target) is node and target.attr in {"vars", "dict", "getattr", "setattr"} + and isinstance(receiver := target.value, ast.Name) and isinstance(receiver.ctx, ast.Load) + and receiver.id == "operator" and isinstance(node.value, ast.Constant) and node.value.value is None): + return None + rows = module.bindings.get(receiver.id, []) + if len(rows) != 1: + return None + row = rows[0] + if not (row.top_level and isinstance(imported := row.node, ast.alias) + and imported.name == "operator" and imported.asname is None + and isinstance(declaration := row.statement, ast.Import) and declaration.names == [imported] + and scopes.parents.get(imported) is declaration and scopes.parents.get(declaration) is module.tree + and declaration in module.tree.body and declaration.lineno < node.lineno + and not scopes.enclosing_bindings(evaluation_site(scopes, receiver), receiver.id) + and not module.bindings.get("__all__")): + return None + for use in ast.walk(module.tree): + if isinstance(use, ast.Constant) and use.value == receiver.id: + return None + if isinstance(use, ast.Name) and use.id == receiver.id and use is not receiver: + lexical = scopes.enclosing_bindings(evaluation_site(scopes, use), use.id) + if not lexical or imported in lexical: + return None + return _OperatorMetadataWrite(node, target, receiver, imported, declaration) + + def operator_metadata_write(self, module: PythonModule, node: ast.AST, family: str) -> bool: + """Route only this actual Store through explicit completed admission.""" + if not isinstance(node, ast.Attribute) or not isinstance(node.ctx, ast.Store): + return False + statement = self.scopes(module).parents.get(node) + metadata = self._operator_metadata_write(module, statement) + if metadata is None or metadata.target is not node: + return False + candidates = [statement for statement in module.tree.body + if self._source_slot_candidate(module, statement) is not None] + if len(candidates) != 1: + return False + return self.idempotent_source_slot( + module, candidates[0], family, required_operator_metadata=(module, node), + ) + + def source_primitive_import(self, module: PythonModule, node: ast.AST, family: str) -> bool: + """Only a completed receipt for this exact qualified primitive Import.""" + if not (isinstance(node, ast.Import) and len(node.names) == 1 + and node.names[0].name == "builtins" and node.names[0].asname is None + and self.scopes(module).parents.get(node) is module.tree): + return False + candidates = [statement for statement in module.tree.body + if isinstance(statement, ast.Assign | ast.Expr | ast.AugAssign) + and (edge := self._source_slot_edge(module, statement)) is not None + and edge.primitive_import is not None and edge.primitive_import.statement is node + and self._source_slot_candidate(module, statement) is not None] + if len(candidates) != 1: + return False + return self.idempotent_source_slot( + module, candidates[0], family, required_primitive_import=(module, node), + ) + + def source_setter_import(self, module: PythonModule, node: ast.AST, family: str) -> bool: + """Admit only this actual setter Import after its complete same-slot proof.""" + if not isinstance(node, ast.Import): + return False + candidates = [statement for statement in module.tree.body + if isinstance(statement, ast.Expr) + and (edge := self._source_slot_setter_edge(module, statement)) is not None + and edge.setter_import is not None and edge.setter_import.statement is node + and self._source_slot_candidate(module, statement) is not None] + if len(candidates) != 1: + return False + return self.idempotent_source_slot(module, candidates[0], family, required_setter_import=(module, node)) + + def source_from_setter_import(self, module: PythonModule, node: ast.AST, family: str) -> bool: + """This actual from-import only, after its complete same-slot proof.""" + if not isinstance(node, ast.ImportFrom): + return False + candidates = [statement for statement in module.tree.body + if isinstance(statement, ast.Expr) + and (edge := self._source_slot_setter_edge(module, statement)) is not None + and isinstance(record := edge.setter_import, _SourceFromSetterImport) + and record.statement is node + and self._source_slot_candidate(module, statement) is not None] + return len(candidates) == 1 and self.idempotent_source_slot( + module, candidates[0], family, required_setter_import=(module, node), + ) + + def source_saved_vars_table_marker(self, module: PythonModule, key: str, line: int) -> bool: + """Discharge one intrinsic marker origin; never remove an ordinary patch.""" + if (key != MODULE_TABLE_COMPUTED or self.resolver._checking_source_module_slot + or self.resolver._checking_fresh_dictionary_primitive + or module.attribute_patches.get(key) != line + or self.resolver._runtime_attribute_patches(module).get(key) != line): + return False + producers: dict[str, set[ast.AST | None]] = {} + if _attribute_patches(module.tree, patch_producers=producers) != module.attribute_patches: + return False + origins = producers.get(key, set()) + if len(origins) != 1: + return False + initializer = next(iter(origins)) + if not (isinstance(initializer, ast.Name) and isinstance(initializer.ctx, ast.Load) + and initializer.id == "vars"): + return False + for family in ("agents", "google.adk"): + try: + if self.source_saved_vars_initializer(module, initializer, family): + return True + except CallLimit: + continue # Each model must finish its own proof; partial evidence grants nothing. + return False + + def source_saved_vars_initializer(self, module: PythonModule, node: ast.AST, family: str) -> bool: + """Only this actual getter initializer after its Store-only complete proof.""" + if not (isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id == "vars"): + return False + candidates = [statement for statement in module.tree.body if isinstance(statement, ast.Assign) + and (edge := self._source_slot_edge(module, statement)) is not None + and (mapping := edge.parts[5]) is not None and mapping.saved_getter is not None + and mapping.saved_getter.initializer is node + and self._source_slot_candidate(module, statement) is not None] + return len(candidates) == 1 and self.idempotent_source_slot(module, candidates[0], family) + + def source_saved_bound_ior_initializer(self, module: PythonModule, node: ast.AST, family: str) -> bool: + """Only this actual saved method after its independent completed proof.""" + if not (isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load) and node.attr == "__ior__"): + return False + if self.resolver._checking_source_module_slot or self.resolver._checking_fresh_dictionary_primitive: + return False + candidates = [statement for statement in module.tree.body if isinstance(statement, ast.Expr) + and (edge := self._source_slot_edge(module, statement)) is not None + and edge.saved_bound_ior is not None and edge.saved_bound_ior.initializer is node + and self._source_slot_candidate(module, statement) is not None] + return len(candidates) == 1 and self.idempotent_source_slot(module, candidates[0], family) + + def source_bare_setter_initializer(self, module: PythonModule, node: ast.AST, family: str) -> bool: + """Only this actual bare initializer after its complete same-slot proof.""" + if not (isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id == "setattr"): + return False + candidates = [statement for statement in module.tree.body + if isinstance(statement, ast.Expr) + and (edge := self._source_slot_setter_edge(module, statement)) is not None + and edge.bare_setter is not None and edge.bare_setter.initializer is node + and self._source_slot_candidate(module, statement) is not None] + return len(candidates) == 1 and self.idempotent_source_slot(module, candidates[0], family) + + def source_setter_initializer(self, module: PythonModule, node: ast.AST, family: str) -> bool: + """Only this actual saved setter initializer after its own complete proof.""" + if not isinstance(node, ast.Attribute) or not isinstance(node.ctx, ast.Load): + return False + candidates = [statement for statement in module.tree.body + if isinstance(statement, ast.Expr) + and (edge := self._source_slot_setter_edge(module, statement)) is not None + and isinstance(record := edge.setter_import, _SourceSavedSetterImport) + and record.initializer is node + and self._source_slot_candidate(module, statement) is not None] + return len(candidates) == 1 and self.idempotent_source_slot(module, candidates[0], family) + + def idempotent_source_slot( + self, module: PythonModule, node: ast.AST, family: str, + *, required_operator_metadata: tuple[PythonModule, ast.Attribute] | None = None, + required_primitive_import: tuple[PythonModule, ast.Import] | None = None, + required_setter_import: tuple[PythonModule, ast.Import | ast.ImportFrom] | None = None, + ) -> bool: + """Discharge one source declaration edge, never a runtime purity claim. + + The complete static context, raw mutation evidence and exact empty + callable census are independent of receiving/constructor-owner proofs. + No external module getter, copied namespace or ordinary cache is granted. + """ + if family not in {"agents", "google.adk"}: + return False + statement = self._source_slot_statement(module, node) + if not (isinstance(statement, ast.Assign | ast.Expr | ast.AugAssign) + and self._source_slot_edge(module, statement) is not None): + return False + if (self._source_slot_edge(module, statement).saved_bound_ior is not None + and self.resolver._checking_fresh_dictionary_primitive): + return False + if self.resolver._checking_source_module_slot: + raise CallLimit("the source-module slot proof was reentered") + self.resolver._checking_source_module_slot = True + try: + selected = self._source_slot_candidate(module, node) + if selected is None: + return False + home, function, selected_statement = selected + proof = BuilderCalls(self.resolver) + context = proof.namespace_source_context( + family, allow_operator_metadata=True, allow_operator_setitem=True, allow_operator_ior=True, + allow_from_operator_ior=True, + ) + if module not in context.modules or home not in context.modules: + raise CallLimit("the source-module slot's actual trees are outside the captured context") + from agents_shipgate.inputs.list_expressions import ( + _fresh_dictionary, + _namespace_call_reference, + _qualified_attribute_patches, + _standard_operator, + _unprovided_root, + _View, + bindings_at, + ) + edges: dict[_SourceSlotStatement, _SourceSlotEdge] = {} + namespace_values: set[ast.Name] = set() + annotations: set[ast.Name] = set() + projections: set[ast.Attribute | ast.Call] = set() + projection_callees: set[ast.Name] = set() + projection_keys: set[ast.Constant] = set() + projection_metadata: set[ast.Constant] = set() + mapping_receivers: set[ast.Name] = set() + mapping_mutators: set[ast.Attribute] = set() + saved_method_roles: set[ast.Attribute | ast.Name] = set() + pending_method_markers: list[ + tuple[_View, str, ast.Attribute, ast.Attribute, frozenset[str], dict[str, frozenset[ast.AST | None]]] + ] = [] + primitive_callees: set[ast.Name | ast.Attribute] = set() + admitted_imports: set[tuple[int, ast.Import]] = set() + admitted_setter_imports: set[tuple[int, ast.Import | ast.ImportFrom]] = set() + setter_callees: set[ast.Attribute | ast.Name] = set() + setter_import_aliases: set[ast.alias] = set() + bare_setter_initializers: set[ast.Name] = set() + saved_getter_initializers: set[ast.Name] = set() + pending_getter_markers: list[tuple[PythonModule, ast.Name]] = [] + admitted_metadata: set[tuple[int, ast.Attribute]] = set() + views: list[_View] = [] + machinery = { + "eval", "exec", "compile", "__import__", "globals", "locals", "vars", "getattr", "setattr", "delattr", + "type", "object", "ModuleType", "__class__", "__dict__", "__builtins__", "__globals__", "__closure__", + "__getattr__", "__getattribute__", "__setattr__", "__delattr__", "__code__", "__defaults__", + "__kwdefaults__", "__annotations__", "cell_contents", "_getframe", "currentframe", "f_globals", "f_locals", + "modules", "meta_path", "path_hooks", "path", "__path__", "__loader__", "__spec__", + } + for current in context.modules: + for item in ast.walk(current.tree): + if isinstance(item, ast.Assign | ast.Expr | ast.AugAssign) and (candidate := proof._source_slot_candidate(current, item)): + if candidate[0] is home and candidate[1] is function: + edge = proof._source_slot_edge(current, item) + assert edge is not None + edges[item] = edge + if (edge.callee is not None and edge.setter_import is None + and edge.bare_setter is None and edge.direct_bare_setter is None + and edge.saved_bound_ior is None + and edge.operator_setitem is None and edge.operator_ior is None + and edge.from_operator_ior is None): + mapping_mutators.add(edge.callee) + if len(edges) > MAX_CONTEXTS: + raise CallLimit(f"the source-module slot context exceeds {MAX_CONTEXTS} exact edges") + parts = edge.parts + if edge.setter_import is not None or edge.bare_setter is not None or edge.direct_bare_setter is not None: + namespace_values.add(parts[0]) + projection_keys.add(parts[4]) + for other in context.modules: + if other is current: + continue + _, exported = proof._expanding_imports( + other, {parts[0].id, parts[1].id}, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, + ) + if exported: + raise CallLimit(f"{current.ref} has an imported setter source namespace") + if parts[3] is not None: + if current.bindings.get("__all__"): + raise CallLimit(f"{current.ref} has an explicit export surface for the intrinsic source mapping") + actual_receivers = [proof._source_slot_receiver(current, name, item) for name in parts[:2]] + assert all(receiver is not None for receiver in actual_receivers) + import_names = {receiver[0].id for receiver in actual_receivers if receiver is not None} + if parts[5] is not None: + import_names.add(parts[5].target.id) + mapping_receivers.add(parts[5].receiver) + if edge.saved_bound_ior is not None: + import_names.add(edge.saved_bound_ior.target.id) + if parts[5].saved_getter is not None: + import_names.add(parts[5].saved_getter.target.id) + for other in context.modules: + if other is current: + continue + _, exported = proof._expanding_imports( + other, {parts[0].id, parts[1].id} | import_names, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, + ) + if exported: + raise CallLimit(f"{current.ref} has an imported intrinsic source mapping namespace") + projections.add(parts[3]) + if isinstance(parts[3], ast.Call): + assert isinstance(parts[3].func, ast.Name) + projection_callees.add(parts[3].func) + if parts[4] is not None: + projection_keys.add(parts[4]) + if (metadata := proof._source_slot_projection_metadata(parts[3])) is not None: + projection_metadata.add(metadata) + namespace_values.add(parts[0]) + for receiver in parts[:2]: + resolved = proof._source_slot_receiver(current, receiver, item) + assert resolved is not None + if resolved[1] is not None: + namespace_values.add(resolved[1]) + alias_statement = proof.scopes(current).parents[resolved[1]] + alias_target = (alias_statement.targets[0] if isinstance(alias_statement, ast.Assign) + else alias_statement.target) + for other in context.modules: + if other is current: + continue + _, exported = proof._expanding_imports( + other, {alias_target.id}, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, + ) + if exported: + raise CallLimit(f"{current.ref} has an imported source-slot namespace alias") + if resolved[2] is not None: + annotations.add(resolved[2]) + for current in context.modules: + current_scopes = proof.scopes(current) + raw_markers = {marker for marker in current.attribute_patches + if marker in {PATH_PATCH, IMPORT_SEARCH_PATCH} or marker.startswith(("*", SELF_PATCH))} + if raw_markers: + initializers = [mapping.saved_getter.initializer for statement, edge in edges.items() + if current_scopes.parents.get(statement) is current.tree + and (mapping := edge.parts[5]) is not None and mapping.saved_getter is not None] + raw_producers: dict[str, set[ast.AST | None]] = {} + replay = _attribute_patches(current.tree, patch_producers=raw_producers) + if not (raw_markers == {MODULE_TABLE_COMPUTED} and len(initializers) == 1 + and replay == current.attribute_patches + and raw_producers.get(MODULE_TABLE_COMPUTED) == {initializers[0]}): + raise CallLimit(f"{current.ref} has raw package-path or module-namespace mutation markers") + pending_getter_markers.append((current, initializers[0])) + # This origin is only pending; no ordinary marker is removed and no proof is granted here. + view = _View(current.ref, current.tree, current_scopes, current.bindings, current, set()) + view.lookup = bindings_at(current_scopes, current.bindings) + view.resolver = self.resolver + views.append(view) + patch_producers: dict[str, set[ast.AST | None]] = {} + patches = _qualified_attribute_patches(view, raw_namespaces=True, patch_producers=patch_producers) + if view.namespace_builtins_changed: + raise CallLimit(f"{current.ref} has unstable namespace primitives in the source-module slot context") + for statement, edge in edges.items(): + mapping = edge.parts[5] + if mapping is not None and mapping.saved_getter is not None and current_scopes.parents.get(statement) is current.tree: + getter = mapping.saved_getter + if _namespace_call_reference(view, getter.projection) != "builtins.vars": + raise CallLimit(f"{current.ref}:{statement.lineno} has an unread saved getter primitive") + saved_getter_initializers.add(getter.initializer) + if edge.saved_bound_ior is not None and current_scopes.parents.get(statement) is current.tree: + saved = edge.saved_bound_ior + mapping = edge.parts[5] + assert mapping is not None and isinstance(mapping.projection, ast.Attribute) + marker = f"{mapping.namespace.id}.*" + origins = patch_producers.get(marker, set()) + if not (marker in patches and origins in ({saved.initializer}, {saved.initializer, edge.rhs}) + and proof._source_slot_candidate(current, statement) == (home, function, statement)): + raise CallLimit(f"{current.ref}:{statement.lineno} has unread saved method wildcard provenance") + # A source Function named update/clear can itself look like an exported + # mutator to the raw scanner. Only this exact original Function read is + # additionally pending; the zero census must complete its separate data role. + pending_method_markers.append((view, marker, saved.initializer, edge.rhs, frozenset(patches), + {key: frozenset(values) for key, values in patch_producers.items()})) + saved_method_roles.update({saved.initializer, saved.callee}) + if edge.operator_setitem is not None and current_scopes.parents.get(statement) is current.tree: + record = edge.operator_setitem + proof._namespace_import_disk_root(current, record.statement, "operator setitem import") + if not (_standard_operator(view) + and proof._namespace_import_root_currency(current, record.statement, "operator setitem import") + and _namespace_call_reference(view, record.call) == "operator.setitem"): + raise CallLimit(f"{current.ref}:{statement.lineno} has an unread standard operator primitive") + for other in context.modules: + if other is current: + continue + _, exported = proof._expanding_imports( + other, {record.root.id}, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, + ) + if exported: + raise CallLimit(f"{current.ref} has an imported operator primitive namespace") + primitive_callees.update({record.root, record.callee}) + if edge.operator_ior is not None and current_scopes.parents.get(statement) is current.tree: + record = edge.operator_ior + proof._namespace_import_disk_root(current, record.statement, "operator ior import") + if not (_standard_operator(view) + and proof._namespace_import_root_currency(current, record.statement, "operator ior import") + and _namespace_call_reference(view, record.call) == "operator.ior"): + raise CallLimit(f"{current.ref}:{statement.lineno} has an unread standard operator primitive") + for other in context.modules: + if other is current: + continue + _, exported = proof._expanding_imports( + other, {record.root.id}, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, ) + if exported: + raise CallLimit(f"{current.ref} has an imported operator primitive namespace") + primitive_callees.update({record.root, record.callee}) + if edge.from_operator_ior is not None and current_scopes.parents.get(statement) is current.tree: + record = edge.from_operator_ior + proof._namespace_import_disk_root(current, record.statement, "from operator ior import") + if not (_standard_operator(view) + and proof._namespace_import_root_currency(current, record.statement, "from operator ior import") + and _namespace_call_reference(view, record.call) == "operator.ior"): + raise CallLimit(f"{current.ref}:{statement.lineno} has an unread standard operator primitive") + for other in context.modules: + if other is current: + continue + _, exported = proof._expanding_imports( + other, {record.callee.id}, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, + ) + if exported: + raise CallLimit(f"{current.ref} has an imported operator primitive namespace") + primitive_callees.add(record.callee) + if edge.direct_bare_setter is not None and current_scopes.parents.get(statement) is current.tree: + proof._namespace_import_disk_root(current, statement, "direct bare setter origin") + if not (_unprovided_root(view, "builtins") + and proof._namespace_import_root_currency(current, statement, "direct bare setter origin") + and edge.call is not None and _namespace_call_reference(view, edge.call) == "builtins.setattr"): + raise CallLimit(f"{current.ref}:{statement.lineno} has an unread direct bare setter origin") + setter_callees.add(edge.direct_bare_setter.callee) + if (edge.setter_import is not None or edge.bare_setter is not None) and current_scopes.parents.get(statement) is current.tree: + record = edge.setter_import or edge.bare_setter + assert record is not None + origin = record.assignment if isinstance(record, _SourceBareSetter) else record.statement + proof._namespace_import_disk_root(current, origin, "source setter origin") + if not _unprovided_root(view, "builtins") or not proof._namespace_import_root_currency( + current, origin, "source setter origin", + ): + raise CallLimit(f"{current.ref}:{statement.lineno} has an unread setter import root") + for other in context.modules: + if other is current: + continue + primitive_names = ({record.target.id} if isinstance(record, _SourceBareSetter) + else {record.callee.id} if isinstance(record, _SourceFromSetterImport) + else {record.root.id}) + if isinstance(record, _SourceSavedSetterImport): + primitive_names.add(record.target.id) + _, exported = proof._expanding_imports( + other, primitive_names, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, + ) + if exported: + raise CallLimit(f"{current.ref} has an imported setter builtin namespace") + if _namespace_call_reference(view, edge.call) != "builtins.setattr": + raise CallLimit(f"{current.ref}:{statement.lineno} has an unread setter primitive") + setter_callees.add(record.callee) + primitive_callees.add(record.callee) + if isinstance(record, _SourceBareSetter): + bare_setter_initializers.add(record.initializer) + primitive_callees.add(record.initializer) + elif isinstance(record, _SourceFromSetterImport): + setter_import_aliases.add(record.imported) + else: + primitive_callees.add(record.root) + if isinstance(record, _SourceSavedSetterImport): + setter_callees.add(record.initializer) + primitive_callees.add(record.initializer) + if not isinstance(record, _SourceBareSetter): + admitted_setter_imports.add((id(current), record.statement)) + if edge.primitive is not None and current_scopes.parents.get(statement) is current.tree: + assert edge.call is not None + if (record := edge.primitive_import) is not None: + proof._namespace_import_disk_root(current, record.statement, "dictionary initialization import") + if not _unprovided_root(view, "builtins") or not proof._namespace_import_root_currency( + current, record.statement, "dictionary initialization import", + ): + raise CallLimit(f"{current.ref}:{statement.lineno} has an unread builtin import root") + for other in context.modules: + if other is current: + continue + _, exported = proof._expanding_imports( + other, {record.root.id}, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, + ) + if exported: + raise CallLimit(f"{current.ref} has an imported dictionary primitive namespace") + expected_primitive = {"__init__": "builtins.dict.__init__", "update": "builtins.dict.update", + "__setitem__": "builtins.dict.__setitem__"}[edge.callee.attr] + if _namespace_call_reference(view, edge.call) != expected_primitive: + raise CallLimit(f"{current.ref}:{statement.lineno} has an unread dictionary initialization primitive") + primitive_callees.add(edge.primitive) + if record is not None: + primitive_callees.add(record.root) + admitted_imports.add((id(current), record.statement)) + metadata_writes: dict[ast.Attribute, _OperatorMetadataWrite] = {} + metadata = proof._operator_metadata_write(current, proof._operator_metadata_statement(current)) + if metadata is not None and _standard_operator(view): + for other in context.modules: + if other is current: + continue + _, exported = proof._expanding_imports( + other, {metadata.receiver.id}, _module_words(current.path), {current.path}, + subject=current.path, namespace_carriers=True, ) - except CallLimit as exc: - contexts.append(ConstructionContext(limits=(f"{site.location}: {exc}",))) - if len(contexts) > MAX_CONTEXTS: - return ( - ConstructionContext( - limits=(f"argument flow exceeds {MAX_CONTEXTS} caller contexts",) - ), - ) - return tuple(contexts) + if exported: + raise CallLimit(f"{current.ref} has an imported operator metadata namespace") + if patch_producers.get(metadata.marker) == {metadata.target}: + metadata_writes[metadata.target] = metadata + admitted_metadata.add((id(current), metadata.target)) + discharged = {metadata.marker for metadata in metadata_writes.values()} + for patch in patches: + if any(part in machinery for part in patch.split(".")) and patch not in discharged: + raise CallLimit(f"{current.ref} has module hook or import-table writes in the source-module slot context") + for item in ast.walk(current.tree): + if item in metadata_writes: + continue # Only this proved operator Store token, with exact unique raw producer evidence. + if item in annotations: + continue # Only the whole, unshadowed builtin object annotation Name. + if item in projections: + continue # Exact intrinsic source-module mapping; all raw markers remain checked. + if item in projection_callees: + continue # Exact unshadowed getter; the independent raw primitive check already finished. + if item in projection_metadata: + continue # Exact dictionary getter metadata, never a general reflective string exemption. + if item in saved_getter_initializers: + continue # Only the actual bare getter initializer after canonical raw proof. + if item in bare_setter_initializers: + continue # Only this actual unshadowed initializer after complete native/canonical proof. + if item in setter_import_aliases: + if item.asname in machinery: + raise CallLimit(f"{current.ref}:{item.lineno} has an unread setter alias spelling") + continue # Only actual imported metadata after canonical proof; never a census value. + if item in setter_callees: + continue # Only the exact setter Attribute after its independent canonical raw proof. + if isinstance(item, ast.ClassDef | ast.Lambda | ast.GeneratorExp): + raise CallLimit(f"{current.ref}:{item.lineno} has an unread class or anonymous carrier in the source-module slot context") + spellings = ( + {item.id} if isinstance(item, ast.Name) else {item.attr} if isinstance(item, ast.Attribute) + else {item.name, item.asname or ""} if isinstance(item, ast.alias) + else {item.name} if isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef) + else {item.value} if isinstance(item, ast.Constant) and isinstance(item.value, str) else set() + ) + if any(part in machinery for spelling in spellings for part in spelling.split(".")): + raise CallLimit(f"{current.ref}:{item.lineno} has reflective module machinery in the source-module slot context") + if selected_statement not in edges: + return False + if (required_operator_metadata is not None + and (id(required_operator_metadata[0]), required_operator_metadata[1]) not in admitted_metadata): + return False + if (required_primitive_import is not None + and (id(required_primitive_import[0]), required_primitive_import[1]) not in admitted_imports): + return False + if (required_setter_import is not None + and (id(required_setter_import[0]), required_setter_import[1]) not in admitted_setter_imports): + return False + for view in views: + assert view.module is not None + for item in ast.walk(view.tree): + if isinstance(item, ast.Attribute) and isinstance(item.ctx, ast.Store | ast.Del) and item.attr == function.name: + parent = view.scopes.parents.get(item) + if parent in edges and item is edges[parent].target: + continue + owner = self.resolver._constructor_reference(view.module, item.value, view.scopes) + other = owner.get("retained_namespace") + if isinstance(other, Path) and other != home.path and self.resolver.contains(other): + continue + raise CallLimit(f"{view.ref}:{item.lineno} has another or unknown write to the selected source slot") + if isinstance(item, ast.Subscript) and isinstance(item.ctx, ast.Store | ast.Del): + parent = view.scopes.parents.get(item) + if parent in edges and item is edges[parent].target: + continue # Only the selected, independently modeled same-slot store. + if isinstance(item.slice, ast.Constant) and item.slice.value != function.name: + continue + if not _fresh_dictionary(view, item.value): + raise CallLimit(f"{view.ref}:{item.lineno} has a keyed write with unread source-slot ownership") + values = frozenset(edge.rhs for edge in edges.values()) + census = proof._census(home, function, allow_empty=True, source_slot_values=values, + source_slot_namespaces=frozenset(namespace_values), + source_slot_projections=frozenset(projections), + source_slot_getters=frozenset(projection_callees | saved_getter_initializers), + source_slot_keys=frozenset(projection_keys), + source_slot_metadata=frozenset(projection_metadata), + source_slot_mappings=frozenset(mapping_receivers), + source_slot_mutators=frozenset(mapping_mutators), + source_slot_saved_methods=frozenset(saved_method_roles), + source_slot_primitives=frozenset(primitive_callees)) + if census.limits: + raise CallLimit(census.limits[0]) + for current, initializer in pending_getter_markers: + raw_producers: dict[str, set[ast.AST | None]] = {} + replay = _attribute_patches(current.tree, patch_producers=raw_producers) + if not (initializer in saved_getter_initializers and not census.sites + and replay == current.attribute_patches + and raw_producers.get(MODULE_TABLE_COMPUTED) == {initializer}): + raise CallLimit(f"{current.ref} has an undischarged saved getter namespace marker") + for view, marker, initializer, rhs, expected_patches, expected_producers in pending_method_markers: + producers: dict[str, set[ast.AST | None]] = {} + replay = _qualified_attribute_patches(view, raw_namespaces=True, patch_producers=producers) + if not (not census.sites and not view.namespace_builtins_changed + and replay == expected_patches + and {key: frozenset(values) for key, values in producers.items()} == expected_producers + and marker in replay and producers.get(marker) in ({initializer}, {initializer, rhs}) + and initializer in saved_method_roles and rhs in values): + raise CallLimit(f"{view.ref} has an undischarged saved method wildcard") + proof._namespace_directory_currency() + return not census.sites + except _Stop as stop: + raise CallLimit(stop.detail) from None + finally: + self.resolver._checking_source_module_slot = False - def _parameters(self, module: PythonModule, expressions: list[ast.expr]) -> set[str]: - """List-value dependencies only; a factory's ordinary inputs are lazy. + def uncalled_source_slot( + self, module: PythonModule, function: Function, *, refused_models: list[str] | None = None, + ) -> bool: + """An independently completed zero-call fact for an exact self-slot. - A call is projected by the membership resolver. Looking into all its - arguments here would demand callers for e.g. a database handle that - only the nested tool body uses, although the returned list is literal. - Unread deeper forwarding remains a named membership limit. + This does not change the ordinary caller cache or synthesize caller + evidence. Each attempted import model completes its own static proof. + """ + if not isinstance(function, ast.FunctionDef) or self.resolver._checking_source_module_slot: + return False + proof = BuilderCalls(self.resolver) + work = 0 + for path in proof._candidates({function.name} | _module_words(module.path), module.path): + caller = proof._module(path) + for node in ast.walk(caller.tree): + work += 1 + if work > MAX_NAMESPACE_CONTEXT_NODES: + raise CallLimit(f"the source-module slot candidate census exceeds {MAX_NAMESPACE_CONTEXT_NODES} syntax nodes") + if not isinstance(node, ast.Assign | ast.Expr | ast.AugAssign): + continue + candidate = proof._source_slot_candidate(caller, node) + if candidate is None or candidate[0] is not module or candidate[1] is not function: + continue + refused: list[str] = [] + for family in ("agents", "google.adk"): + try: + if proof.idempotent_source_slot(caller, node, family): + return True + detail = "the model did not establish empty source-slot ownership" + except CallLimit as exc: + detail = str(exc) # Another model cannot inherit a partial proof. + refused.append(f"{family}: {caller.ref}:{node.lineno}: {module.ref}:{function.lineno} {function.name}: {detail}"[:1024]) + if refused_models is not None: + for detail in refused: + if detail not in refused_models and len(refused_models) < 2: + refused_models.append(detail) + return False + return False + + def _unused_namespaces(self, module: PythonModule) -> _UnusedNamespaceEdges: + """Read a bare Import-backed namespace copied to an unused destination. + + This is a data-use proof only. Source/provider/import effects and every + other namespace use remain subject to the normal constructor checks. """ + cached = self._unused_namespace_data.get(module.tree) + if cached is not None: + return cached + nodes = list(ast.walk(module.tree)) + if len(nodes) > 20_000: + return _UnusedNamespaceEdges(module.tree) scopes = self.scopes(module) - found: set[str] = set() - seen: set[int] = set() - pending: list[ast.AST] = list(expressions) - while pending: - node = pending.pop() - if id(node) in seen: + canonical = { + path.rpartition(".")[0] + for family in ("agents", "google.adk") + for path in (_external_constructor_paths(family) | _external_wrapper_paths(family) + | _external_decorator_paths(family)) + } + values: set[ast.Name] = set() + work = 0 + for statement in module.tree.body: + if not (isinstance(statement, ast.Assign) and len(statement.targets) == 1 + and isinstance(target := statement.targets[0], ast.Name) + and isinstance(value := statement.value, ast.Name)): continue - seen.add(id(node)) - if len(seen) > 1000: - raise CallLimit("capability dependency flow exceeds 1000 expressions") - if isinstance(node, ast.Name): - bindings = scopes.enclosing_bindings(evaluation_site(scopes, node), node.id) - if len(bindings) == 1: - binding = bindings[0] - if isinstance(binding, ast.arg): - found.add(binding.arg) - else: - statement = scopes.statement_of(binding) - if ( - isinstance(statement, ast.Assign | ast.AnnAssign) - and statement.value is not None - ): - pending.append(statement.value) - elif isinstance(node, ast.Lambda): + destination = module.bindings.get(target.id, []) + source = module.bindings.get(value.id, []) + if not (len(destination) == 1 and destination[0].node is target + and len(source) == 1 and isinstance(alias := source[0].node, ast.alias) + and isinstance(imported := source[0].statement, ast.Import) + and imported in module.tree.body and imported.lineno < statement.lineno): continue - elif isinstance(node, ast.IfExp): - pending.extend((node.body, node.orelse)) - elif isinstance(node, ast.ListComp | ast.GeneratorExp): - pending.extend(generator.iter for generator in node.generators) - elif isinstance(node, ast.Call): - if isinstance(node.func, ast.Attribute) and node.func.attr == "get": - pending.append(node.func.value) - elif isinstance(node.func, ast.Name) and node.func.id in { - "list", - "tuple", - "sorted", - "filter", - }: - pending.extend(node.args[1:2] if node.func.id == "filter" else node.args[:1]) + work += len(nodes) + if work > 20_000: + break + if any( + isinstance(node, ast.Name) and node.id == target.id and node is not target + or isinstance(node, ast.Constant) and isinstance(node.value, str) and node.value == target.id + for node in nodes + ): + continue + outcome = self.resolver._constructor_reference(module, value, scopes) + namespace = outcome.get("retained_namespace") + if isinstance(namespace, Path) and self.resolver.contains(namespace): + self._module(namespace) # Capture and parse the actual provider. + elif outcome.get("external_constructor") in canonical: + try: + if self.resolver._external_provider_issue(module, imported, alias) is not None: + continue + except _Stop as exc: + raise CallLimit(exc.detail) from None else: - pending.extend(ast.iter_child_nodes(node)) - return found + continue # Arbitrary missing imports are not namespace identities. + words = _module_words(module.path) + for path in self._candidates({target.id} | words, module.path): + if path == module.path: + continue + _, possible = self._expanding_imports( + self._module(path), {target.id}, words, {module.path}, + subject=module.path, namespace_carriers=True, + ) + if possible: + break + else: + values.add(value) + result = _UnusedNamespaceEdges(module.tree, frozenset(values)) + self._unused_namespace_data[module.tree] = result + return result + + def _confined_dictionary_data(self, module: PythonModule) -> _DictionaryDataEdges: + """Prove confinement without asking whether any stored function is safe. - def callers(self, module: PythonModule, function: Function) -> CallerCensus: - cached = self._censuses.get(id(function)) + Only literal dictionaries, module-level plain aliases and literal + key Store/Del and exact literal initialization/update/ior writes are read. No retrieval, + result, method handle, capture, namespace or export can borrow this proof. Other RHS obligations remain + with the constructor reader and the ordinary import/dependency census. + """ + cached = self._dictionary_data.get(module.tree) if cached is not None: return cached - try: - result = self._census(module, function) - except CallLimit as exc: - result = CallerCensus(limits=(str(exc),)) - self._censuses[id(function)] = result + scopes = self.scopes(module) + nodes = list(ast.walk(module.tree)) + empty = _DictionaryDataEdges(module.tree) + if len(nodes) > 20_000: + return empty + def literal_mapping(value: ast.AST) -> tuple[tuple[ast.expr, ...], tuple[ast.Constant, ...]] | None: + if not isinstance(value, ast.Dict) or not all( + isinstance(key, ast.Constant) and isinstance(key.value, str) + and not key.value.startswith("__") and isinstance(item, ast.Name | ast.Attribute) + for key, item in zip(value.keys, value.values, strict=True) + ): + return None + return tuple(value.values), tuple(key for key in value.keys if isinstance(key, ast.Constant)) + + declarations: dict[str, ast.Assign] = {} + augmented: set[ast.AugAssign] = set() + for name, bindings in module.bindings.items(): + primary = [binding for binding in bindings if isinstance(binding.node, ast.Name) + and isinstance(binding.statement, ast.Assign) + and binding.statement.targets == [binding.node] + and binding.statement in module.tree.body] + if len(primary) != 1: + continue + allocation = primary[0].statement + operations: set[ast.AugAssign] = set() + for binding in bindings: + if binding is primary[0]: + continue + statement = binding.statement + if not (isinstance(binding.node, ast.Name) and isinstance(statement, ast.AugAssign) + and statement.target is binding.node and isinstance(statement.op, ast.BitOr) + and statement in module.tree.body and statement.lineno > allocation.lineno + and literal_mapping(statement.value) is not None): + break + operations.add(statement) + else: + declarations[name] = allocation + augmented.update(operations) + values: set[ast.expr] = set() + keys: set[ast.Constant] = set() + work = 0 + for root, declaration in declarations.items(): + initial = literal_mapping(declaration.value) + if initial is None: + continue + names = {root} + for _ in range(128): + work += len(declarations) + if work > 20_000: + break + added = {name for name, statement in declarations.items() + if isinstance(statement.value, ast.Name) + and statement.value.id in names + and statement.value.lineno > declarations[statement.value.id].lineno} + if added <= names: + break + names |= added + if len(names) >= 128: + break + else: + continue + if work > 20_000: + break + if len(names) >= 128: + continue + work += len(nodes) + if work > 20_000: + break + own_values: set[ast.expr] = set(initial[0]) + own_keys: set[ast.Constant] = set(initial[1]) + confined = True + for node in nodes: + if isinstance(node, ast.Constant) and isinstance(node.value, str) and node.value in names: + confined = False # Includes explicit/dynamic export spellings. + break + if not isinstance(node, ast.Name) or node.id not in names: + continue + statement = scopes.statement_of(node) + if isinstance(node.ctx, ast.Store) and statement is declarations[node.id]: + continue + if (isinstance(node.ctx, ast.Store) and isinstance(statement, ast.AugAssign) + and statement in augmented and statement.target is node): + mapping = literal_mapping(statement.value) + assert mapping is not None + own_values.update(mapping[0]) + own_keys.update(mapping[1]) + continue + if not isinstance(node.ctx, ast.Load) or statement not in module.tree.body: + confined = False + break + parent = scopes.parents.get(node) + if (isinstance(parent, ast.Assign) and parent.value is node + and len(parent.targets) == 1 and isinstance(parent.targets[0], ast.Name) + and parent.targets[0].id in names + and declarations[parent.targets[0].id] is parent): + continue + if (isinstance(parent, ast.Subscript) and parent.value is node + and isinstance(parent.slice, ast.Constant) + and isinstance(parent.slice.value, str) + and not parent.slice.value.startswith("__") + and node.lineno > declarations[node.id].lineno): + if (isinstance(parent.ctx, ast.Store) and isinstance(statement, ast.Assign) + and statement.targets == [parent] + and isinstance(statement.value, ast.Name | ast.Attribute)): + own_values.add(statement.value) + own_keys.add(parent.slice) + continue + if isinstance(parent.ctx, ast.Del) and isinstance(statement, ast.Delete) and statement.targets == [parent]: + own_keys.add(parent.slice) + continue + if (isinstance(parent, ast.Attribute) and parent.value is node and parent.attr in {"update", "__init__"} + and isinstance(call := scopes.parents.get(parent), ast.Call) and call.func is parent + and isinstance(statement, ast.Expr) and statement.value is call + and node.lineno > declarations[node.id].lineno): + if not call.args and all( + keyword.arg is not None and not keyword.arg.startswith("__") + and isinstance(keyword.value, ast.Name | ast.Attribute) for keyword in call.keywords + ): + own_values.update(keyword.value for keyword in call.keywords) + continue + if len(call.args) == 1 and not call.keywords and (mapping := literal_mapping(call.args[0])) is not None: + own_values.update(mapping[0]) + own_keys.update(mapping[1]) + continue + confined = False + break + if not confined: + continue + # Complete the bounded import census before publishing any edges. + words = _module_words(module.path) + for path in self._candidates(names | words, module.path): + if path == module.path: + continue + _, possible = self._expanding_imports( + self._module(path), names, words, {module.path}, + subject=module.path, namespace_carriers=True, + ) + if possible: + confined = False + break + if confined: + values.update(own_values) + keys.update(own_keys) + result = _DictionaryDataEdges(module.tree, frozenset(values), frozenset(keys)) + self._dictionary_data[module.tree] = result return result + def _implicit_child_binding( + self, caller: PythonModule, statement: ast.Import | ast.ImportFrom, + alias: ast.alias, child: PythonModule, + ) -> bool: + """An otherwise empty initializer repeats Python's own child binding.""" + if not (caller.path.name == "__init__.py" and child.path.parent == caller.path.parent + and child.path.name == alias.name + ".py" + and isinstance(statement, ast.ImportFrom) and statement.level == 1 + and statement.module is None and statement.names == [alias] + and alias.asname is None and alias.name.isidentifier()): + return False + body = caller.tree.body + if not (len(body) == 1 and body[0] is statement or len(body) == 2 and body[1] is statement + and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) + and isinstance(body[0].value.value, str)): + return False + if caller.text != self._text(caller.path) or child.text != self._text(child.path): + raise CallLimit(f"{caller.ref} or {child.ref} differs from its consumed own-child source") + try: + package = self.resolver._from_base(caller, statement) + located = self.resolver._locate(package.directory, [alias.name], spelling=alias.name) + except _Stop: + return False # The ordinary export path retains the unresolved import. + return (package.module_path == caller.path and located is not None + and located.module_path == child.path and self._module(child.path) is child) + def exported_elsewhere(self, module: PythonModule, call: ast.Call) -> bool: """A module-level result imported elsewhere is an unread retained handle. @@ -500,18 +3440,46 @@ def exported_elsewhere(self, module: PythonModule, call: ast.Call) -> bool: if any(getattr(binding.statement, "value", None) is call for binding in bindings) } exported = False + implicit_child = False + routes = self._export_routes[id(call)] = set() if names: try: for path in self._candidates(_module_words(module.path), module.path): caller = self._module(path) if caller.path == module.path: continue + excluded = self.runtime_exclusions(caller) for statement in ast.walk(caller.tree): - if not isinstance(statement, ast.Import | ast.ImportFrom): + if not isinstance(statement, ast.Import | ast.ImportFrom) or id(statement) in excluded: continue for alias in statement.names: - if self._retained_namespace(caller, statement, alias, {module.path}): + own_child = self._implicit_child_binding(caller, statement, alias, module) + implicit_child |= own_child + if not own_child and self._retained_namespace(caller, statement, alias, {module.path}): exported = True + routes.add("namespace") + self._export_limits.setdefault( + id(call), f"an import at {caller.ref}:{statement.lineno} " + f"retains the constructed value from {module.ref}:{call.lineno} " + f"through {alias.asname or alias.name!r}", + ) + if isinstance(statement, ast.ImportFrom) and alias.name in names: + try: + owner = self.resolver._from_base(caller, statement) + except _Stop: + owner = None + if owner is not None and owner.module_path == module.path: + # A self-wrapped/rebound member can resolve + # to no single value while still exporting + # this actual retained result. Its identity + # uncertainty cannot prove absence of export. + exported = True + routes.add("value_import") + self._export_limits.setdefault( + id(call), f"an import at {caller.ref}:{statement.lineno} " + f"retains the constructed value from {module.ref}:{call.lineno} " + f"through {alias.asname or alias.name!r}", + ) references = ( [alias.asname or alias.name] if isinstance(statement, ast.ImportFrom) @@ -519,6 +3487,11 @@ def exported_elsewhere(self, module: PythonModule, call: ast.Call) -> bool: ) if alias.name == "*": exported = True + routes.add("wildcard") + self._export_limits.setdefault( + id(call), f"the wildcard import at {caller.ref}:{statement.lineno} " + f"leaves export of the constructed value at {module.ref}:{call.lineno} unread", + ) for reference in references: result = self.resolver.resolve_local_import( caller, statement, alias, reference @@ -529,19 +3502,38 @@ def exported_elsewhere(self, module: PythonModule, call: ast.Call) -> bool: and result.value is call ): exported = True - except CallLimit: + routes.add("value_import" if isinstance(statement, ast.ImportFrom) else "module_reference") + self._export_limits.setdefault( + id(call), f"an import at {caller.ref}:{statement.lineno} " + f"retains the constructed value from {module.ref}:{call.lineno} " + f"through {reference!r}", + ) + if implicit_child: + self._namespace_directory_currency() + except CallLimit as exc: + self._export_limits.setdefault(id(call), str(exc)) exported = True - self._exports[id(call)] = exported + routes.add("unread") + if exported or not implicit_child: + self._exports[id(call)] = exported return exported - def borrowers(self, module: PythonModule, name: str) -> tuple[PythonModule, ...]: + def export_routes(self, call: ast.expr) -> frozenset[str]: + """How the export census found the value leave its module; empty if it did not run.""" + return frozenset(self._export_routes.get(id(call), ())) + + def export_limit(self, call: ast.expr) -> str | None: + """Preserve the first concrete import route or census failure beside its refusal.""" + return self._export_limits.get(id(call)) + + def borrowers(self, module: PythonModule, name: str, *, namespace_carriers: bool = False) -> tuple[PythonModule, ...]: """Candidate importers of a shared module value, including re-exports. Membership still belongs to ListExpressions. It checks each candidate with the existing import-identity and mutation tests. Failure to finish this bounded census never establishes a borrowed list's contents. """ - key = (id(module.tree), name) + key = (id(module.tree), name, namespace_carriers) if key in self._borrowers: return self._borrowers[key] names, words = {name}, _module_words(module.path) @@ -555,7 +3547,7 @@ def borrowers(self, module: PythonModule, name: str) -> tuple[PythonModule, ...] continue # Its own aliases/mutations are indexed by ListExpressions. caller = self._module(path) aliases, possible = self._expanding_imports( - caller, names, words, related, subject=module.path + caller, names, words, related, subject=module.path, namespace_carriers=namespace_carriers ) if possible: expanded.update(_module_words(path)) @@ -574,12 +3566,17 @@ def borrowers(self, module: PythonModule, name: str) -> tuple[PythonModule, ...] ) def borrower_spellings( - self, module: PythonModule, name: str + self, module: PythonModule, name: str, *, namespace_carriers: bool = False ) -> tuple[tuple[str, ...], frozenset[str]]: - self.borrowers(module, name) - key = (id(module.tree), name) + self.borrowers(module, name, namespace_carriers=namespace_carriers) + key = (id(module.tree), name, namespace_carriers) return self._borrower_aliases[key], self._borrower_words[key] + def retaining_modules(self, module: PythonModule, name: str, *, namespace_carriers: bool = False) -> frozenset[Path]: + """Modules whose globals can retain a subject through imports/re-exports.""" + self.borrowers(module, name, namespace_carriers=namespace_carriers) + return self._borrower_paths[id(module.tree), name, namespace_carriers] + def import_may_share( self, caller: PythonModule, @@ -587,10 +3584,11 @@ def import_may_share( name: str, statement: ast.Import | ast.ImportFrom, alias: ast.alias, + *, namespace_carriers: bool = False, ) -> bool: """Whether one import may retain the subject list or its namespace.""" - names, words = self.borrower_spellings(defining, name) - key = (id(defining.tree), name) + names, words = self.borrower_spellings(defining, name, namespace_carriers=namespace_carriers) + key = (id(defining.tree), name, namespace_carriers) _, possible = self._expanding_imports( caller, set(names), @@ -599,10 +3597,33 @@ def import_may_share( (statement,), subject=defining.path, selected_alias=alias, + namespace_carriers=namespace_carriers, ) return possible - def _census(self, module: PythonModule, function: Function) -> CallerCensus: + def _census( + self, module: PythonModule, function: Function, *, allow_empty: bool = False, + confined_dictionary_data: bool = False, + unused_namespace_data: bool = False, + confined_primitive_dictionary_values: frozenset[ast.expr] = frozenset(), + absent_field_roles: _AbsentFieldDictionaryRoles | None = None, + source_slot_values: frozenset[ast.expr] = frozenset(), + source_slot_namespaces: frozenset[ast.Name] = frozenset(), + source_slot_mappings: frozenset[ast.Name] = frozenset(), + source_slot_mutators: frozenset[ast.Attribute] = frozenset(), + source_slot_saved_methods: frozenset[ast.Attribute | ast.Name] = frozenset(), + source_slot_primitives: frozenset[ast.Name | ast.Attribute] = frozenset(), + source_slot_projections: frozenset[ast.Attribute | ast.Call] = frozenset(), + source_slot_getters: frozenset[ast.Name] = frozenset(), + source_slot_keys: frozenset[ast.Constant] = frozenset(), + source_slot_metadata: frozenset[ast.Constant] = frozenset(), + ) -> CallerCensus: + if absent_field_roles is not None and not ( + absent_field_roles.proof is self and absent_field_roles.home is module + and absent_field_roles.function is function and self._fresh_receipt_active + and not self._fresh_receipt_finished and self._fresh_identity_session is not None + ): + raise CallLimit("the absent-field roles do not belong to this active exact census") where = f"{function.name!r} ({module.ref}:{function.lineno})" binding = module.bindings.get(function.name, []) if function.decorator_list or len(binding) != 1 or binding[0].node is not function: @@ -636,18 +3657,51 @@ def _census(self, module: PythonModule, function: Function) -> CallerCensus: for path in self._candidates(names | modules, module.path): caller = self._module(path) scopes = self.scopes(caller) + data = self._confined_dictionary_data(caller) if confined_dictionary_data else _DictionaryDataEdges(caller.tree) + namespaces = self._unused_namespaces(caller) if unused_namespace_data else _UnusedNamespaceEdges(caller.tree) exports = _export_strings(caller.tree) dynamic = self.dynamic_importer(caller) if dynamic is not None: limits.append( f"{where} may be reached through dynamic import machinery at {caller.ref}:{dynamic.lineno}" ) - reflection = reflective_access(caller.tree) + excluded = self.runtime_exclusions(caller) + reflection = reflective_access(caller.tree, excluded=excluded) if caller.path in related and reflection is not None: limits.append( f"{where} may be reached through reflection at {caller.ref}:{reflection.lineno}" ) for node in ast.walk(caller.tree): + if id(node) in excluded: + continue + if absent_field_roles is not None and ( + node is absent_field_roles.value or node in absent_field_roles.namespace_nodes + or node is absent_field_roles.getter_metadata + or node is absent_field_roles.intrinsic_projection + ): + continue # Only exact nodes from the independent unread-field receipt. + if node in confined_primitive_dictionary_values: + continue # Only exact Function-data values from a live private primitive receipt. + if node in source_slot_values: + continue # This exact RHS has its own actual identity and no-change proof. + if node in source_slot_namespaces: + continue # Independently bounded namespace node, never Function evidence. + if node in source_slot_mappings: + continue # Exact confined saved dictionary receiver, never Function evidence. + if node in source_slot_mutators: + continue # Exact modeled dictionary method; its Call and descendants remain visited. + if node in source_slot_saved_methods: + continue # Only exact saved method roles; Call and literal descendants remain visited. + if node in source_slot_primitives: + continue # Exact raw-proved builtin callee, separate from the selected Function. + if node in source_slot_projections: + continue # Only an independently modeled intrinsic source mapping. + if node in source_slot_getters: + continue # Exact canonical getter callee, never Function evidence. + if node in source_slot_keys: + continue # Exact same-slot literal key, never a general string exemption. + if node in source_slot_metadata: + continue # Exact intrinsic getter metadata, distinct from Function and slot-key evidence. if isinstance(node, ast.Call) and (reference_spelling(node.func) or "").rsplit( ".", 1 )[-1] in {"__import__", "import_module"}: @@ -672,7 +3726,7 @@ def _census(self, module: PythonModule, function: Function) -> CallerCensus: for name in names ): parent = scopes.parents.get(node) - if not (isinstance(parent, ast.Attribute) and parent.attr in names): + if not (isinstance(parent, ast.Attribute) and parent.attr in names) and node not in namespaces.values: limits.append( f"{where}'s module is used through computed access or as a value at {caller.ref}:{node.lineno}" ) @@ -690,7 +3744,7 @@ def _census(self, module: PythonModule, function: Function) -> CallerCensus: for alias in statement.names: if (alias.asname or alias.name.split(".", 1)[0]) != head: continue - if self._retained_namespace(caller, statement, alias, related): + if self._retained_namespace(caller, statement, alias, related) and node not in namespaces.values: limits.append( f"{where}'s retained namespace is used through computed access or as a value at {caller.ref}:{node.lineno}" ) @@ -699,7 +3753,7 @@ def _census(self, module: PythonModule, function: Function) -> CallerCensus: and isinstance(node.value, str) and node.value in names ): - if id(node) not in exports: + if id(node) not in exports and node not in data.keys: limits.append(f"{where} is named in a string at {caller.ref}:{node.lineno}") continue if not ( @@ -717,13 +3771,13 @@ def _census(self, module: PythonModule, function: Function) -> CallerCensus: parent = scopes.parents.get(node) if isinstance(parent, ast.Call) and parent.func is node: sites.append(CallSite(caller, parent)) - else: + elif node not in data.values: limits.append(f"{where} is used as a value at {caller.ref}:{node.lineno}") - elif not self._elsewhere(caller, node, resolution): + elif not (self._elsewhere(caller, node, resolution) or self._agent_instance_attribute(caller, node)): limits.append( f"{where} has an unresolved reference at {caller.ref}:{node.lineno}: {resolution.detail}" ) - if not sites: + if not sites and not allow_empty: limits.append(f"no direct caller of {where} is established in the read scope") if len(sites) > MAX_CONTEXTS: raise CallLimit(f"{where} has more than {MAX_CONTEXTS} caller contexts") @@ -747,6 +3801,7 @@ def _expanding_imports( *, subject: Path, selected_alias: ast.alias | None = None, + namespace_carriers: bool = False, ) -> tuple[set[str], bool]: """Expand aliases through related imports, not matching module basenames. @@ -757,8 +3812,9 @@ def _expanding_imports( """ aliases: set[str] = set() possible = False + excluded = self.runtime_exclusions(caller) for statement in statements if statements is not None else ast.walk(caller.tree): - if not isinstance(statement, ast.Import | ast.ImportFrom): + if not isinstance(statement, ast.Import | ast.ImportFrom) or id(statement) in excluded: continue _, textual = _candidate_imports(statement, names, words) ancestor = any( @@ -774,11 +3830,12 @@ def _expanding_imports( try: if isinstance(statement, ast.ImportFrom): container = self.resolver._from_base(caller, statement) + relevant = namespace_carriers and container.module_path in related if alias.name != "*": child = self.resolver._locate( container.directory, alias.name.split("."), spelling=alias.name ) - relevant = child is not None and child.module_path in related + relevant |= child is not None and child.module_path in related else: container = self.resolver._absolute(caller, alias.name) relevant = container.module_path in related @@ -810,8 +3867,7 @@ def _expanding_imports( resolution.value is not None or ( resolution.definition is not None - and resolution.module.path == subject - and resolution.definition.name in names + and (namespace_carriers or (resolution.module.path == subject and resolution.definition.name in names)) ) ) ): @@ -835,6 +3891,12 @@ def _retained_namespace( related: set[Path], ) -> bool: """An imported namespace can retain loaded children of related modules.""" + if (isinstance(statement, ast.Import) and alias.name in {"agents", "openai_agents"} + and self.resolver._external_provider_issue(caller, statement, alias) is None): + # Canonical external SDK roots cannot borrow an application's + # namespace-only directory of the same name. Concrete local + # providers and imports of actual children keep the ordinary proof. + return False try: if isinstance(statement, ast.Import): dotted = alias.name if alias.asname else alias.name.split(".", 1)[0] @@ -889,6 +3951,32 @@ def _elsewhere(self, module: PythonModule, expr: ast.expr, resolution: Resolutio for item in bindings ) + def _agent_instance_attribute(self, module: PythonModule, node: ast.expr) -> bool: + """``assistant.run`` where ``assistant = Agent(...)`` is bound once. + + An attribute of an instance of the framework's own class is not this + module's function of the same name: reaching the function through it + needs the function stored as a value, and that is a limit of its own. + The class is the exact import the constructor census resolves, so a + local ``Agent`` or any other constructor does not qualify. + """ + if not (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) + and isinstance(node.ctx, ast.Load) and isinstance(node.value.ctx, ast.Load)): + return False + scopes = self.scopes(module) + found = scopes.enclosing_bindings(evaluation_site(scopes, node), node.value.id) + if len(found) != 1 or not isinstance(found[0], ast.Name): + return False + statement = scopes.statement_of(found[0]) + if not (isinstance(statement, ast.Assign) and statement.targets == [found[0]] + and isinstance(statement.value, ast.Call)): + return False + canonical = self.resolver._constructor_reference(module, statement.value.func, scopes).get("external_constructor") + return isinstance(canonical, str) and canonical in ( + _external_constructor_paths("agents") | _external_constructor_paths("openai_agents") + | _external_constructor_paths("google.adk") + ) + def another_library(self, statement: ast.Import | ast.ImportFrom, alias: ast.alias) -> bool: if isinstance(statement, ast.ImportFrom): if statement.level or not statement.module: @@ -900,7 +3988,10 @@ def another_library(self, statement: ast.Import | ast.ImportFrom, alias: ast.ali def _module(self, path: Path) -> PythonModule: try: - return self.resolver._patch_scan(path) + module = self.resolver._patch_scan(path) + if self._fresh_identity_session is not None and module.text != self._text(path): + raise CallLimit(f"{module.ref} differs from its fresh identity-bound source") + return module except _Stop as exc: raise CallLimit(exc.detail) from None @@ -941,6 +4032,428 @@ def _candidates(self, names: set[str], subject: Path | None = None) -> list[Path raise CallLimit(f"the caller census exceeds {MAX_CANDIDATES} candidate modules") return result + def _namespace_directory_entries(self, directory: Path) -> frozenset[str] | None: + """Capture each directory once; every import uses the same evidence.""" + layout = self.resolver._layout + assert layout is not None and layout.disk_root is not None + snapshot = active_static_input_snapshot() + if snapshot is not None and (snapshot.excludes(directory) + or directory != snapshot.root and not snapshot.contains(directory)): + raise CallLimit(f"{directory} above the read scope is outside the bound input snapshot") + if directory in self._namespace_directories: + return self._namespace_directories[directory] + if directory.is_symlink(): + raise CallLimit(f"{directory} above the read scope is a linked import root") + if not directory.exists(): + parent = self._namespace_directory_entries(directory.parent) + folded = directory.name.casefold() + if parent is None or any(name.casefold() == folded for name in parent): + # Absence is read from the exact listing under any letter case, + # never from whether this host's filesystem finds the name. + raise CallLimit(f"{directory} above the read scope has unread directory evidence") + if snapshot is not None and not snapshot.bind_dependency_absence(directory): + raise CallLimit(f"{directory} above the read scope is not absent") + self._namespace_directories[directory] = None + return None + if not directory.is_dir(): + raise CallLimit(f"{directory} above the read scope is not a directory") + current = frozenset(child.name for child in list_input_directory(directory)) + if self._fresh_identity_session is not None: + live = frozenset(self._fresh_identity_session.directory_entries(directory.relative_to(self._fresh_identity_session.root))) + if live != current: + raise CallLimit(f"{directory} differs from its live fresh import-root inventory") + self._namespace_entry_work += len(current) + if self._namespace_entry_work > 100000: + raise CallLimit("the namespace ancestry census exceeds 100000 directory entries") + ref = directory.relative_to(layout.disk_root).as_posix() + cached = layout.entries("" if ref == "." else ref) + if cached is None or cached != current: + raise CallLimit(f"{directory} above the read scope has unread or changed directory evidence") + self._namespace_directories[directory] = current + names_by_fold: dict[str, set[str]] = {} + for name in current: + names_by_fold.setdefault(name.casefold(), set()).add(name) + self._namespace_directory_names[directory] = { + folded: frozenset(names) for folded, names in names_by_fold.items() + } + return current + + def _namespace_directory_currency(self) -> None: + """Reconfirm consumed source bytes, inventory topology and ancestry.""" + try: + for directory, captured in self._inventory_directories.items(): + if directory.is_symlink() or not directory.is_dir(): + raise CallLimit(f"{directory} changed after its caller inventory was read") + children = list_input_directory(directory) + if frozenset(child.name for child in children) != frozenset(name for name, _kind in captured): + raise CallLimit(f"{directory} changed after its caller inventory was read") + # Only the unchanged population bounded by the original census + # can require kind reads; reject added names before any lstat. + current = frozenset((child.name, stat.S_IFMT(child.lstat().st_mode)) for child in children) + if current != captured: + raise CallLimit(f"{directory} changed after its caller inventory was read") + for marker, captured_selector in self._inventory_venv_selectors.items(): + present = _lists_entry(marker) + current_selector = (present, stat.S_IFMT(marker.lstat().st_mode) if present else None) + if current_selector != captured_selector: + raise CallLimit(f"{marker} changed its caller inventory virtual-environment selector") + # Reconfirm only bytes already consumed by this proof. A quiet + # source can become a caller without changing directory topology. + sizes = {path: len(text.encode("utf-8")) for path, text in self._texts.items()} + if sum(sizes.values()) > MAX_TOTAL_BYTES: + raise CallLimit(f"the caller census exceeds {MAX_TOTAL_BYTES} source bytes") + for path, captured_text in self._texts.items(): + metadata = path.lstat() + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size != sizes[path]: + raise CallLimit(f"{self.resolver.ref(path)} changed after its namespace source bytes were read") + if load_text_file(path) != captured_text: + raise CallLimit(f"{self.resolver.ref(path)} changed after its namespace source bytes were read") + for directory, captured in self._namespace_directories.items(): + if directory.is_symlink() or (captured is None and directory.exists()): + raise CallLimit(f"{directory} changed after its namespace directory evidence was read") + if captured is not None and ( + not directory.is_dir() + or frozenset(child.name for child in list_input_directory(directory)) != captured + ): + raise CallLimit(f"{directory} changed after its namespace directory evidence was read") + except (InputParseError, OSError, ValueError) as exc: + raise CallLimit(f"the namespace directory evidence could not be reconfirmed: {exc}") from None + + def _namespace_scope_absence(self) -> None: + """Refuse selected scopes without bound, readable ancestor absence. + + This proves no getter behavior. Any actual initializer above the scope + remains outside this first source-context increment, even if readable. + """ + layout = self.resolver._layout + if layout is None or not layout.scope: + return + if layout.disk_root is None: + raise CallLimit("the namespace source context has no bound disk ancestry above the read scope") + root = layout.disk_root + if root / layout.scope != self.resolver.scope_root: + raise CallLimit("the namespace source context's disk ancestry does not match the read scope") + snapshot = active_static_input_snapshot() + parts = layout.scope.split("/") + for length in range(len(parts)): + ref = "/".join(parts[:length]) + directory = root / ref + if snapshot is not None and (snapshot.excludes(directory) + or directory != snapshot.root and not snapshot.contains(directory)): + raise CallLimit(f"{directory} above the read scope is outside the bound input snapshot") + try: + current = self._namespace_directory_entries(directory) + if current is None: + raise CallLimit(f"{directory} above the read scope is not a readable plain directory") + initializers = sorted(name for name in current if name.casefold() == "__init__.py") + if initializers: + raise CallLimit( + f"the namespace source context for {layout.scope} has initializer candidates " + f"above the read scope: {directory}/{' or '.join(initializers)}" + ) + marker = directory / "__init__.py" + if snapshot is not None and not snapshot.bind_dependency_absence(marker): + raise CallLimit(f"{marker} above the namespace read scope is not absent") + except (InputParseError, OSError, ValueError) as exc: + raise CallLimit(f"{directory} above the read scope could not be inspected: {exc}") from None + + def _namespace_operator_metadata_import(self, module: PythonModule, statement: ast.Import, imported: ast.alias) -> bool: + """A confined metadata-only import boundary, with captured root currency.""" + if imported.name != "operator" or imported.asname is not None or statement.names != [imported]: + return False + metadata = self._operator_metadata_write(module, self._operator_metadata_statement(module)) + if not (metadata is not None and metadata.declaration is statement and metadata.imported is imported): + return False + self._namespace_import_disk_root(module, statement, "operator metadata import") + from agents_shipgate.inputs.list_expressions import _standard_operator, _View, bindings_at + + view = _View(module.ref, module.tree, self.scopes(module), module.bindings, module, set()) + view.lookup = bindings_at(view.scopes, view.bindings) + view.resolver = self.resolver + if not _standard_operator(view): + return False + return self._namespace_import_root_currency(module, statement, "operator metadata import") + + def _namespace_operator_setitem_import(self, module: PythonModule, statement: ast.Import, imported: ast.alias) -> bool: + """Confined import boundary only; the independent slot proof still owes every role.""" + if imported.name != "operator" or imported.asname is not None or statement.names != [imported]: + return False + if len(module.tree.body) > MAX_NAMESPACE_CONTEXT_NODES: + return False + records = [record for node in module.tree.body + if (record := self._source_operator_setitem(module, node)) is not None + and record.statement is statement and record.imported is imported] + if len(records) != 1: + return False + self._namespace_import_disk_root(module, statement, "operator setitem import") + from agents_shipgate.inputs.list_expressions import _standard_operator, _View, bindings_at + + view = _View(module.ref, module.tree, self.scopes(module), module.bindings, module, set()) + view.lookup = bindings_at(view.scopes, view.bindings) + view.resolver = self.resolver + return _standard_operator(view) and self._namespace_import_root_currency(module, statement, "operator setitem import") + + def _namespace_operator_ior_import(self, module: PythonModule, statement: ast.Import, imported: ast.alias) -> bool: + """Confined import boundary only; the independent slot proof still owes every role.""" + if imported.name != "operator" or imported.asname is not None or statement.names != [imported]: + return False + if len(module.tree.body) > MAX_NAMESPACE_CONTEXT_NODES: + return False + records = [record for node in module.tree.body + if (record := self._source_operator_ior(module, node)) is not None + and record.statement is statement and record.imported is imported] + if len(records) != 1: + return False + self._namespace_import_disk_root(module, statement, "operator ior import") + from agents_shipgate.inputs.list_expressions import _standard_operator, _View, bindings_at + + view = _View(module.ref, module.tree, self.scopes(module), module.bindings, module, set()) + view.lookup = bindings_at(view.scopes, view.bindings) + view.resolver = self.resolver + return _standard_operator(view) and self._namespace_import_root_currency(module, statement, "operator ior import") + + def _namespace_from_operator_ior_import(self, module: PythonModule, statement: ast.ImportFrom, imported: ast.alias) -> bool: + """Confined import boundary only; the independent slot proof still owes every role.""" + if (statement.module != "operator" or statement.level or imported.name != "ior" + or imported.asname is None or statement.names != [imported]): + return False + if len(module.tree.body) > MAX_NAMESPACE_CONTEXT_NODES: + return False + names = self._source_from_operator_token_index(module)[0].get(imported.asname, frozenset()) + if len(names) != 1: + return False + callee = next(iter(names)) + scopes = self.scopes(module) + call = scopes.parents.get(callee) + terminal = scopes.parents.get(call) + if not (isinstance(call, ast.Call) and call.func is callee + and isinstance(terminal, ast.Expr) and terminal.value is call + and (record := self._source_from_operator_ior(module, terminal)) is not None + and record.statement is statement and record.imported is imported): + return False + self._namespace_import_disk_root(module, statement, "from operator ior import") + from agents_shipgate.inputs.list_expressions import _standard_operator, _View, bindings_at + + view = _View(module.ref, module.tree, self.scopes(module), module.bindings, module, set()) + view.lookup = bindings_at(view.scopes, view.bindings) + view.resolver = self.resolver + return _standard_operator(view) and self._namespace_import_root_currency(module, statement, "from operator ior import") + + def _namespace_import_disk_root(self, module: PythonModule, statement: ast.Import | ast.ImportFrom | ast.Assign | ast.Expr, role: str) -> Path: + """Bound disk provenance only, without granting any import or assignment its role.""" + layout = self.resolver._layout + if layout is None or layout.disk_root is None: + raise CallLimit(f"{module.ref}:{statement.lineno} has no bound import-root currency for the {role}") + if layout.disk_root / layout.scope != self.resolver.scope_root: + raise CallLimit(f"{module.ref}:{statement.lineno} has disk ancestry that does not match the read scope") + return layout.disk_root + + def _namespace_import_root_currency(self, module: PythonModule, statement: ast.Import | ast.ImportFrom | ast.Assign | ast.Expr, role: str) -> bool: + """Capture the same root prefixes; provider identity remains separate.""" + root = self._namespace_import_disk_root(module, statement, role) + layout = self.resolver._layout + assert layout is not None + for search in self.resolver._import_roots(): + prefixes = [""] + ["/".join(search.split("/")[:i]) for i in range(1, len(search.split("/")) + 1)] if search else [""] + previous: frozenset[str] | None = None + for prefix in prefixes: + captured = self._namespace_directory_entries(root / prefix) + if prefix and previous is not None and prefix.rsplit("/", 1)[-1] not in previous: + if captured is not None: + return False + break # The same parent listing and missing prefix used by the raw standard-owner proof. + expected = layout.entries(prefix) + if expected is None or captured is None or captured != frozenset(expected): + return False + previous = captured + return True + + def namespace_source_context(self, family: str, *, allow_operator_metadata: bool = False, + allow_operator_setitem: bool = False, + allow_operator_ior: bool = False, + allow_from_operator_ior: bool = False) -> NamespaceSourceContext: + """Read a small complete production census and its actual import closure. + + The inventory excludes tests, hidden directories and virtual environments; + actual imports into those locations still expand the closure. This first + increment refuses unbound ancestry, above-scope inputs and all unmodeled + external imports. + Nothing here calls a receiving/ownership proof or caches a semantic grant. + """ + self._namespace_entry_work = 0 + self._namespace_directories: dict[Path, frozenset[str] | None] = {} + self._namespace_directory_names: dict[Path, dict[str, frozenset[str]]] = {} + self._namespace_scope_absence() + pending = list(self._inventory()) + seen = set(pending) + modules: list[PythonModule] = [] + boundaries: list[str] = [] + nodes = 0 + symbols = _external_constructor_paths(family) | _external_decorator_paths(family) + namespaces = {symbol.rsplit(".", 1)[0] for symbol in _external_constructor_paths(family)} + + def append(path: Path) -> None: + if not path.is_relative_to(self.resolver.scope_root): + raise CallLimit(f"{path} is above the namespace source context's read scope") + if path not in seen: + seen.add(path) + pending.append(path) + if len(seen) > MAX_CANDIDATES: + raise CallLimit(f"the namespace source context exceeds {MAX_CANDIDATES} total modules") + + if len(seen) > MAX_CANDIDATES: + raise CallLimit(f"the namespace source context exceeds {MAX_CANDIDATES} total modules") + try: + for path in pending: + text = self._text(path) # Account captured bytes before parsing, including expanded imports. + module = self._module(path) + if module.text != text: + raise CallLimit(f"{module.ref} changed after its parsed namespace source was read") + modules.append(module) + for _node in ast.walk(module.tree): + nodes += 1 + if nodes > MAX_NAMESPACE_CONTEXT_NODES: + raise CallLimit( + f"the namespace source context exceeds {MAX_NAMESPACE_CONTEXT_NODES} syntax nodes" + ) + dynamic = self.dynamic_importer(module) + if dynamic is not None: + raise CallLimit( + f"{module.ref}:{dynamic.lineno} has unread dynamic import machinery " + "in the namespace source context" + ) + for node in ast.walk(module.tree): + if not isinstance(node, ast.Import | ast.ImportFrom): + continue + self._namespace_above_import(module, node) + if any(alias.name == "*" for alias in node.names): + raise CallLimit(f"{module.ref}:{node.lineno} has an unread wildcard namespace import") + if isinstance(node, ast.Import): + # Every actual alias has its own read obligation. A + # readable sibling cannot stand in for a namespace + # portion or an absent canonical external provider. + for alias in node.names: + root = alias.name.split(".", 1)[0] + if root in _PRELOADED: + boundaries.append(f"{module.ref}:{node.lineno}: preloaded {root}") + continue + try: + containers = self.resolver._absolute_candidates(module, alias.name) + except _Stop as stop: + if (stop.reason == MODULE_NOT_FOUND and allow_operator_metadata + and self._namespace_operator_metadata_import(module, node, alias)): + boundaries.append(f"{module.ref}:{node.lineno}: confined operator metadata import") + continue + if (stop.reason == MODULE_NOT_FOUND and allow_operator_setitem + and self._namespace_operator_setitem_import(module, node, alias)): + boundaries.append(f"{module.ref}:{node.lineno}: confined operator setitem import") + continue + if (stop.reason == MODULE_NOT_FOUND and allow_operator_ior + and self._namespace_operator_ior_import(module, node, alias)): + boundaries.append(f"{module.ref}:{node.lineno}: confined operator ior import") + continue + if stop.reason != MODULE_NOT_FOUND or alias.name not in namespaces: + raise CallLimit(f"{module.ref}:{node.lineno}: {stop.detail}") from None + issue = self.resolver._external_provider_issue(module, node, alias) + if issue is not None: + raise CallLimit(f"{module.ref}:{node.lineno}: {issue}") from None + boundaries.append(f"{module.ref}:{node.lineno}: {alias.name}") + continue + if not containers or any(item.module_path is None for item in containers): + raise CallLimit( + f"{module.ref}:{node.lineno} imports {alias.name!r}, " + "a namespace import with no readable module" + ) + for item in containers: + assert item.module_path is not None + append(item.module_path) + for package in self.resolver._enclosing_packages(item.module_path): + append(package) + continue + paths, missing = self.resolver._imported_paths(module, node) + for stop, spelling, _names in missing: + # Only exact framework declarations have a modeled external + # boundary. Unknown external code is still an unread input. + modeled = not node.level and all( + f"{node.module}.{alias.name}" in symbols for alias in node.names + ) + if (stop.reason == MODULE_NOT_FOUND and allow_from_operator_ior and len(node.names) == 1 + and self._namespace_from_operator_ior_import(module, node, node.names[0])): + boundaries.append(f"{module.ref}:{node.lineno}: confined from operator ior import") + continue + if stop.reason != MODULE_NOT_FOUND or not modeled: + raise CallLimit(f"{module.ref}:{node.lineno}: {stop.detail}") + for alias in node.names: + issue = self.resolver._external_provider_issue(module, node, alias) + if issue is not None: + raise CallLimit(f"{module.ref}:{node.lineno}: {issue}") + boundaries.append(f"{module.ref}:{node.lineno}: {spelling}") + if not paths and not missing: + roots = [(node.module or "").split(".", 1)[0]] + if node.level or not all(root in _PRELOADED for root in roots): + raise CallLimit(f"{module.ref}:{node.lineno} has a namespace import with no readable module") + boundaries.append(f"{module.ref}:{node.lineno}: preloaded {','.join(roots)}") + for item in paths: + append(item) + for package in self.resolver._enclosing_packages(item): + append(package) + for package in self.resolver._enclosing_packages(path): + append(package) + except _Stop as stop: + raise CallLimit(stop.detail) from None + search_reader = _CapturedImportSearchResolver(self.resolver, modules) + try: + limits = search_reader._import_search_limit([module.path for module in modules]) + except _Stop as stop: + raise CallLimit(f"import-search source evidence is unread: {stop.detail}") from None + if limits: + raise CallLimit(limits[0]) + self._namespace_directory_currency() + return NamespaceSourceContext(tuple(modules), tuple(boundaries)) + + def _namespace_above_import(self, module: PythonModule, node: ast.Import | ast.ImportFrom) -> None: + """Do not mistake an empty target list for absence outside a sub-scope.""" + layout = self.resolver._layout + if layout is None or not layout.scope: + return + if layout.disk_root is None: + raise CallLimit("the namespace source context has no bound above-scope import roots") + root = layout.disk_root + + try: + if isinstance(node, ast.ImportFrom) and node.level: + base = module.path.parent + for _ in range(node.level - 1): + base = base.parent + if not base.is_relative_to(self.resolver.scope_root): + raise CallLimit(f"{module.ref}:{node.lineno} imports code above the namespace read scope") + return # The ordinary relative reader retains missing/submodule obligations. + names = [alias.name for alias in node.names] if isinstance(node, ast.Import) else [node.module or ""] + for name in names: + if not name or name.split(".", 1)[0] in _PRELOADED: + continue + for search in self.resolver._import_roots(): + directory = root / search + for part in name.split("."): + present = self._namespace_directory_entries(directory) + if present is None: + break + folded = self._namespace_directory_names[directory] + matches = folded.get(part.casefold(), frozenset()) | folded.get(f"{part}.py".casefold(), frozenset()) + if not matches: + break # The captured parent listing proves this root cannot provide it. + target = directory / part + if f"{part}.py" in matches or matches - {part, f"{part}.py"}: + if not directory.is_relative_to(self.resolver.scope_root): + raise CallLimit(f"{module.ref}:{node.lineno} has a module candidate for {name!r} above the read scope") + break + if not (target.is_relative_to(self.resolver.scope_root) + or self.resolver.scope_root.is_relative_to(target)): + raise CallLimit(f"{module.ref}:{node.lineno} has a namespace candidate for {name!r} above the read scope") + directory = target + except (InputParseError, OSError, ValueError) as exc: + raise CallLimit(f"{module.ref}:{node.lineno} has unread above-scope import evidence: {exc}") from None + def _inventory(self) -> list[Path]: if self._files is not None: return self._files @@ -952,12 +4465,24 @@ def _inventory(self) -> list[Path]: while pending: directory = pending.pop() children = list_input_directory(directory) + if self._fresh_identity_session is not None: + live = self._fresh_identity_session.directory_entries(directory.relative_to(self._fresh_identity_session.root)) + if frozenset(live) != frozenset(child.name for child in children): + raise CallLimit(f"{directory} differs from its live fresh source inventory") entries += len(children) if entries > 100000: raise CallLimit("the caller census exceeds 100000 directory entries") + captured_entries: set[tuple[str, int]] = set() for child in children: relative = child.relative_to(root).as_posix() metadata = child.lstat() + captured_entries.add((child.name, stat.S_IFMT(metadata.st_mode))) + if self._fresh_identity_session is not None: + kind = self._fresh_identity_session.directory_entry_kind(child.relative_to(self._fresh_identity_session.root)) + expected = ("symlink" if stat.S_ISLNK(metadata.st_mode) else "directory" if stat.S_ISDIR(metadata.st_mode) + else "file" if stat.S_ISREG(metadata.st_mode) else "special") + if kind != expected: + raise CallLimit(f"{relative} changed kind in the fresh source inventory") if _test_path(relative): continue if stat.S_ISLNK(metadata.st_mode): @@ -969,11 +4494,22 @@ def _inventory(self) -> list[Path]: # Both presence and absence select which code is read. marker = child / "pyvenv.cfg" snapshot = active_static_input_snapshot() + if snapshot is not None and not snapshot.contains(marker): + snapshot = None # A materialized Git base is outside the live-head snapshot. absent = ( snapshot.bind_dependency_absence(marker) if snapshot is not None - else not marker.exists() + else not _lists_entry(marker) + ) + self._inventory_venv_selectors[marker] = ( + not absent, stat.S_IFMT(marker.lstat().st_mode) if not absent else None ) + if self._fresh_identity_session is not None: + names = self._fresh_identity_session.directory_entries(child.relative_to(self._fresh_identity_session.root)) + if absent != ("pyvenv.cfg" not in names): + raise CallLimit(f"{relative} changed its fresh virtual-environment selector") + if not absent: + self._fresh_identity_session.directory_entry_kind(marker.relative_to(self._fresh_identity_session.root)) if not absent: if snapshot is not None: snapshot.capture_selected_path(marker, allow_directory=False) @@ -986,18 +4522,29 @@ def _inventory(self) -> list[Path]: files.append(child) if len(files) > MAX_FILES: raise CallLimit(f"the caller census exceeds {MAX_FILES} Python files") + self._inventory_directories[directory] = frozenset(captured_entries) except (InputParseError, OSError, ValueError) as exc: raise CallLimit(f"the caller census could not read the scope: {exc}") from None self._files = sorted(files) return self._files def _text(self, path: Path) -> str: + if self._fresh_context_paths is not None and path not in self._fresh_context_paths: + raise CallLimit(f"{self.resolver.ref(path)} is outside the completed fresh dictionary context") if self._read_bytes > MAX_TOTAL_BYTES: raise CallLimit(f"the caller census exceeds {MAX_TOTAL_BYTES} source bytes") if path not in self._texts: snapshot = active_static_input_snapshot() + if snapshot is not None and not snapshot.contains(path): + # A materialized base tree is read under its own identity; + # its bytes do not belong to an active live-head capture. + snapshot = None try: self._texts[path] = load_text_file(path) + if self._fresh_identity_session is not None: + live_text = self.resolver._live_text(path) + if live_text != self._texts[path]: + raise CallLimit(f"{self.resolver.ref(path)} differs from its live fresh source bytes") if snapshot is not None: snapshot.mark_dependency_input(path) self._read_bytes += len(self._texts[path].encode("utf-8")) @@ -1047,6 +4594,16 @@ def _test_path(path: str) -> bool: ) +def _lists_entry(path: Path) -> bool: + """Whether its directory's exact listing names ``path``, on any filesystem. + + ``Path.exists`` asks the host: a differently cased spelling is found on a + case-insensitive filesystem and not on a case-sensitive one. A marker whose + presence selects the code that is read must be decided by the listing. + """ + return path.name in {child.name for child in list_input_directory(path.parent)} + + def _module_words(path: Path) -> set[str]: return {path.stem, path.parent.name} if path.stem == "__init__" else {path.stem} diff --git a/src/agents_shipgate/inputs/google_adk.py b/src/agents_shipgate/inputs/google_adk.py index 61c11d2f4..35cc25123 100644 --- a/src/agents_shipgate/inputs/google_adk.py +++ b/src/agents_shipgate/inputs/google_adk.py @@ -31,7 +31,11 @@ from agents_shipgate.core.errors import InputParseError from agents_shipgate.core.privacy import is_credential_key, redact_url_credentials from agents_shipgate.core.source_warnings import adk_unresolved_tool_warning -from agents_shipgate.inputs.builder_calls import BuilderCalls, ConstructionContext, Invocation +from agents_shipgate.inputs.builder_calls import ( + BuilderCalls, + ConstructionContext, + Invocation, +) from agents_shipgate.inputs.common import ( load_structured_file, load_text_file, @@ -920,6 +924,8 @@ class _AdkAgentBinding: duplicated: set[str] = field(default_factory=set) #: ``tool_name -> why`` for a binding made on a guess (#879 review). tool_issues: dict[str, str] = field(default_factory=dict) + constructor_issue: str | None = None + constructor_issues: dict[str, str] = field(default_factory=dict) #: Why this agent's tool list is incomplete, when it is. issues: list[str] = field(default_factory=list) #: Every ``(tool, locator)`` one construction of this name asked to bind, @@ -1142,8 +1148,8 @@ def lists(self) -> ListExpressions: module=self.module, resolver=self.resolver, agent_reads=lambda call, keyword: self.module is not None - and keyword in {"tools", "sub_agents"} - and self._is_agent_call(call), + and keyword in {"tools", "sub_agents"} | CALLBACK_KEYS + and self._is_agent_call(call) and self._agent_constructor_issue(call) is None, builder_calls=self._builder_calls, module_agent_reads=self._module_agent_reads, ) @@ -1205,6 +1211,18 @@ def extract(self) -> list[LoadedToolSource]: # The call is only ADK's ``Agent`` while the name still refers to # the import it was resolved through. self._require_proven_framework_symbol(call) + constructor_issue = self._agent_constructor_issue(call) + if constructor_issue is None and self.lists.constructor_namespace_changed(): + constructor_issue = self.lists.constructor_namespace_issue or "an agent handle may change or hand on a tool carrying the constructor namespace" + if constructor_issue is not None: + binding = self._binding_for(agent_name, call) + message = f"Google ADK agent {agent_name!r}: its constructor identity is not established: {constructor_issue}." + self._surface_warning(message, SURFACE_GAP_SHADOWED_FRAMEWORK_SYMBOL) + if message not in binding.issues: + binding.issues.append(message) + binding.constructor_issue = message + binding.constructor_issues[f"{self.source_ref}:{call.lineno}"] = message + binding.handoffs_complete = False if any(keyword.arg is None for keyword in call.keywords): # ``Agent(**config)`` hides every keyword, ``tools`` included. # Without ``tools=`` the agent silently records tool_count 0, @@ -1294,6 +1312,13 @@ def extract(self) -> list[LoadedToolSource]: loaded_sources.extend( self._read_construction(call, agent_name, binding, members, tools, handoffs) ) + for record in self.artifacts.sub_agents: + binding = self.agent_bindings.get(record.get("agent_name")) + if binding is not None and binding.constructor_issue is not None and "sub_agent_count" in record: + # The names stay as readable candidates. ``unread`` makes their + # edge incomplete; they were matched, so they are not + # "unresolved" (that would claim no agent definition was found). + record["unread"] = binding.constructor_issue self._record_duplicate_constructions() self._record_agent_subclasses() self._resolve_extraction_evidence(warnings_before, loaded_sources) @@ -1339,6 +1364,11 @@ def _read_construction( loaded.extend(self._extract_member(member, tools, agent_name, binding)) for tool_name, _ in binding.recording[at:]: if tool_name in binding.tool_names or tool_name in binding.objects: + if member.invocation is not None: + locations = binding.tool_sites.setdefault(tool_name, []) + for location in member.invocation.locations: + if location not in locations: + locations.append(location) binding.when.add(tool_name, member.conditions) if member.conditions: conditioned.add((tool_name, " and ".join(member.conditions))) @@ -1880,7 +1910,10 @@ def _binding_observations(self) -> list[AgentBindingObservation]: source_pointer=binding.source_pointer, tool_names=list(binding.tool_names), tool_locators=dict(binding.tool_locators), - tool_issues=dict(binding.tool_issues), + tool_issues={**binding.tool_issues, + **({name: "; ".join(filter(None, [binding.tool_issues.get(name), binding.constructor_issue])) + for name in (*binding.tool_names, *binding.objects)} + if binding.constructor_issue is not None else {})}, tool_sites={ name: sorted(lines, key=lambda location: (location.rsplit(":", 1)[0], int(location.rsplit(":", 1)[1]))) for name, lines in binding.tool_sites.items() @@ -1893,6 +1926,7 @@ def _binding_observations(self) -> list[AgentBindingObservation]: handoffs_complete=binding.handoffs_complete, handoff_sites=dict(binding.handoff_sites), issues=list(binding.issues), + constructor_issues=dict(binding.constructor_issues), ) for binding in self.agent_bindings.values() if binding.tool_names or binding.issues or binding.handoff_sites or binding.objects @@ -1910,7 +1944,7 @@ def _agent_calls(self) -> list[tuple[str | None, ast.Call]]: return calls def _module_agent_reads(self, module: PythonModule, call: ast.Call, keyword: str | None) -> bool: - if keyword not in {"tools", "sub_agents"} or self._builder_calls is None: + if keyword not in {"tools", "sub_agents"} | CALLBACK_KEYS or self._builder_calls is None: return False spelling = reference_spelling(call.func) if spelling is None: @@ -1935,7 +1969,13 @@ def _module_agent_reads(self, module: PythonModule, call: ast.Call, keyword: str return False suffix = spelling.partition(".")[2] qualified = f"{imported}.{suffix}" if suffix else imported - return qualified in AGENT_CLASS_NAMES + return (qualified in AGENT_CLASS_NAMES and self.resolver is not None + and self._builder_calls.constructor_issue(module, call) is None) + + def _agent_constructor_issue(self, call: ast.Call) -> str | None: + if self.module is not None and self._builder_calls is not None: + return self._builder_calls.constructor_issue(self.module, call) + return None if self._framework_symbol_is_proven(call) else "the constructor root is not bound by one import" def _is_agent_call(self, call: ast.Call) -> bool: return _qualified_name(call.func, self.aliases) in AGENT_CLASS_NAMES @@ -2052,7 +2092,7 @@ def _extract_member( local = self._builder_calls.scopes(module).enclosing_bindings(member.expr, spelling.split(".", 1)[0]) if self._builder_calls else [] if local and self._builder_calls is not None: resolution, long_running = self._builder_calls.resolve(module, member.expr), False - if resolution.definition is not None and resolution.definition not in module.tree.body: + if resolution.definition is not None and resolution.module is not None and resolution.definition not in resolution.module.tree.body: resolution = Resolution(reference=spelling, reason=LOCAL_BINDING, detail=f"the caller-local tool at {module.ref}:{resolution.definition.lineno} has an enclosing closure, which this increment does not follow") else: diff --git a/src/agents_shipgate/inputs/list_expressions.py b/src/agents_shipgate/inputs/list_expressions.py index 36cb88868..5cdde70eb 100644 --- a/src/agents_shipgate/inputs/list_expressions.py +++ b/src/agents_shipgate/inputs/list_expressions.py @@ -34,8 +34,8 @@ from __future__ import annotations import ast -from collections.abc import Callable -from dataclasses import dataclass, field +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field, replace from pathlib import PurePosixPath from typing import TYPE_CHECKING, Any @@ -48,9 +48,13 @@ PythonModule, Resolution, ScopeIndex, + _external_constructor_paths, + _independent_foreign_class_slot_write, _Stop, + _unused_reflection_import, reference_spelling, reflective_access, + replaces_builtin_namespace, ) from agents_shipgate.inputs.python_static import dotted_name @@ -86,6 +90,11 @@ #: Builtins whose result holds the same members as their one argument. _SAME_MEMBERS = frozenset({"list", "tuple", "sorted"}) +#: Owner roles whose result may leave its module through a plain ``from m import name`` +#: when every importer's use of it is read: an agent handle, an inert data instance, a +#: tool object and the SDK's wrapped function. The ADK wrapper and field-data roles keep +#: their narrower proof, which refuses any export. +_EXPORTABLE_ROLES = frozenset({"__class__", "instance_data", "toolset_data", "tool_wrapper"}) #: An agent's list arguments, and the attributes it keeps them under. CAPABILITY_FIELDS = frozenset({"tools", "handoffs", "sub_agents", "mcp_servers"}) # Canonical SDK/ADK fields can be initialized or inspected even when omitted @@ -161,7 +170,7 @@ def __add__(self, other: ListResolution) -> ListResolution: def under(self, condition: str) -> ListResolution: return ListResolution( tuple( - ListMember(m.expr, m.module, (condition, *m.conditions), m.via, m.invocation) + ListMember(m.expr, m.module, tuple(dict.fromkeys((condition, *m.conditions))), m.via, m.invocation) for m in self.members ), self.unresolved, @@ -318,6 +327,17 @@ def read_only_use( return False +def _source_slot_context(rows: list[str] | tuple[str, ...]) -> tuple[str, ...]: + """Bound supplemental refusal text; it never supplies an ownership fact.""" + return tuple(dict.fromkeys(row[:1024] for row in rows))[:2] + + +def _record_constructor_refusal(rows: list[str] | None, message: str) -> bool: + if rows is not None: + rows[:] = _source_slot_context((*rows, message)) + return False + + @dataclass class _View: """One module as this resolver reads it.""" @@ -338,11 +358,13 @@ class _View: #: Imports whose binding some code changes in place, wherever in the #: module: the list they import is changed, whatever name reaches it. changed_imports: list[tuple[ast.alias, ast.stmt]] = field(default_factory=list) + changed_import_context: dict[tuple[ast.alias, ast.stmt], tuple[str, ...]] = field(default_factory=dict) #: ``bindings_at`` for this module, built once. lookup: BindingsAt | None = None #: Imported namespace paths visibly replaced, resolved at each store's #: evaluation site so two aliases of one namespace share the obligation. qualified_patches: set[str] = field(default_factory=set) + family_qualified_patches: dict[str, set[str]] = field(default_factory=dict) #: A visible write may replace a builtin namespace getter too. namespace_builtins_changed: set[str] = field(default_factory=set) fresh_dictionaries: dict[tuple[int, bool], bool] = field(default_factory=dict) @@ -354,6 +376,7 @@ class _View: returned_instances: dict[int, bool] = field(default_factory=dict) selected_instance_call: ast.Call | None = None constructor_reads: dict[tuple[Any, ...], bool] = field(default_factory=dict) + constructor_read_contexts: dict[tuple[Any, ...], tuple[str, ...]] = field(default_factory=dict) _NAMESPACE_ALIAS_LIMIT = 128 @@ -377,6 +400,12 @@ def _standard_operator(view: _View) -> bool: return _unprovided_root(view, "operator") +def _folded_in(name: str, entries: Iterable[str]) -> bool: + """Whether a directory listing holds ``name`` under any letter case.""" + folded = name.casefold() + return any(entry.casefold() == folded for entry in entries) + + def _unprovided_root(view: _View, provider: str) -> bool: """Require captured absence before assigning an external import its role.""" if provider not in view.unprovided_roots: @@ -385,7 +414,7 @@ def _unprovided_root(view: _View, provider: str) -> bool: if resolver is None or view.module is None or resolver._layout is None: return False try: - resolver._absolute(view.module, provider) + resolver._installed_absolute(view.module, provider) except _Stop as stop: if stop.reason != MODULE_NOT_FOUND or any(names is None for names in resolver._listings.values()): return False @@ -396,6 +425,8 @@ def _unprovided_root(view: _View, provider: str) -> bool: for _ in layout.scope.split("/") if layout.scope else (): root = root.parent snapshot = active_static_input_snapshot() + if snapshot is not None and root != snapshot.root and not snapshot.contains(root): + snapshot = None # A disjoint materialized tree has its own input identity. for base in resolver._import_roots(): # Inspect each prefix so a missing root is distinguished from an # unread/linked root. A verifier cannot use an unbound external @@ -406,6 +437,8 @@ def _unprovided_root(view: _View, provider: str) -> bool: name = prefix.rsplit("/", 1)[-1] directory = root / prefix if prefix and previous is not None and name not in previous: + if _folded_in(name, previous): + return False # A differently spelled entry may be this root on another filesystem. if snapshot is not None: if not snapshot.bind_dependency_absence(directory): return False @@ -433,7 +466,11 @@ def _unprovided_root(view: _View, provider: str) -> bool: return False previous = names else: - if {provider, provider + ".py"} & (previous or frozenset()): + # The exact listing decides, never the host filesystem: a + # differently cased ``Operator.py`` provides ``operator`` on a + # case-insensitive filesystem and not on a case-sensitive one, + # and a proof of absence cannot depend on which one ran it. + if _folded_in(provider, previous or ()) or _folded_in(provider + ".py", previous or ()): return False for name in (provider, provider + ".py"): candidate = root / base / name @@ -546,6 +583,27 @@ def _agent_instance(view: _View, owner: ast.expr) -> bool: return False +def _direct_alias_constructor(view: _View, owner: ast.expr) -> ast.Call | None: + """Resolve only unique plain aliases of one actual direct Agent call.""" + lookup = view.lookup or bindings_at(view.scopes, view.bindings) + seen: set[int] = set() + while isinstance(owner, ast.Name): + if id(owner) in seen or len(seen) >= _NAMESPACE_ALIAS_LIMIT: + return None + seen.add(id(owner)) + found = lookup(owner.id, owner) + if len(found) != 1: + return None + _, statement = found[0] + if not isinstance(statement, ast.Assign | ast.AnnAssign): + return None + targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] + if len(targets) != 1 or not isinstance(targets[0], ast.Name): + return None + owner = statement.value + return owner if isinstance(owner, ast.Call) and _direct_agent_instance(view, owner) else None + + def _namespace_call_reference(view: _View, call: ast.Call) -> str | None: spelling = reference_spelling(call.func) if spelling is None: @@ -696,18 +754,56 @@ def _imported_references( return references +def _constructor_family(view: _View, call: ast.Call) -> str | None: + """Take a family only from this construction or its exact direct receiver.""" + qualified = _namespace_call_reference(view, call) + receiver = None + if isinstance(call.func, ast.Attribute) and call.func.attr == "clone": + receiver = call.func.value + elif qualified in {"dataclasses.replace", "copy.replace"} and len(call.args) == 1: + receiver = call.args[0] + if receiver is not None: + if not isinstance(receiver, ast.Name): + return None + lookup = view.lookup or bindings_at(view.scopes, view.bindings) + found = lookup(receiver.id, receiver) + owner = getattr(found[0][1], "value", None) if len(found) == 1 else None + if not isinstance(owner, ast.Call) or not _direct_agent_instance(view, owner): + return None + qualified = _namespace_call_reference(view, owner) + return next((family for family in ("agents", "openai_agents", "google.adk") + if qualified in _external_constructor_paths(family)), None) + + +def _constructor_patches(view: _View, family: str | None) -> set[str]: + if family is None: + return view.qualified_patches + if family not in view.family_qualified_patches: + # Primitive stability is a fixed point of this family's exact write + # census. Never reuse another family's exclusions or builtin flags. + independent = replace(view, namespace_builtins_changed=set(), fresh_dictionaries={}, + family_qualified_patches={}, returned_instances={}) + view.family_qualified_patches[family] = _qualified_attribute_patches(independent, family=family) + return view.family_qualified_patches[family] + + def _constructor_unchanged( - view: _View, call: ast.Call, *, qualified: str | None = None, + view: _View, call: ast.Call, *, qualified: str | None = None, family: str | None = None, + refused_context: list[str] | None = None, ) -> bool: # An import's canonical spelling does not prove the callee stayed the # framework's constructor: a visible store may replace any prefix. + def refused(phase: str) -> bool: + return _record_constructor_refusal(refused_context, f"constructor namespace in {view.ref}: {phase}") + spelling = qualified or reference_spelling(call.func) + patches = _constructor_patches(view, family or _constructor_family(view, call)) if view.module is not None and spelling is not None: if ( - _UNREAD_NAMESPACE_ALIAS in view.qualified_patches - or f"{_UNREAD_NAMESPACE_ALIAS}.*" in view.qualified_patches + _UNREAD_NAMESPACE_ALIAS in patches + or f"{_UNREAD_NAMESPACE_ALIAS}.*" in patches ): - return False + return refused('unread namespace replacement') fields = _CONSTRUCTOR_FIELDS | {item.arg for item in call.keywords if item.arg is not None} if any( patch.startswith(_UNREAD_NAMESPACE_ALIAS + ".") and ( @@ -715,36 +811,36 @@ def _constructor_unchanged( or name.startswith("_") or name.startswith("model_") ) - for patch in view.qualified_patches + for patch in patches ): - return False + return refused('unread constructor field') parts = spelling.split(".") if any( ".".join(parts[:length]) in view.module.attribute_patches for length in range(2, len(parts) + 1) ): - return False - if view.qualified_patches: + return refused('visible constructor prefix replacement') + if patches: references = {qualified} if qualified is not None else { spelling, *_imported_references(view, spelling, call.func) } for reference in references: parts = reference.split(".") - if any(f"{_UNREAD_NAMESPACE_ALIAS}.{part}" in view.qualified_patches for part in parts): - return False + if any(f"{_UNREAD_NAMESPACE_ALIAS}.{part}" in patches for part in parts): + return refused('visible or unread constructor namespace patch') if any( - ".".join(parts[:length]) + ".*" in view.qualified_patches + ".".join(parts[:length]) + ".*" in patches for length in range(1, len(parts) + 1) ): - return False + return refused('visible or unread constructor namespace patch') if any( - ".".join(parts[:length]) in view.qualified_patches + ".".join(parts[:length]) in patches for length in range(2, len(parts) + 1) ): - return False - if any(patch.startswith(reference + ".") for patch in view.qualified_patches): + return refused('visible or unread constructor namespace patch') + if any(patch.startswith(reference + ".") for patch in patches): # Constructor implementation fields are not immutable. - return False + return refused('visible or unread constructor namespace patch') return True @@ -753,18 +849,24 @@ def _constructor_imports_unchanged( *, checked_modules: frozenset[object] = frozenset(), additional_callers: tuple[_View, ...] = (), qualified: str | None = None, + family: str | None = None, + refused_context: list[str] | None = None, ) -> bool: """Census source-local import effects before granting a returned instance.""" + def refused(phase: str) -> bool: + return _record_constructor_refusal(refused_context, f"constructor imports in {view.ref}: {phase}") + resolver, calls = view.resolver, view.builder_calls if resolver is None or calls is None or view.module is None or constructor.module is None: - return False + return refused('missing scoped constructor reader') qualified = qualified or _namespace_call_reference(constructor, returned) + family = family or _constructor_family(constructor, returned) if qualified is None or qualified.startswith(_UNREAD_NAMESPACE_ALIAS): - return False + return refused('unread canonical constructor receiver') try: above = resolver._above_scope() if above.unread or above.patched or above.tables: - return False + return refused('above-scope import context is unread or patched') pending = [constructor.module, view.module, *( caller.module for caller in additional_callers if caller.module is not None )] @@ -779,7 +881,7 @@ def _constructor_imports_unchanged( if module.path in seen: continue if len(seen) >= _NAMESPACE_ALIAS_LIMIT or calls.dynamic_importer(module) is not None: - return False + return refused('module census bound or unread dynamic importer') seen.add(module.path) scopes = calls.scopes(module) other = _View(module.ref, module.tree, scopes, module.bindings, module, set()) @@ -797,8 +899,10 @@ def _constructor_imports_unchanged( if module.tree is view.tree: other.selected_instance_call = invocation other.qualified_patches = _qualified_attribute_patches(other) - if module.path not in checked_modules and not _constructor_unchanged(other, returned, qualified=qualified): - return False + if module.path not in checked_modules and not _constructor_unchanged( + other, returned, qualified=qualified, family=family, refused_context=refused_context, + ): + return refused(f'namespace patch refusal in {module.ref}') paths = resolver._enclosing_packages(module.path) for node in ast.walk(module.tree): if not isinstance(node, ast.Import | ast.ImportFrom): @@ -810,15 +914,101 @@ def _constructor_imports_unchanged( or isinstance(node, ast.ImportFrom) and node.level or not _unprovided_root(other, spelling.partition(".")[0]) ): - return False + return refused(f'unread import {module.ref}:{node.lineno}: {stop.reason}: {stop.detail}') paths.extend(imported) pending.extend(resolver._patch_scan(path) for path in paths if path not in seen) - except _Stop: + except _Stop as stop: + return refused(f"{stop.reason}: {stop.detail}") + return True + + +def _independent_plain_class_slot_write(view: _View, node: ast.Attribute) -> bool: + """An inert source-local class owns only its exact ordinary init store.""" + if node.attr != "__init__" or not isinstance(node.ctx, ast.Store) or view.resolver is None or view.module is None: return False + parent = view.scopes.parents.get(node) + if not ((isinstance(parent, ast.Assign) and parent.targets == [node]) + or (isinstance(parent, ast.AnnAssign) and parent.target is node)): + return False + spelling = reference_spelling(node.value) + if spelling is None: + return False + root = spelling.partition(".")[0] + if view.scopes.enclosing_bindings(evaluation_site(view.scopes, node), root): + return False + bindings = view.bindings.get(root, []) + if (len(bindings) != 1 or not bindings[0].top_level or not isinstance(bindings[0].node, ast.alias) + or bindings[0].statement not in view.tree.body): + return False + outcome = view.resolver._constructor_reference(view.module, node.value, view.scopes) + definition, home = outcome.get("retained_class"), outcome.get("module") + if (set(outcome) != {"retained_class", "module"} or not isinstance(home, PythonModule) + or not isinstance(definition, ast.ClassDef) or definition not in home.tree.body + or definition.bases or definition.decorator_list or definition.keywords + or getattr(definition, "type_params", []) + or len(definition.body) != 1 or not isinstance(definition.body[0], ast.Pass)): + return False + owners = home.bindings.get(definition.name, []) + return len(owners) == 1 and owners[0].node is definition and owners[0].top_level + + +def _provisional_agent_list_clear(view: _View, call: ast.Call) -> bool: + """Separate an exact literal-list clear from dictionary namespace writes. + + This AST-only role does not grant ownership. Calling the final member + proof while constructing its mutation census would make it recursive. + """ + if (not isinstance(call.func, ast.Attribute) or call.func.attr != "clear" + or call.args or call.keywords or not isinstance(view.scopes.parents.get(call), ast.Expr)): + return False + field = call.func.value + if not isinstance(field, ast.Attribute) or field.attr not in CAPABILITY_FIELDS: + return False + lookup = view.lookup or bindings_at(view.scopes, view.bindings) + + def direct(owner: ast.expr) -> ast.Call | None: + if not isinstance(owner, ast.Name): + return None + found = lookup(owner.id, owner) + if len(found) != 1: + return None + _, statement = found[0] + if not isinstance(statement, ast.Assign | ast.AnnAssign): + return None + targets = statement.targets if isinstance(statement, ast.Assign) else [statement.target] + value = statement.value + return value if (len(targets) == 1 and isinstance(targets[0], ast.Name) + and isinstance(value, ast.Call) and _direct_agent_instance(view, value)) else None + + def literal(value: ast.expr | None) -> bool: + if isinstance(value, ast.Name): + found = lookup(value.id, value) + if len(found) != 1: + return False + value = getattr(found[0][1], "value", None) + return isinstance(value, ast.List) + + owner = direct(field.value) + if owner is None or not literal(next((item.value for item in owner.keywords if item.arg == field.attr), None)): + return False + for node in ast.walk(view.tree): + if (not isinstance(node, ast.Attribute) or node.attr != field.attr + or not isinstance(node.ctx, ast.Store | ast.Del) or direct(node.value) is not owner): + continue + statement = view.scopes.parents.get(node) + if not (isinstance(node.ctx, ast.Store) + and ((isinstance(statement, ast.Assign) and statement.targets == [node]) + or (isinstance(statement, ast.AnnAssign) and statement.target is node)) + and literal(statement.value)): + return False return True -def _qualified_attribute_patches(view: _View, *, primitive_pass: int = 0) -> set[str]: +def _qualified_attribute_patches( + view: _View, *, primitive_pass: int = 0, family: str | None = None, + raw_namespaces: bool = False, + patch_producers: dict[str, set[ast.AST | None]] | None = None, +) -> set[str]: """Retain visible namespace mutations without flattening lexical imports. Multiple possible import owners keep every possibility: uncertainty cannot @@ -830,7 +1020,12 @@ def _qualified_attribute_patches(view: _View, *, primitive_pass: int = 0) -> set changed: set[str] = set() nodes = list(ast.walk(view.tree)) - def record(owner: ast.expr, attribute: str | None) -> None: + def emit(marker: str, producer: ast.AST | None) -> None: + changed.add(marker) + if patch_producers is not None: + patch_producers.setdefault(marker, set()).add(producer) + + def record(owner: ast.expr, attribute: str | None, producer: ast.AST) -> None: dictionary_owner = _namespace_dictionary_owner(view, owner) if dictionary_owner is not None: owner = dictionary_owner @@ -854,38 +1049,44 @@ def record(owner: ast.expr, attribute: str | None) -> None: if reference.startswith(_UNREAD_NAMESPACE_ALIAS + "."): # An unread owner may be any namespace; a literal stored # field is still known. A computed field can replace any one. - changed.add(f"{_UNREAD_NAMESPACE_ALIAS}.{attribute or '*'}") + emit(f"{_UNREAD_NAMESPACE_ALIAS}.{attribute or '*'}", producer) else: - changed.add(reference.replace(".__dict__.", ".")) + emit(reference.replace(".__dict__.", "."), producer) - def dictionary_fields(owner: ast.expr, arguments: list[ast.expr], keywords: list[ast.keyword]) -> None: + def dictionary_fields(owner: ast.expr, arguments: list[ast.expr], keywords: list[ast.keyword], producer: ast.AST) -> None: if _fresh_dictionary(view, owner): return for argument in arguments: if isinstance(argument, ast.Dict): for key in argument.keys: - record(owner, key.value if isinstance(key, ast.Constant) and isinstance(key.value, str) else None) + record(owner, key.value if isinstance(key, ast.Constant) and isinstance(key.value, str) else None, producer) else: - record(owner, None) + record(owner, None, producer) for keyword in keywords: - record(owner, keyword.arg) + record(owner, keyword.arg, producer) dictionary_methods = {"__init__", "update", "clear", "pop", "popitem", "setdefault", "__setitem__", "__delitem__", "__ior__"} - def dictionary_mutation(owner: ast.expr, method: str, arguments: list[ast.expr], keywords: list[ast.keyword]) -> None: + def dictionary_mutation(owner: ast.expr, method: str, arguments: list[ast.expr], keywords: list[ast.keyword], producer: ast.AST) -> None: if _fresh_dictionary(view, owner): return if method in {"__init__", "update", "__ior__"}: - dictionary_fields(owner, arguments, keywords) + dictionary_fields(owner, arguments, keywords, producer) elif method in {"clear", "popitem"} or not arguments: - record(owner, None) + record(owner, None, producer) else: key = arguments[0] - record(owner, key.value if isinstance(key, ast.Constant) and isinstance(key.value, str) else None) + record(owner, key.value if isinstance(key, ast.Constant) and isinstance(key.value, str) else None, producer) for node in nodes: if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Store | ast.Del): - if ( + if not raw_namespaces and (( + view.resolver is not None + and family is not None + and _independent_foreign_class_slot_write(view.resolver, view.module, node, view.scopes, family) + ) or _independent_plain_class_slot_write(view, node)): + continue # Only this exact foreign slot store; other loads and RHS still count. + if (not raw_namespaces and isinstance(node.ctx, ast.Store) and isinstance(view.scopes.parents.get(node), ast.Assign | ast.AnnAssign) and node.attr in {"tools", "handoffs", "mcp_servers", "sub_agents"} @@ -895,16 +1096,16 @@ def dictionary_mutation(owner: ast.expr, method: str, arguments: list[ast.expr], # does not write its constructor class or a sibling's list. # The existing handle/list census still checks this instance. continue - record(node.value, node.attr) + record(node.value, node.attr, node) elif isinstance(node, ast.Subscript) and isinstance(node.ctx, ast.Store | ast.Del): if _fresh_dictionary(view, node.value): continue key = node.slice if isinstance(key, ast.Constant) and not isinstance(key.value, str): continue - record(node.value, key.value if isinstance(key, ast.Constant) else None) + record(node.value, key.value if isinstance(key, ast.Constant) else None, node) elif isinstance(node, ast.AugAssign) and isinstance(node.op, ast.BitOr): - dictionary_fields(node.target, [node.value], []) + dictionary_fields(node.target, [node.value], [], node) elif isinstance(node, ast.Call): reference = _namespace_call_reference(view, node) raw = reference_spelling(node.func) @@ -918,34 +1119,36 @@ def dictionary_mutation(owner: ast.expr, method: str, arguments: list[ast.expr], ): # No established receiver position: never infer a mutation # owner from an unpacked payload or a missing argument. - changed.add(f"{_UNREAD_NAMESPACE_ALIAS}.*") + emit(f"{_UNREAD_NAMESPACE_ALIAS}.*", node) continue if len(node.args) >= 2 and (setter or reference in _NAMESPACE_SETTERS): key = node.args[1] if (setter and reference not in _NAMESPACE_SETTERS) or reference in view.namespace_builtins_changed or ( reference is not None and reference.startswith("operator.") and not _standard_operator(view) ): - changed.add(f"{_UNREAD_NAMESPACE_ALIAS}.*") + emit(f"{_UNREAD_NAMESPACE_ALIAS}.*", node) else: record(node.args[0], key.value if isinstance(key, ast.Constant) - and isinstance(key.value, str) else None) + and isinstance(key.value, str) else None, node) elif reference == "operator.ior" and node.args: if reference in view.namespace_builtins_changed or not _standard_operator(view): - changed.add(f"{_UNREAD_NAMESPACE_ALIAS}.*") + emit(f"{_UNREAD_NAMESPACE_ALIAS}.*", node) else: - dictionary_fields(node.args[0], node.args[1:], node.keywords) + dictionary_fields(node.args[0], node.args[1:], node.keywords, node) elif unbound_dictionary: if "builtins.dict" in view.namespace_builtins_changed or not node.args: - changed.add(f"{_UNREAD_NAMESPACE_ALIAS}.*") + emit(f"{_UNREAD_NAMESPACE_ALIAS}.*", node) else: - dictionary_mutation(node.args[0], reference.rsplit(".", 1)[-1], node.args[1:], node.keywords) + dictionary_mutation(node.args[0], reference.rsplit(".", 1)[-1], node.args[1:], node.keywords, node) elif isinstance(node.func, ast.Attribute) and node.func.attr in dictionary_methods: - dictionary_mutation(node.func.value, node.func.attr, node.args, node.keywords) + if not raw_namespaces and _provisional_agent_list_clear(view, node): + continue # Membership and retained members still need the final owner proof. + dictionary_mutation(node.func.value, node.func.attr, node.args, node.keywords, node) elif isinstance(node, ast.Attribute) and node.attr in dictionary_methods: parent = view.scopes.parents.get(node) if not (isinstance(parent, ast.Call) and parent.func is node) and not _fresh_dictionary(view, node.value): # An exported mutator may later write any key. - record(node.value, None) + record(node.value, None, node) # A getter's spelling is trustworthy only if its actual namespace remains # unchanged. Unknown keys/mapping arguments retain namespace wildcards; # unrelated foreign/fresh dictionary fields do not poison builtin names. @@ -963,10 +1166,14 @@ def dictionary_mutation(owner: ast.expr, method: str, arguments: list[ast.expr], } if not unstable <= view.namespace_builtins_changed: if primitive_pass >= len(_NAMESPACE_PRIMITIVES): - return changed | {_UNREAD_NAMESPACE_ALIAS} + emit(_UNREAD_NAMESPACE_ALIAS, None) + return changed view.namespace_builtins_changed.update(unstable) view.fresh_dictionaries.clear() - return _qualified_attribute_patches(view, primitive_pass=primitive_pass + 1) + return _qualified_attribute_patches( + view, primitive_pass=primitive_pass + 1, family=family, raw_namespaces=raw_namespaces, + patch_producers=patch_producers, + ) return changed @@ -983,8 +1190,28 @@ class ListCache: views: dict[tuple[int, bool], _View] = field(default_factory=dict) memo: dict[tuple[Any, ...], ListResolution] = field(default_factory=dict) changes: dict[tuple[int, bool, int, str], int | None] = field(default_factory=dict) - callees: dict[tuple[int, str, tuple], bool] = field(default_factory=dict) + change_contexts: dict[tuple[int, bool, int, str], tuple[str, ...]] = field(default_factory=dict) + callees: dict[tuple[int, str, tuple, bool, bool], bool] = field(default_factory=dict) scopes: dict[int, ScopeIndex] = field(default_factory=dict) + callable_changes: dict[tuple, str | None] = field(default_factory=dict) + executable_modules: dict[int, bool] = field(default_factory=dict) + + +@dataclass(frozen=True) +class _Projection: + key: str + view: _View + invocation: Invocation | None + required: bool = True + default: ast.expr | None = None + caller_defaults: set[tuple[int, int]] = field(default_factory=set, compare=False) + + @property + def identity(self) -> tuple: + return ( + self.key, self.required, id(self.default), id(self.view.tree), + self.invocation.key if self.invocation else (), + ) class ListExpressions: @@ -1011,10 +1238,22 @@ def __init__( self._resolver = resolver self._agent_reads = agent_reads self._module_agent_reads = module_agent_reads + self._checking_constructor_namespaces = False + self._checking_handle_owners: set[tuple[int, int, str, bool, bool]] = set() + self._constructor_owner_results: dict[tuple[int, int, str, bool, bool], bool] = {} + self._constructor_container_results: dict[tuple[int, int, bool | None], bool] = {} + self._checking_container_owners: set[tuple[int, int, bool | None]] = set() + self._constructor_owner_work = 0 + self._constructor_namespace_result: tuple[object, bool] | None = None + self._constructor_namespace_refusal: tuple[object, tuple[str, ...]] | None = None + self.constructor_namespace_issue: str | None = None + self._constructor_change_context: tuple[ast.Call, tuple, tuple[str, ...]] | None = None self._builder_calls = builder_calls self._invocation: Invocation | None = None self._field = "tools" + self._projection: _Projection | None = None self._checking_returns: set[tuple[int, str]] = set() + self._checking_member_receivers: set[tuple[int, int]] = set() self._cache = cache if cache is not None else ListCache() self._visits = 0 self.entry = self._view(ref, tree, scopes, bindings, module, entry=True) @@ -1082,6 +1321,13 @@ def _scopes(self, tree: ast.Module) -> ScopeIndex: scopes = self._cache.scopes[id(tree)] = ScopeIndex(tree) return scopes + def _reflection(self, view: _View) -> ast.AST | None: + excluded = ( + self.calls.runtime_exclusions(view.module) + if self.calls is not None and view.module is not None else frozenset() + ) + return reflective_access(view.tree, excluded=excluded) + def _index_changes(self, view: _View) -> None: """Mark every binding some code may change in place (#879 review).""" @@ -1106,12 +1352,14 @@ def _index_changes(self, view: _View) -> None: } | {argument.arg for argument in ast.walk(view.tree) if isinstance(argument, ast.arg)} ) - if reflective_access(view.tree) is not None: + if self._reflection(view) is not None: # ``globals()["TOOLS"]``, ``vars()`` and ``sys.modules[__name__]`` # reach a module list without spelling its name. view.changed.update(("module", name) for name in tracked) - call_reads = self._call_reads(view) + refused_reads: list[str] = [] + call_reads = self._call_reads(view, refused_reads=refused_reads) for node in ast.walk(view.tree): + refused_reads.clear() if isinstance(node, ast.Global): view.changed.update(("module", name) for name in node.names) elif isinstance(node, ast.Nonlocal): @@ -1148,7 +1396,7 @@ def _index_changes(self, view: _View) -> None: parent, ast.AugAssign | ast.NamedExpr ) if not unchanged: - self._mark_changed(view, node) + self._mark_changed(view, node, refused_context=_source_slot_context(refused_reads)) # Shared-list holders and namespace reflection can mark an imported # binding without loading that name directly. Publish those changes # to every other importer of the same list as well. @@ -1166,7 +1414,9 @@ def _index_changes(self, view: _View) -> None: # that otherwise bounded work quadratic in the number of agents. view.changed_imports = list(dict.fromkeys(view.changed_imports)) - def _mark_changed(self, view: _View, node: ast.Name) -> None: + def _mark_changed( + self, view: _View, node: ast.Name, *, refused_context: tuple[str, ...] = (), + ) -> None: """Mark every binding a change at ``node`` may reach (#909 review). The name is looked up where Python evaluates it: a default, decorator @@ -1182,11 +1432,19 @@ def _mark_changed(self, view: _View, node: ast.Name) -> None: view.changed.add(id(binding)) statement = view.scopes.statement_of(binding) if isinstance(binding, ast.alias) and isinstance(statement, ast.Import | ast.ImportFrom): - view.changed_imports.append((binding, statement)) + pair = (binding, statement) + view.changed_imports.append(pair) + view.changed_import_context[pair] = _source_slot_context( + (*view.changed_import_context.get(pair, ()), *refused_context) + ) if not found: for item in view.bindings.get(node.id, []): if isinstance(item.node, ast.alias) and isinstance(item.statement, ast.Import | ast.ImportFrom): - view.changed_imports.append((item.node, item.statement)) + pair = (item.node, item.statement) + view.changed_imports.append(pair) + view.changed_import_context[pair] = _source_slot_context( + (*view.changed_import_context.get(pair, ()), *refused_context) + ) scope = _nearest_scope(view.scopes, site) walrus = isinstance(view.scopes.parents.get(node), ast.NamedExpr) if isinstance(scope, ast.ClassDef) or walrus: @@ -1216,7 +1474,7 @@ def _built_with(self, view: _View, receiver: ast.expr) -> set[object]: keys |= shared_lists(view.scopes, view.bindings, fields) return keys - def _call_reads(self, view: _View) -> CallReads: + def _call_reads(self, view: _View, *, refused_reads: list[str] | None = None) -> CallReads: bindings = view.lookup or bindings_at(view.scopes, view.bindings) def reads(call: ast.Call, position: int | None, keyword: str | None) -> bool: @@ -1224,14 +1482,24 @@ def reads(call: ast.Call, position: int | None, keyword: str | None) -> bool: return True # Another module's ``Agent`` may be its own class: only the module # the agent is built in says which constructions are the framework's. - if self._reads_agent(view, call, keyword): + staged: list[str] = [] + if self._reads_agent(view, call, keyword, refused_context=staged): return True - return self._callee_leaves_alone(view, call, position, keyword) + unchanged = self._callee_leaves_alone(view, call, position, keyword) + if not unchanged and refused_reads is not None: + refused_reads[:] = _source_slot_context((*refused_reads, *staged)) + return unchanged return reads - def _reads_agent(self, view: _View, call: ast.Call, keyword: str | None) -> bool: - if not _constructor_unchanged(view, call): + def _reads_agent( + self, view: _View, call: ast.Call, keyword: str | None, *, refused_context: list[str] | None = None, + ) -> bool: + def refused(phase: str) -> bool: + return _record_constructor_refusal(refused_context, f"constructor read in {view.ref}: {phase}") + + family = _constructor_family(view, call) + if not _constructor_unchanged(view, call, family=family, refused_context=refused_context): return False recognized = (view.entry and self._agent_reads(call, keyword)) or bool( view.module is not None @@ -1241,7 +1509,23 @@ def _reads_agent(self, view: _View, call: ast.Call, keyword: str | None) -> bool if not recognized or view.module is None: # Pure-AST callers supply their own consumer predicate. File # adapters establish a scoped module before granting that role. - return bool(recognized) + result = bool(recognized) + if not result: + refused("source consumer predicate did not recognize this construction") + if view.module is not None and view.builder_calls is not None: + issue = view.builder_calls.cached_constructor_issue(view.module, call) + if issue: + refused(f"cached constructor issue: {issue}") + return result + if self.constructor_namespace_changed(): + result = self._constructor_namespace_result + refusal = self._constructor_namespace_refusal + if (refused_context is not None and result is not None and result[1] + and refusal is not None and refusal[0] == result[0]): + refused_context[:] = _source_slot_context((*refused_context, *refusal[1])) + elif refused_context is not None: + refused("retained constructor namespace ownership census refused") + return False # Ordinary constructions have the same import-effect obligation as a # returned instance. Reuse the existing view's own mutation census; # rebuilding it would discard its established instance roles. @@ -1297,25 +1581,281 @@ def _reads_agent(self, view: _View, call: ast.Call, keyword: str | None) -> bool # The dependency census compares one canonical constructor and its # keyword fields in immutable module views, not a particular return # value. Many equivalent ordinary reads can reuse that same proof. - key = (qualified, tuple(sorted(item.arg for item in call.keywords if item.arg is not None)), + key = (qualified, family, tuple(sorted(item.arg for item in call.keywords if item.arg is not None)), tuple(id(caller.tree) for caller in callers)) if key not in view.constructor_reads: view.constructor_reads[key] = False - checked = frozenset( - item.module.path for item in callers if item.module is not None - and _constructor_unchanged(item, call, qualified=qualified) - ) + view.constructor_read_contexts[key] = () + staged: list[str] = [] + checked_paths: set[object] = set() + for item in callers: + if item.module is not None and _constructor_unchanged( + item, call, qualified=qualified, family=family, refused_context=staged, + ): + checked_paths.add(item.module.path) + checked = frozenset(checked_paths) if any(caller.module is not None and caller.module.path not in checked for caller in callers): - return False - view.constructor_reads[key] = _constructor_imports_unchanged( - view, view, call, call, checked_modules=checked, - additional_callers=tuple(callers[1:]), - qualified=qualified, + view.constructor_read_contexts[key] = _source_slot_context(staged) + else: + staged = [] + view.constructor_reads[key] = _constructor_imports_unchanged( + view, view, call, call, checked_modules=checked, + additional_callers=tuple(callers[1:]), + qualified=qualified, + family=family, + refused_context=staged, + ) + view.constructor_read_contexts[key] = ( + () if view.constructor_reads[key] else _source_slot_context(staged) + ) + if not view.constructor_reads[key] and refused_context is not None: + refused_context[:] = _source_slot_context( + (*refused_context, *view.constructor_read_contexts.get(key, ())) ) return view.constructor_reads[key] + def constructor_namespace_changed(self) -> bool: + """Known agent destinations must retain constructor-carrying tools safely. + + Every instance retains its class, even when its tools are empty. Local + tool functions also carry module globals. Prove all receiving handles + and containers before granting the import reader's retention exception. + """ + if self._resolver is None: + return False + if self._checking_constructor_namespaces: + # Known constructor retention is checked by the outer census. A + # recursive handle edge is separately refused by the owner guard. + return False + def inventory() -> tuple[int, int, int, int, int, int, int]: + return (len(self._resolver._constructor_namespace_owners), len(self._resolver._constructor_container_owners), + len(self._resolver._constructor_member_sinks), len(self._resolver._constructor_decorator_owners), + len(self._resolver._constructor_dictionary_sinks), len(self._resolver._constructor_unused_namespace_sinks), + len(self._resolver._constructor_dictionary_class_sinks)) + + generation = inventory() + context = (self._invocation.key if self._invocation else (), + self._projection.identity if self._projection else ()) + cache_key = (generation, context) + if self._constructor_namespace_result is not None and self._constructor_namespace_result[0] == cache_key: + return self._constructor_namespace_result[1] + self._checking_constructor_namespaces = True + self._constructor_namespace_refusal = None + self.constructor_namespace_issue = None + self._constructor_owner_results.clear() + self._constructor_container_results.clear() + self._constructor_owner_work = 0 + checked: set[tuple[int, str]] = set() + try: + while True: + owners = [(*owner, True) for owner in self._resolver._constructor_namespace_owners.values()] + owners += [(module, expression, "container", False) + for module, expression in self._resolver._constructor_container_owners.values()] + owners += [(module, expression, "member_sink", False) + for module, expression in self._resolver._constructor_member_sinks.values()] + owners += [(module, expression, "decorator_handle", False) + for module, expression in self._resolver._constructor_decorator_owners.values()] + owners += [(module, expression, "dictionary_sink:" + family, False) + for module, expression, family in self._resolver._constructor_dictionary_sinks.values()] + owners += [(module, expression, "unused_namespace_sink", False) + for module, expression in self._resolver._constructor_unused_namespace_sinks.values()] + owners += [(module, expression, "dictionary_class_sink:" + family, False) + for module, expression, family, _ in self._resolver._constructor_dictionary_class_sinks.values()] + pending = [owner for owner in owners if (id(owner[1]), owner[2]) not in checked] + if not pending: + self._constructor_namespace_result = (( + inventory(), + context, + ), False) + return False + if len(checked) + len(pending) > 1024: + self.constructor_namespace_issue = "the constructor namespace census exceeds 1024 owners" + return True + current_generation = inventory() + if current_generation != generation: + self._constructor_owner_results.clear() + self._constructor_container_results.clear() + generation = current_generation + for module, expression, field, is_handle in pending: + checked.add((id(expression), field)) + view = self._foreign(module) + refused_models: list[str] = [] + if is_handle: + changed = self._agent_result_changed(view, expression, field, strict_container=True) + elif field == "member_sink": + changed = not ( + self._uncalled_member_sink(view, expression, refused_models=refused_models) + or self._member_receiver_owned(view, expression) + ) + elif field == "decorator_handle": + changed = self._retained_callable_changes(expression, { + (id(module), id(expression)): Resolution(reference=expression.name, module=module, definition=expression), + }, factory_return=False) is not None + elif field.startswith("dictionary_sink:"): + changed = not self._dictionary_function_sink_owned(view, expression, field.removeprefix("dictionary_sink:")) + elif field == "unused_namespace_sink": + changed = not self._unused_namespace_sink_owned(view, expression) + elif field.startswith("dictionary_class_sink:"): + family = field.removeprefix("dictionary_class_sink:") + _, _, _, canonical = self._resolver._constructor_dictionary_class_sinks[(family, id(expression))] + changed = not self._dictionary_class_sink_owned(view, expression, family, canonical) + else: + changed = self._factory_result_changed(view, expression) + if changed: + self._constructor_namespace_result = (cache_key, True) + owner_phase = f"constructor namespace owner {field} in {module.ref}:{expression.lineno} refused" + self._constructor_namespace_refusal = ( + self._constructor_namespace_result[0], + _source_slot_context((*refused_models, owner_phase)), + ) + return True + finally: + self._checking_constructor_namespaces = False + + def _uncalled_member_sink( + self, view: _View, expression: ast.expr, *, refused_models: list[str] | None = None, + ) -> bool: + """Discharge only a sink in a function with a complete empty census.""" + staged: list[str] = [] + calls = self.calls + if calls is None or view.module is None: + return False + function = calls.function_at(view.module, expression) + if function is None: + return False + census = calls.callers(view.module, function, allow_empty=True, confined_dictionary_data=True, + unused_namespace_data=True) + if not census.limits and not census.sites: + return True + if not census.sites: + from agents_shipgate.inputs.builder_calls import CallLimit + + try: + if calls.uncalled_source_slot(view.module, function, refused_models=staged): + return True + recorded = (self._resolver._constructor_member_sinks.get(id(expression)) + if self._checking_constructor_namespaces and self._resolver is not None else None) + if (recorded is not None + and recorded[0] is view.module and recorded[1] is expression): + for home, data, family in tuple(self._resolver._constructor_dictionary_sinks.values()): + if (calls._fresh_unbound_dictionary_edge(home, data) is None + and calls._absent_field_dictionary_edge(home, data) is None): + continue + if self._fresh_dictionary_function_sink_owned( + self._foreign(home), data, family, + expected_module=view.module, expected_function=function, + ): + return True + except CallLimit as exc: + self._constructor_limit((str(exc),)) + if refused_models is not None: + refused_models[:] = _source_slot_context((*refused_models, *staged)) + return False + + def _fresh_dictionary_function_sink_owned( + self, view: _View, expression: ast.expr, family: str, *, + expected_module: PythonModule | None = None, + expected_function: ast.FunctionDef | ast.AsyncFunctionDef | None = None, + ) -> bool: + calls = self.calls + if calls is None or view.module is None: + return False + with calls.fresh_unbound_dictionary_receipt(view.module, expression, family) as receipt: + if receipt is None: + return self._absent_field_dictionary_function_sink_owned( + view, expression, family, expected_module=expected_module, expected_function=expected_function, + ) + resolution = receipt.proof.resolve(view.module, expression) + if (not resolution.resolved or resolution.caveats or resolution.module is None + or not isinstance(resolution.definition, ast.FunctionDef | ast.AsyncFunctionDef)): + return False + if expected_module is not None or expected_function is not None: + if resolution.module is not expected_module or resolution.definition is not expected_function: + return False + census = receipt.proof._census( + resolution.module, resolution.definition, allow_empty=True, + confined_dictionary_data=True, unused_namespace_data=True, + confined_primitive_dictionary_values=receipt.values, + ) + if census.sites or census.limits: + return False + receipt.reconfirm() + return True + + def _absent_field_dictionary_function_sink_owned( + self, view: _View, expression: ast.expr, family: str, *, + expected_module: PythonModule | None = None, + expected_function: ast.FunctionDef | ast.AsyncFunctionDef | None = None, + ) -> bool: + calls = self.calls + if calls is None or view.module is None: + return False + with calls.absent_field_dictionary_receipt(view.module, expression, family) as receipt: + if receipt is None: + return False + if expected_module is not None or expected_function is not None: + if receipt.roles.home is not expected_module or receipt.roles.function is not expected_function: + return False + census = receipt.census() + if census.sites or census.limits: + return False + receipt.reconfirm() + return True + + def _dictionary_function_sink_owned(self, view: _View, expression: ast.expr, family: str) -> bool: + from agents_shipgate.inputs.builder_calls import CallLimit + + calls = self.calls + if calls is None or view.module is None: + return False + try: + if not calls.confined_dictionary_value(view.module, expression): + return self._fresh_dictionary_function_sink_owned(view, expression, family) + except CallLimit as exc: + self._constructor_limit((str(exc),)) + return False + resolution = calls.resolve(view.module, expression) + if (not resolution.resolved or resolution.caveats or resolution.module is None + or not isinstance(resolution.definition, ast.FunctionDef | ast.AsyncFunctionDef)): + return False + census = calls.callers(resolution.module, resolution.definition, allow_empty=True, + confined_dictionary_data=True, unused_namespace_data=True) + return not census.sites and not census.limits + + def _dictionary_class_sink_owned( + self, view: _View, expression: ast.expr, family: str, canonical: str, + ) -> bool: + from agents_shipgate.inputs.builder_calls import CallLimit + + calls = self.calls + if calls is None or view.module is None or self._resolver is None: + return False + try: + if not calls.confined_dictionary_value(view.module, expression): + return False + except CallLimit as exc: + self._constructor_limit((str(exc),)) + return False + # Identity only: the outer namespace/import/mutation census remains + # mandatory. Never ask this passive token to prove a receiving call. + outcome = self._resolver._constructor_reference(view.module, expression, calls.scopes(view.module)) + return (canonical in _external_constructor_paths(family) + and outcome.get("external_constructor") == canonical) + + def _unused_namespace_sink_owned(self, view: _View, expression: ast.expr) -> bool: + from agents_shipgate.inputs.builder_calls import CallLimit + + if self.calls is None or view.module is None: + return False + try: + return self.calls.unused_namespace_value(view.module, expression) + except CallLimit as exc: + self._constructor_limit((str(exc),)) + return False + def _callee_leaves_alone( - self, view: _View, call: ast.Call, position: int | None, keyword: str | None + self, view: _View, call: ast.Call, position: int | None, keyword: str | None, + *, strict_container: bool = False, ) -> bool: """Whether the function ``call`` names never changes the argument it passes.""" @@ -1355,10 +1895,18 @@ def _callee_leaves_alone( return False defining = resolution.module assert defining is not None - key = (id(function), parameter, invocation.key) + key = (id(function), parameter, invocation.key, + strict_container, self._checking_constructor_namespaces) if key not in self._cache.callees: # A recursive forwarding cycle is not a read-only proof. self._cache.callees[key] = False + if strict_container: + try: + if self._constructor_limit(calls.callers(defining, function).limits): + return False + except CallLimit as exc: + self._constructor_limit((str(exc),)) + return False foreign = self._foreign(defining) reads = self._call_reads(foreign) @@ -1369,28 +1917,79 @@ def safe_read(inner: ast.Call, position: int | None, field: str | None) -> bool: previous, self._invocation = self._invocation, invocation try: - self._cache.callees[key] = parameter_left_alone( - function, - parameter, - bindings_at(self._scopes(defining.tree), defining.bindings), - safe_read if self._module_agent_reads is not None else None, - ) + if strict_container: + self._cache.callees[key] = all( + isinstance(node.ctx, ast.Load) and self._container_read(foreign, node) + for node in ast.walk(function) + if isinstance(node, ast.Name) and node.id == parameter + ) and not any( + isinstance(node, ast.Global | ast.Nonlocal) and parameter in node.names + for node in ast.walk(function) + ) + else: + self._cache.callees[key] = parameter_left_alone( + function, + parameter, + bindings_at(self._scopes(defining.tree), defining.bindings), + safe_read if self._module_agent_reads is not None else None, + ) finally: self._invocation = previous return self._cache.callees[key] def _agent_result_changed( - self, view: _View, call: ast.Call, keyword: str, *, borrowed: bool = False + self, view: _View, call: ast.Call, keyword: str, *, borrowed: bool = False, + strict_container: bool = False, + ) -> bool: + key = (id(view.tree), id(call), keyword, borrowed, strict_container) + if key in self._checking_handle_owners or len(self._checking_handle_owners) >= 128: + if self._checking_constructor_namespaces: + self.constructor_namespace_issue = "recursive constructor-handle ownership or more than 128 retained edges" + return True + if self._checking_constructor_namespaces: + if key in self._constructor_owner_results: + return self._constructor_owner_results[key] + self._constructor_owner_work += 1 + if self._constructor_owner_work > 4096: + self.constructor_namespace_issue = "the constructor ownership traversal exceeds 4096 edges" + return True + self._checking_handle_owners.add(key) + try: + result = self._agent_result_changed_inner(view, call, keyword, borrowed=borrowed, + strict_container=strict_container) + if self._checking_constructor_namespaces: + self._constructor_owner_results[key] = result + return result + finally: + self._checking_handle_owners.remove(key) + + def _agent_result_changed_inner( + self, view: _View, call: ast.Call, keyword: str, *, borrowed: bool = False, + strict_container: bool = False, ) -> bool: """Follow returned builder handles before proving a borrowed list read-only.""" - if reflective_access(view.tree) is not None: + if self._reflection(view) is not None: return True if ( view.module is not None and self.calls is not None and self.calls.exported_elsewhere(view.module, call) ): - return True + self._constructor_limit((self.calls.export_limit(call),)) + if not ( + strict_container and self._checking_constructor_namespaces + and keyword in _EXPORTABLE_ROLES + and self.calls.export_routes(call) == {"value_import"} + and self._imported_members_owned( + view, call, + lambda foreign, subject: isinstance(subject, ast.Name) and not self._handle_use_changed( + foreign, subject, keyword, borrowed=borrowed, strict_container=True, + ), + carriers=keyword != "instance_data", + require_use=keyword == "__class__", + ) + ): + return True parent = view.scopes.parents.get(call) if isinstance(parent, ast.Return): function = _enclosing_function(view.scopes, parent) @@ -1402,18 +2001,35 @@ def _agent_result_changed( return True self._checking_returns.add(key) try: - census = calls.callers(view.module, function) - return bool(census.limits) or any( + census = calls.callers(view.module, function, allow_empty=self._checking_constructor_namespaces) + return self._constructor_limit(census.limits) or any( self._agent_result_changed( - self._foreign(site.module), site.call, keyword, borrowed=borrowed + self._foreign(site.module), site.call, keyword, borrowed=borrowed, + strict_container=strict_container, ) for site in census.sites ) finally: self._checking_returns.remove(key) - if not isinstance(parent, ast.Assign | ast.AnnAssign) or parent.value is not call: - return not isinstance(parent, ast.Expr) - targets = parent.targets if isinstance(parent, ast.Assign) else [parent.target] + entered = ( + strict_container and self._checking_constructor_namespaces and keyword == "toolset_data" + and isinstance(parent, ast.withitem) and parent.context_expr is call + and isinstance(parent.optional_vars, ast.Name) and self._enters_as_itself(view, call) + ) + if not entered and (not isinstance(parent, ast.Assign | ast.AnnAssign) or parent.value is not call): + return not isinstance(parent, ast.Expr) and not ( + strict_container and ( + self._field_data_read(view, call) if keyword == "field_data" + else self._toolset_data_read(view, call) if keyword == "toolset_data" + else self._instance_data_read(view, call) if keyword == "instance_data" + else self._container_read(view, call) + ) + ) + if entered: + assert isinstance(parent, ast.withitem) + targets = [parent.optional_vars] + else: + targets = parent.targets if isinstance(parent, ast.Assign) else [parent.target] if len(targets) != 1 or not isinstance(targets[0], ast.Name): return True target = targets[0] @@ -1427,71 +2043,341 @@ def _agent_result_changed( and lookup(node.id, node) == owner ): continue - use = view.scopes.parents.get(node) - if isinstance(use, ast.Return): - # Follow ``agent = Agent(...); return agent`` with its actual - # source scope, never a synthetic AST lacking lexical parents. - function = _enclosing_function(view.scopes, use) - calls = self.calls - if function is None or calls is None or view.module is None: + if self._handle_use_changed( + view, node, keyword, borrowed=borrowed, strict_container=strict_container, + ): + return True + return False + + def _handle_use_changed( + self, view: _View, node: ast.Name, keyword: str, *, borrowed: bool, strict_container: bool, + ) -> bool: + """Whether one load of a constructed handle may change or hand it on.""" + if (self._checking_constructor_namespaces and self._resolver is not None + and id(node) in self._resolver._constructor_wrapped_operands + and keyword in {"func", "wrapper_class"}): + return False # This exact operand loads the function before wrapping it. + use = view.scopes.parents.get(node) + if (keyword in {"__class__", *CAPABILITY_FIELDS} and strict_container + and self._checking_constructor_namespaces + and self._agent_copy_receiver_read(view, node, keyword)): + return False + if isinstance(use, ast.Return): + # Follow ``agent = Agent(...); return agent`` with its actual + # source scope, never a synthetic AST lacking lexical parents. + function = _enclosing_function(view.scopes, use) + calls = self.calls + if function is None or calls is None or view.module is None: + return True + key = (id(function), keyword) + if key in self._checking_returns or len(self._checking_returns) >= 4: + return True + self._checking_returns.add(key) + try: + census = calls.callers(view.module, function, allow_empty=self._checking_constructor_namespaces) + if self._constructor_limit(census.limits) or any( + self._agent_result_changed( + self._foreign(site.module), site.call, keyword, borrowed=borrowed, + strict_container=strict_container, + ) + for site in census.sites + ): return True - key = (id(function), keyword) - if key in self._checking_returns or len(self._checking_returns) >= 4: + finally: + self._checking_returns.remove(key) + elif keyword == "field_data": + if not self._field_data_read(view, node): + return True + elif keyword == "toolset_data": + if not self._toolset_data_read(view, node): + return True + elif keyword == "instance_data": + if not self._instance_data_read(view, node): + return True + elif (strict_container and self._checking_constructor_namespaces + and isinstance(use, ast.Attribute) and use.value is node and use.attr == "as_tool" + and isinstance(use.ctx, ast.Load) + and isinstance(call := view.scopes.parents.get(use), ast.Call) and call.func is use): + # The SDK's documented ``agent.as_tool(...)``: a new tool object that + # holds the agent. The handle goes no further; the tool has its own owner. + return not self._register_as_tool(view, call) + elif strict_container and self._checking_constructor_namespaces and self._agent_tool_argument(view, node, use): + return False # ADK's ``AgentTool(agent=...)`` holds the agent and goes no further. + elif (strict_container and self._checking_constructor_namespaces and isinstance(use, ast.Attribute) + and use.value is node and self._discarded_method_call(view, use)): + return False # A statement calling a method of the exact class; nothing is handed on. + elif isinstance(use, ast.Attribute): + if use.attr == "__dict__": + return True + if keyword == "__class__" and use.attr in CAPABILITY_FIELDS: + # Membership changes do not change the instance's class. + # Retained class-carrying members have their own ownership + # edges; their namespace use must remain proved separately. + return False + if use.attr == keyword: + if isinstance(view.scopes.parents.get(use), ast.AugAssign): return True - self._checking_returns.add(key) - try: - census = calls.callers(view.module, function) - if census.limits or any( - self._agent_result_changed( - self._foreign(site.module), site.call, keyword, borrowed=borrowed - ) - for site in census.sites - ): + if (strict_container and self._checking_constructor_namespaces + and isinstance(use.ctx, ast.Store | ast.Del)): + if isinstance(use.ctx, ast.Store) and not self._member_list_shape(view, use): return True - finally: - self._checking_returns.remove(key) - elif isinstance(use, ast.Attribute): - if use.attr == "__dict__": + return False # Dropping members does not export their identity. + if not borrowed and not isinstance(use.ctx, ast.Load): return True - if use.attr == keyword: - if isinstance(view.scopes.parents.get(use), ast.AugAssign): - return True - if not borrowed and not isinstance(use.ctx, ast.Load): - return True - if isinstance(use.ctx, ast.Load) and not read_only_use( - use, view.scopes.parents, self._call_reads(view) - ): - return True - elif isinstance(use.ctx, ast.Load) and not read_only_use( - node, view.scopes.parents, self._call_reads(view) + if isinstance(use.ctx, ast.Load) and not ( + self._container_read(view, use) if strict_container + else read_only_use(use, view.scopes.parents, self._call_reads(view)) ): return True - elif isinstance(use, ast.Call) and reference_spelling(use.func) in { - "getattr", - "setattr", - "delattr", - "vars", - }: - return True - elif isinstance(use, ast.Assign | ast.AnnAssign | ast.NamedExpr): - # An alias can subsequently reach the same mutable list. - return True - elif not read_only_use(node, view.scopes.parents, self._call_reads(view)): + elif isinstance(use.ctx, ast.Load) and not ( + self._container_read(view, use) if strict_container + else read_only_use(node, view.scopes.parents, self._call_reads(view)) + ): return True + elif isinstance(use, ast.Call) and reference_spelling(use.func) in { + "getattr", + "setattr", + "delattr", + "vars", + }: + return True + elif isinstance(use, ast.Assign | ast.AnnAssign | ast.NamedExpr): + if (strict_container and self._checking_constructor_namespaces + and keyword in {"__class__", *CAPABILITY_FIELDS} + and self._direct_alias_stores_owned(view, node)): + return False + # An alias can subsequently reach the same mutable list. + return True + elif not (self._container_read(view, node) if strict_container + else read_only_use(node, view.scopes.parents, self._call_reads(view))): + return True return False + def _enters_as_itself(self, view: _View, call: ast.Call) -> bool: + """``async with MCPServerStdio(...) as server``: the SDK's server is its own context. + + Its ``__aenter__`` connects and returns the server itself, so the name + the ``with`` binds is the object this owner already checks. Only the + SDK's MCP server classes, by their exact import, have that protocol. + """ + from agents_shipgate.inputs.object_tools import SDK_MCP_SERVERS + + resolver = self._resolver + if resolver is None or view.module is None: + return False + canonical = resolver._constructor_reference(view.module, call.func, view.scopes).get("external_constructor") + return (isinstance(canonical, str) and canonical.partition(".")[0] in {"agents", "openai_agents"} + and canonical.rsplit(".", 1)[-1] in SDK_MCP_SERVERS) + + @staticmethod + def _discarded_method_call(view: _View, attribute: ast.Attribute) -> bool: + """``await agent.run()`` as a statement: a method call whose result is dropped. + + The handle is an instance of the framework's own class, whose methods + do not edit its capability lists, and nothing is passed in or kept. A + method that could re-initialize it (``__init__``, ``model_*``) or a + field of the class is not this; a method the code stores on the + instance runs with no access to it except through names this census + already reads. + """ + name = attribute.attr + if (not isinstance(attribute.ctx, ast.Load) or name.startswith("_") or name.startswith("model_") + or name in _CONSTRUCTOR_FIELDS): + return False + call = view.scopes.parents.get(attribute) + if not (isinstance(call, ast.Call) and call.func is attribute): + return False + statement = view.scopes.parents.get(call) + if isinstance(statement, ast.Await): + statement = view.scopes.parents.get(statement) + return isinstance(statement, ast.Expr) + + def _register_as_tool(self, view: _View, call: ast.Call) -> bool: + """``agent.as_tool(...)`` builds a tool object; it needs an owner like any other.""" + resolver = self._resolver + if resolver is None or view.module is None: + return False + family = next((key[0] for key in resolver._constructor_namespace_owners if key[0] != "google.adk"), "agents") + resolver._constructor_namespace_owners.setdefault( + (family, id(call), "toolset_data"), (view.module, call, "toolset_data"), + ) + return True + + def _agent_tool_argument(self, view: _View, node: ast.Name, use: ast.AST | None) -> bool: + """Whether ``node`` is the agent ADK's ``AgentTool`` wraps, by that class's exact import.""" + from agents_shipgate.inputs.object_tools import ADK_AGENT_TOOLS + + resolver = self._resolver + if resolver is None or view.module is None: + return False + if isinstance(use, ast.keyword) and use.arg == "agent" and use.value is node: + call = view.scopes.parents.get(use) + elif isinstance(use, ast.Call) and use.args and use.args[0] is node and use.func is not node: + call = use + else: + return False + if not isinstance(call, ast.Call): + return False + return resolver._constructor_reference(view.module, call.func, view.scopes).get("external_constructor") in ADK_AGENT_TOOLS + + def _direct_alias_stores_owned(self, view: _View, node: ast.Name) -> bool: + """An exact direct-instance alias may only replace owned list fields.""" + calls = self.calls + constructor = _direct_alias_constructor(view, node) + if constructor is None or calls is None or view.module is None: + return False + lookup = view.lookup or bindings_at(view.scopes, view.bindings) + + def scope(at: ast.AST) -> ast.AST: + current = view.scopes.parents.get(evaluation_site(view.scopes, at)) + while current is not None: + if isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda): + return current + current = view.scopes.parents.get(current) + return view.tree + + origin_scope = scope(node) + if scope(constructor) is not origin_scope: + return False # A captured instance does not receive this local-store role. + pending = [node] + checked: set[int] = set() + stores = 0 + work = 0 + while pending: + source = pending.pop() + parent = view.scopes.parents.get(source) + if not isinstance(parent, ast.Assign | ast.AnnAssign) or parent.value is not source: + return False + targets = parent.targets if isinstance(parent, ast.Assign) else [parent.target] + if len(targets) != 1 or not isinstance(targets[0], ast.Name): + return False + target = targets[0] + if id(parent) in checked or len(checked) >= _NAMESPACE_ALIAS_LIMIT: + return False + checked.add(id(parent)) + bindings = lookup(target.id, target) + if (len(bindings) != 1 or bindings[0][1] is not parent + or calls.exported_elsewhere(view.module, source)): + return False + for use in ast.walk(view.tree): + work += 1 + if work > MAX_VISITS: + return False + if (not isinstance(use, ast.Name) or not isinstance(use.ctx, ast.Load) + or use.id != target.id or lookup(use.id, use) != bindings): + continue + if scope(use) is not origin_scope or _direct_alias_constructor(view, use) is not constructor: + return False + destination = view.scopes.parents.get(use) + if isinstance(destination, ast.Assign | ast.AnnAssign) and destination.value is use: + pending.append(use) + elif (isinstance(destination, ast.Attribute) and destination.value is use + and destination.attr in CAPABILITY_FIELDS and isinstance(destination.ctx, ast.Store) + and self._member_list_shape(view, destination)): + stores += 1 + else: + return False + return stores > 0 + + def _agent_copy_receiver_read(self, view: _View, node: ast.Name, keyword: str) -> bool: + """Retain an exact SDK copy receiver only through its owned result.""" + parent = view.scopes.parents.get(node) + if isinstance(parent, ast.Attribute) and parent.value is node and parent.attr == "clone": + call = view.scopes.parents.get(parent) + if not isinstance(call, ast.Call) or call.func is not parent: + return False + elif (isinstance(parent, ast.Call) and parent.args == [node] + and _namespace_call_reference(view, parent) in {"dataclasses.replace", "copy.replace"}): + call = parent + else: + return False + # The existing consumer proof checks the direct Agent receiver, its + # actual imports and providers, and class/protocol mutations. A copy + # must also retain its class safely through every use of its result. + return ( + self._reads_agent(view, call, "tools") + and not self._agent_result_changed(view, call, "__class__", strict_container=True) + and (keyword == "__class__" or not self._agent_result_changed( + view, call, keyword, strict_container=True, + )) + ) + + @staticmethod + def _field_data_read(view: _View, node: ast.expr) -> bool: + """FieldInfo is data carrying a shared class, never a builtin container.""" + parent = view.scopes.parents.get(node) + return isinstance(parent, ast.Expr) or ( + isinstance(parent, ast.Compare) + and all(isinstance(operator, ast.Is | ast.IsNot) for operator in parent.ops) + ) + + @staticmethod + def _instance_data_read(view: _View, node: ast.expr) -> bool: + """An inert dataclass instance is read for its fields and goes nowhere else. + + Its fields hold strings and ``None`` (the class body admits nothing + else), so a field read hands on no namespace. Passing the instance on, + calling anything on it or aliasing it is not that read. + """ + parent = view.scopes.parents.get(node) + if isinstance(parent, ast.Expr) or ( + isinstance(parent, ast.Compare) and all(isinstance(operator, ast.Is | ast.IsNot) for operator in parent.ops) + ): + return True + if not (isinstance(parent, ast.Attribute) and parent.value is node and isinstance(parent.ctx, ast.Load) + and not parent.attr.startswith("_")): + return False + call = view.scopes.parents.get(parent) + return not (isinstance(call, ast.Call) and call.func is parent) + + def _toolset_data_read(self, view: _View, node: ast.expr) -> bool: + """A toolset instance only reaches the existing Agent.tools destination. + + Literal list/tuple packaging does not make this object a builtin + container: methods, indexing, aliases and other consumers stay unread. + Returned factories are followed by the surrounding handle census. + """ + parent = view.scopes.parents.get(node) + if isinstance(parent, ast.Expr) or ( + isinstance(parent, ast.Compare) and all(isinstance(operator, ast.Is | ast.IsNot) for operator in parent.ops) + ): + return True + current: ast.AST = node + while isinstance(view.scopes.parents.get(current), ast.List | ast.Tuple): + current = view.scopes.parents[current] + keyword = view.scopes.parents.get(current) + call = view.scopes.parents.get(keyword) + return bool( + isinstance(keyword, ast.keyword) and keyword.arg in {"tools", "mcp_servers"} and isinstance(call, ast.Call) + and self._reads_agent(view, call, keyword.arg) + and not self._agent_result_changed(view, call, keyword.arg, borrowed=True, strict_container=True) + ) + # -- resolution -------------------------------------------------------- def constructor_changed(self, call: ast.Call, invocation: Invocation | None) -> bool: """A bound construction must retain its constructor even for literals.""" + self._constructor_change_context = None if invocation is None: return False previous, self._invocation = self._invocation, invocation try: - return not self._reads_agent(self.entry, call, "tools") + staged: list[str] = [] + changed = not self._reads_agent(self.entry, call, "tools", refused_context=staged) + self._constructor_change_context = ( + (call, invocation.key, _source_slot_context(staged)) if changed else None + ) + return changed + except BaseException: + self._constructor_change_context = None + raise finally: self._invocation = previous + def constructor_refusal_context(self, call: ast.Call, invocation: Invocation | None) -> tuple[str, ...]: + row = self._constructor_change_context + return row[2] if row is not None and row[0] is call and invocation is not None and row[1] == invocation.key else () + def construction_changed(self, invocation: Invocation | None, field: str) -> bool: """Guard an entire construction, including fields omitted by its source.""" return invocation is not None and self._agent_result_changed( @@ -1564,6 +2450,11 @@ def _resolve(self, node: ast.expr, view: _View, depth: int, seen: frozenset) -> return self._stop(view, node, "the expression is larger than the reader follows") if depth > MAX_DEPTH: return self._stop(view, node, "the expression nests further than the reader follows") + if self._projection is not None: + if isinstance(node, ast.Dict): + return self._dictionary(node, view, depth, seen) + if not isinstance(node, ast.Name | ast.Attribute | ast.Call): + return self._stop(view, node, "the projected value is not a proven literal dictionary") if isinstance(node, ast.List | ast.Tuple): result = ListResolution() for item in node.elts: @@ -1586,6 +2477,10 @@ def _resolve(self, node: ast.expr, view: _View, depth: int, seen: frozenset) -> return self._filtered_comprehension(node, view, depth, seen) if isinstance(node, ast.Call): return self._call(node, view, depth, seen) + if isinstance(node, ast.Subscript): + if not isinstance(node.slice, ast.Constant) or not isinstance(node.slice.value, str): + return self._stop(view, node, "a dictionary projection needs one literal string key") + return self._project(node.value, _Projection(node.slice.value, view, self._invocation), view, depth, seen) if isinstance(node, ast.Name): return self._name(node, view, depth, seen) if isinstance(node, ast.Attribute): @@ -1645,20 +2540,931 @@ def _filtered_comprehension( def _call(self, node: ast.Call, view: _View, depth: int, seen: frozenset) -> ListResolution: func = node.func + if self._projection is None and isinstance(func, ast.Attribute) and func.attr == "get": + if ( + len(node.args) not in {1, 2} or node.keywords + or not isinstance(node.args[0], ast.Constant) + or not isinstance(node.args[0].value, str) + ): + return self._stop(view, node, "a dictionary get needs one literal string key and an optional positional default") + if len(node.args) == 2 and not self._nonexecuting_default(view, node.args[1]): + return self._stop(view, node, "a dictionary get default is evaluated eagerly and its executable or unresolved expression is not read") + return self._project( + func.value, + _Projection(node.args[0].value, view, self._invocation, required=False, + default=node.args[1] if len(node.args) == 2 else None), + view, depth, seen, + ) builtin = ( func.id if isinstance(func, ast.Name) and not (view.lookup or bindings_at(view.scopes, view.bindings))(func.id, func) else None ) - if builtin in _SAME_MEMBERS and len(node.args) == 1 and not node.keywords: + if self._projection is None and builtin in _SAME_MEMBERS and len(node.args) == 1 and not node.keywords: return self._resolve(node.args[0], view, depth + 1, seen) - if builtin == "filter" and len(node.args) == 2 and not node.keywords: + if self._projection is None and builtin == "filter" and len(node.args) == 2 and not node.keywords: predicate, iterable = node.args part = self._resolve(iterable, view, depth + 1, seen) if isinstance(predicate, ast.Constant) and predicate.value is None: return part return part.under(f"the filter `{_condition(predicate)}` keeps it") - return self._stop(view, node, f"a call to `{_source(func)}`, whose result is not read") + return self._factory(node, view, depth, seen) + + def _nonexecuting_default(self, view: _View, node: ast.expr) -> bool: + if isinstance(node, ast.Constant): + return node.value is None + if isinstance(node, ast.List | ast.Tuple): + return all(self._nonexecuting_default(view, item) for item in node.elts) + if isinstance(node, ast.Name | ast.Attribute) and view.module is not None and self.calls is not None: + resolution = self.calls.resolve(view.module, node) + return resolution.resolved and not resolution.caveats + return False + + def _project( + self, node: ast.expr, projection: _Projection, view: _View, depth: int, seen: frozenset + ) -> ListResolution: + previous, self._projection = self._projection, projection + try: + return self._resolve(node, view, depth + 1, seen) + finally: + self._projection = previous + + def _dictionary(self, node: ast.Dict, view: _View, depth: int, seen: frozenset) -> ListResolution: + projection = self._projection + assert projection is not None + keys: dict[str, ast.expr] = {} + for key, value in zip(node.keys, node.values, strict=True): + if not isinstance(key, ast.Constant) or not isinstance(key.value, str): + return self._stop(view, node, "dictionary spreads or nonliteral string keys may replace the projected entry") + if key.value in keys: + return self._stop(view, node, f"dictionary key {key.value!r} is defined more than once") + keys[key.value] = value + selected = keys.get(projection.key) + if selected is None and projection.required: + return self._stop(view, node, f"dictionary key {projection.key!r} is absent; subscription does not yield an empty list") + previous, self._projection = self._projection, None + invocation = self._invocation + try: + if selected is not None: + return self._resolve(selected, view, depth + 1, seen).through( + f"dictionary key {projection.key!r} ({self._where(view, node)})" + ) + if projection.default is None: + return ListResolution() + self._invocation = projection.invocation + result = self._resolve(projection.default, projection.view, depth + 1, seen) + projection.caller_defaults.update((id(member.expr), id(member.module)) for member in result.members) + return result + finally: + self._projection, self._invocation = previous, invocation + + def _factory(self, node: ast.Call, view: _View, depth: int, seen: frozenset) -> ListResolution: + calls = self.calls + if calls is None or view.module is None: + return self._stop(view, node, f"a call to `{_source(node.func)}`, whose result is not read") + from agents_shipgate.inputs.builder_calls import CallLimit, CallSite, single_return + + resolution = calls.resolve(view.module, node.func) + if not resolution.resolved: + return self._stop(view, node, f"a call to `{_source(node.func)}`, whose result is not read") + if resolution.caveats: + detail = "; ".join(resolution.caveats) + return self._stop(view, node, f"a call to `{_source(node.func)}`, whose result is not read: {detail}") + home, function = resolution.module, resolution.definition + assert home is not None and function is not None + returned = single_return(function) + if returned is None: + return self._stop(view, node, "the factory has no single final return expression") + key = ("factory", id(function)) + if key in seen: + return self._stop(view, node, "the returned-list factory calls itself") + try: + census = calls.callers(home, function) + if census.limits: + return self._stop(view, node, "the factory callable is not established: " + "; ".join(census.limits)) + invocation = calls.invoke(home, function, CallSite(view.module, node), self._invocation) + except CallLimit as exc: + return self._stop(view, node, f"the returned-list factory call is not established: {exc}") + if not self._inert_factory(function) or any( + not (established := calls.resolve(home, item)).resolved or established.caveats + for item in ast.walk(returned) if isinstance(item, ast.Name) + ): + return self._stop(view, node, "the factory needs an inert prelude and a literal list, tuple or dictionary return; executable or unresolved expressions are not read by this increment") + # Another invocation can hand on the same module callables (and their + # globals) before this call. A fresh container at the selected site + # cannot establish ownership while any other result escapes. + if any(self._factory_result_changed(self._foreign(site.module), site.call) for site in census.sites): + return self._stop(view, node, "the returned factory container or a projected member may be changed or handed on") + previous, self._invocation = self._invocation, invocation + try: + result = self._resolve(returned, self._foreign(home), depth + 1, seen | {key}) + finally: + self._invocation = previous + changed = self._factory_member_change( + self._foreign(home), function, result, + caller_defaults=self._projection.caller_defaults if self._projection else frozenset(), + ) + if changed is not None: + return self._stop(view, node, changed) + for condition in invocation.conditions: + result = result.under(condition) + return result.through(f"{function.name} returned at {self._where(view, node)}") + + @staticmethod + def _inert_factory(function: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + """Limit outer statements and eager returned values to supported syntax.""" + if not all( + isinstance(statement, ast.Import | ast.ImportFrom | ast.Pass) + or (isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant) and isinstance(statement.value.value, str)) + for statement in function.body[:-1] + ): + return False + + def literal(node: ast.expr) -> bool: + if isinstance(node, ast.List | ast.Tuple): + return all(isinstance(item, ast.Name) for item in node.elts) + if isinstance(node, ast.Dict): + return all( + isinstance(key, ast.Constant) and isinstance(key.value, str) + and (isinstance(value, ast.List | ast.Tuple) and literal(value) or isinstance(value, ast.Constant) and value.value is None) + for key, value in zip(node.keys, node.values, strict=True) + ) + return False + + returned = function.body[-1] + return isinstance(returned, ast.Return) and returned.value is not None and literal(returned.value) + + def _constructor_limit(self, limits: tuple[str | None, ...]) -> bool: + reasons = [reason for reason in limits if reason] + if reasons and self._checking_constructor_namespaces: + self.constructor_namespace_issue = self.constructor_namespace_issue or "; ".join(reasons) + return bool(reasons) + + def _factory_result_changed(self, view: _View, call: ast.expr, *, dictionary: bool | None = None) -> bool: + if not self._checking_constructor_namespaces: + return self._factory_result_changed_inner(view, call, dictionary=dictionary) + key = (id(view.tree), id(call), dictionary) + if key in self._checking_container_owners or len(self._checking_container_owners) >= 128: + self.constructor_namespace_issue = "recursive constructor-container ownership or more than 128 retained edges" + return True + if key in self._constructor_container_results: + return self._constructor_container_results[key] + self._constructor_owner_work += 1 + if self._constructor_owner_work > 4096: + self.constructor_namespace_issue = "the constructor ownership traversal exceeds 4096 edges" + return True + self._checking_container_owners.add(key) + try: + result = self._factory_result_changed_inner(view, call, dictionary=dictionary) + self._constructor_container_results[key] = result + return result + finally: + self._checking_container_owners.remove(key) + + def _factory_result_changed_inner(self, view: _View, call: ast.expr, *, dictionary: bool | None = None) -> bool: + if self._reflection(view) is not None: + return True + if view.module is not None and self.calls is not None and self.calls.exported_elsewhere(view.module, call): + self._constructor_limit((self.calls.export_limit(call),)) + if not (self._checking_constructor_namespaces and isinstance(call, ast.List | ast.Tuple) + and self._imported_members_owned(view, call)): + return True + parent = view.scopes.parents.get(call) + if dictionary is None: + dictionary = isinstance(call, ast.Dict) or self._projection is not None + if isinstance(parent, ast.Return): + function = _enclosing_function(view.scopes, parent) + if function is None or view.module is None or self.calls is None: + return True + key = (id(function), "namespace_dictionary" if dictionary else "namespace_result") + if key in self._checking_returns or len(self._checking_returns) >= 4: + return True + self._checking_returns.add(key) + try: + census = self.calls.callers(view.module, function, allow_empty=self._checking_constructor_namespaces) + return self._constructor_limit(census.limits) or any( + self._factory_result_changed(self._foreign(site.module), site.call, dictionary=dictionary) + for site in census.sites + ) + finally: + self._checking_returns.remove(key) + if not isinstance(parent, ast.Assign | ast.AnnAssign) or parent.value is not call: + return not self._container_read(view, call, dictionary=dictionary) + targets = parent.targets if isinstance(parent, ast.Assign) else [parent.target] + if (self._checking_constructor_namespaces and len(targets) == 1 + and isinstance(targets[0], ast.Attribute) and targets[0].attr in CAPABILITY_FIELDS): + return not self._container_read(view, call, dictionary=dictionary) + if len(targets) != 1 or not isinstance(targets[0], ast.Name): + return True + return self._container_binding_changed(view, targets[0], dictionary=dictionary) + + def _container_binding_changed(self, view: _View, target: ast.Name, *, dictionary: bool = True) -> bool: + lookup = view.lookup or bindings_at(view.scopes, view.bindings) + owner = lookup(target.id, target) + return any( + not self._container_read(view, node, dictionary=dictionary) + for node in ast.walk(view.tree) + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) + and node.id == target.id and lookup(node.id, node) == owner + ) + + def _container_read(self, view: _View, node: ast.expr, *, dictionary: bool = False) -> bool: + """Read a retained container through its projection, never stop at indexing.""" + parent = view.scopes.parents.get(node) + if isinstance(parent, ast.arguments): + function = view.scopes.parents.get(parent) + if (view.module is None or self.calls is None or not isinstance(function, ast.FunctionDef) + or function.decorator_list + or any(isinstance(item, ast.Yield | ast.YieldFrom | ast.GeneratorExp) for item in ast.walk(function))): + return False + positional = [*parent.posonlyargs, *parent.args] + defaults = list(zip(positional[len(positional) - len(parent.defaults):], parent.defaults, strict=True)) + defaults += list(zip(parent.kwonlyargs, parent.kw_defaults, strict=True)) + parameter = next((argument for argument, value in defaults if value is node), None) + if parameter is None: + return False + key = (id(function), "namespace_default:" + parameter.arg) + if key in self._checking_returns or len(self._checking_returns) >= 4: + return False + from agents_shipgate.inputs.builder_calls import CallLimit + self._checking_returns.add(key) + try: + census = self.calls.callers( + view.module, function, allow_empty=self._checking_constructor_namespaces, + ) + if self._constructor_limit(census.limits): + return False + for site in census.sites: + self.calls.invoke(view.module, function, site) + uses = [ + (item, view.scopes.enclosing_bindings(evaluation_site(view.scopes, item), item.id)) + for item in ast.walk(function) if isinstance(item, ast.Name) and item.id == parameter.arg + ] + return not any( + isinstance(item, ast.Global | ast.Nonlocal) and parameter.arg in item.names + for item in ast.walk(function) + ) and all( + bindings == [parameter] and isinstance(item.ctx, ast.Load) + and self._container_read(view, item, dictionary=dictionary) + for item, bindings in uses if parameter in bindings + ) + except CallLimit as exc: + self._constructor_limit((str(exc),)) + return False + finally: + self._checking_returns.remove(key) + if isinstance(parent, ast.Return): + return not self._factory_result_changed(view, node, dictionary=dictionary) + if isinstance(parent, ast.Dict) and node in parent.values: + return all(isinstance(key, ast.Constant) and isinstance(key.value, str) for key in parent.keys) and self._container_read(view, parent, dictionary=True) + if isinstance(parent, ast.List | ast.Tuple) and node in parent.elts: + return self._container_read(view, parent) + known_copy = (isinstance(node, ast.Call) and not node.keywords and len(node.args) == 1 + and reference_spelling(node.func) in {"list", "tuple"} + and leaves_arguments_alone(node, view.lookup or bindings_at(view.scopes, view.bindings))) + known_copy = known_copy or ( + not dictionary and isinstance(node, ast.Call) and not node.args and not node.keywords + and isinstance(node.func, ast.Attribute) and node.func.attr == "copy" + and self._member_list_shape(view, node.func.value, tuple_ok=True) + ) + if ((isinstance(node, ast.List | ast.Tuple | ast.Dict | ast.BoolOp | ast.IfExp | ast.BinOp) or known_copy) + and isinstance(parent, ast.Assign | ast.AnnAssign) and parent.value is node): + targets = parent.targets if isinstance(parent, ast.Assign) else [parent.target] + if (self._checking_constructor_namespaces and len(targets) == 1 + and isinstance(targets[0], ast.Attribute) and targets[0].attr in CAPABILITY_FIELDS): + # Prove the sink after the current handle walk has completed; + # recursively asking the same handle now would reject even an + # owned replacement. The outer census validates this exact edge. + if self._resolver is None or view.module is None: + return False + self._resolver._constructor_member_sinks[id(targets[0])] = (view.module, targets[0]) + return self._member_list_shape(view, targets[0]) + return (len(targets) == 1 and isinstance(targets[0], ast.Name) + and not self._container_binding_changed(view, targets[0], dictionary=dictionary)) + if isinstance(parent, ast.BoolOp) or ( + isinstance(parent, ast.IfExp) and parent.test is not node + ): + return self._container_read(view, parent, dictionary=dictionary) + if isinstance(parent, ast.Starred) and parent.value is node: + outer = view.scopes.parents.get(parent) + if (isinstance(outer, ast.Call) and outer.args == [parent] and not outer.keywords + and reference_spelling(outer.func) == "print" + and leaves_arguments_alone(outer, view.lookup or bindings_at(view.scopes, view.bindings))): + return True + return isinstance(outer, ast.List | ast.Tuple) and self._container_read(view, outer) + if isinstance(parent, ast.BinOp): + other = parent.right if parent.left is node else parent.left + return not dictionary and isinstance(parent.op, ast.Add) and self._list_operand(view, other, subject=node) and self._container_read(view, parent) + if isinstance(parent, ast.Compare): + return all(isinstance(operator, ast.Is | ast.IsNot) for operator in parent.ops) + if isinstance(parent, ast.Subscript) and parent.value is node: + return ( + isinstance(parent.ctx, ast.Load) and isinstance(parent.slice, ast.Constant) + and dictionary and isinstance(parent.slice.value, str) and self._container_read(view, parent) + ) + if isinstance(parent, ast.Attribute) and parent.value is node and parent.attr == "get": + call = view.scopes.parents.get(parent) + return ( + isinstance(call, ast.Call) and call.func is parent and len(call.args) in {1, 2} + and not call.keywords and isinstance(call.args[0], ast.Constant) + and dictionary and isinstance(call.args[0].value, str) and self._container_read(view, call) + ) + if dictionary and isinstance(parent, ast.Attribute | ast.BinOp): + # dict.copy/values/items and union retain the mutable values. A + # fresh list copy or list concatenation has separate membership. + return False + if isinstance(parent, ast.Attribute) and parent.value is node: + call = view.scopes.parents.get(parent) + if isinstance(call, ast.Call) and call.func is parent: + if (parent.attr == "as_tool" and self._checking_constructor_namespaces + and self._register_as_tool(view, call)): + return True # The tool object it builds has its own owner. + if parent.attr == "copy" and not call.args and not call.keywords: + return self._container_read(view, call) + if self._checking_constructor_namespaces and self._member_mutation(view, node, call): + return True + return parent.attr in {"count", "index"} and all( + self._nonexecuting_default(view, argument) for argument in call.args + ) and not call.keywords + return False + if (isinstance(parent, ast.Call) and isinstance(parent.func, ast.Attribute) + and parent.func.attr == "get" and len(parent.args) == 2 and not parent.keywords + and parent.args[1] is node and isinstance(parent.args[0], ast.Constant) + and isinstance(parent.args[0].value, str) and self._nonexecuting_default(view, node)): + shape = self._shape_value(view, parent.func.value, 0) + if shape is not None and isinstance(shape[0], ast.Dict): + keys = [key.value for key in shape[0].keys if isinstance(key, ast.Constant) and isinstance(key.value, str)] + if len(keys) == len(shape[0].keys) and len(set(keys)) == len(keys): + return self._container_read(view, parent) + call, position, keyword = None, None, None + if isinstance(parent, ast.keyword): + call, keyword = view.scopes.parents.get(parent), parent.arg + elif isinstance(parent, ast.Call): + call = parent + position = next((i for i, argument in enumerate(call.args) if argument is node), None) + if isinstance(call, ast.Call): + if (self._checking_constructor_namespaces and isinstance(call.func, ast.Attribute) + and node in call.args and call.func.attr in {"append", "insert", "extend"} + and self._member_mutation(view, call.func.value, call)): + if self._resolver is None or view.module is None: + return False + self._resolver._constructor_member_sinks[id(call.func.value)] = (view.module, call.func.value) + return True + lookup = view.lookup or bindings_at(view.scopes, view.bindings) + if leaves_arguments_alone(call, lookup): + name = reference_spelling(call.func) + if name in {"list", "tuple"} and len(call.args) == 1 and not call.keywords: + return dictionary or self._container_read(view, call) + return name in {"len", "repr", "str", "bool", "id", "hash", "type", "print"} and call.args == [node] and not call.keywords + if dictionary: + # A helper's old list-use proof stops at subscription and + # cannot show it leaves the dictionary's mutable values alone. + return False + if self._reads_agent(view, call, keyword): + return not self._agent_result_changed(view, call, str(keyword), borrowed=True, strict_container=True) + return self._callee_leaves_alone(view, call, position, keyword, strict_container=True) + # Iteration may retain the mutable projected list, and an alias may + # subsequently change it. Neither is a read-only ownership proof. + if isinstance(parent, ast.For | ast.AsyncFor | ast.comprehension): + return False + return read_only_use(node, view.scopes.parents, self._call_reads(view)) + + def _member_mutation(self, view: _View, receiver: ast.expr, call: ast.Call) -> bool: + """Membership edits may preserve contained objects, only on builtin lists.""" + if (not isinstance(view.scopes.parents.get(call), ast.Expr) or call.keywords + or any(isinstance(argument, ast.Starred) for argument in call.args) + or not isinstance(call.func, ast.Attribute)): + return False + method = call.func.attr + signature = ( + method == "append" and len(call.args) == 1 + or method == "insert" and len(call.args) == 2 + and isinstance(call.args[0], ast.Constant) and type(call.args[0].value) is int + or method in {"clear", "reverse"} and not call.args + or method == "extend" and len(call.args) == 1 + and self._member_list_shape(view, call.args[0], tuple_ok=True) + ) + return bool(signature) and self._member_list_shape(view, receiver) + + def _member_list_shape( + self, view: _View, node: ast.expr, invocation: Invocation | None = None, + *, tuple_ok: bool = False, depth: int = 0, seen: frozenset[int] = frozenset(), + ) -> bool: + """Prove receiver type and replacement history without granting membership.""" + if depth > MAX_DEPTH or id(node) in seen: + return False + self._constructor_owner_work += 1 + if self._constructor_owner_work > 4096: + self.constructor_namespace_issue = "the constructor ownership traversal exceeds 4096 edges" + return False + seen = seen | {id(node)} + + def shape(other: ast.expr, source: _View = view, context: Invocation | None = invocation) -> bool: + return self._member_list_shape(source, other, context, tuple_ok=tuple_ok, depth=depth + 1, seen=seen) + + if isinstance(node, ast.List): + return True + if isinstance(node, ast.Tuple): + return tuple_ok + if isinstance(node, ast.BoolOp): + return all(shape(value) for value in node.values) + if isinstance(node, ast.IfExp): + return shape(node.body) and shape(node.orelse) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + return shape(node.left) and shape(node.right) + lookup = view.lookup or bindings_at(view.scopes, view.bindings) + if isinstance(node, ast.Call) and leaves_arguments_alone(node, lookup): + return (reference_spelling(node.func) == "list" and not node.keywords + and len(node.args) <= 1 and (not node.args or self._member_list_shape( + view, node.args[0], invocation, tuple_ok=True, depth=depth + 1, seen=seen))) + if isinstance(node, ast.Name): + bindings = lookup(node.id, node) + if len(bindings) != 1: + return False + binding, statement = bindings[0] + if isinstance(binding, ast.arg): + origin = invocation.argument(binding) if invocation is not None else None + if origin is not None: + home, value, context = origin + return shape(value, self._foreign(home), context) + function = view.scopes.parents.get(view.scopes.parents.get(binding)) + if view.module is None or self.calls is None or not isinstance(function, ast.FunctionDef): + return False + from agents_shipgate.inputs.builder_calls import CallLimit + try: + census = self.calls.callers(view.module, function) + if self._constructor_limit(census.limits): + return False + for site in census.sites: + context = self.calls.invoke(view.module, function, site) + origin = context.argument(binding) + if origin is None or not shape(origin[1], self._foreign(origin[0]), origin[2]): + return False + return bool(census.sites) + except CallLimit as exc: + self._constructor_limit((str(exc),)) + return False + if isinstance(binding, ast.alias) and view.module is not None and self.calls is not None: + resolution = self.calls.resolve(view.module, node) + return (resolution.module is not None and resolution.value is not None and not resolution.caveats + and shape(resolution.value, self._foreign(resolution.module), None)) + value = getattr(statement, "value", None) + return isinstance(binding, ast.Name) and isinstance(value, ast.expr) and shape(value) + if isinstance(node, ast.Attribute) and node.attr in CAPABILITY_FIELDS and isinstance(node.value, ast.Name): + bindings = lookup(node.value.id, node.value) + if len(bindings) != 1: + return False + binding, statement = bindings[0] + value = getattr(statement, "value", None) + if not isinstance(binding, ast.Name): + return False + if isinstance(node.ctx, ast.Store): + # Replacing a field owns the new container, even when the + # constructor omitted that field. This grants no initial-field + # read or mutation role, and no returned/copied alias role. + direct = _direct_alias_constructor(view, node.value) + write = view.scopes.parents.get(node) + if (direct is not None and self._reads_agent(view, direct, node.attr) + and ((isinstance(write, ast.Assign) and write.targets == [node]) + or (isinstance(write, ast.AnnAssign) and write.target is node))): + return shape(write.value) + if not isinstance(value, ast.Call): + return False + field = self._member_agent_field(view, value, node.attr, invocation, depth, seen) + if field is None or not shape(field[1], field[0], field[2]): + return False + # Every lexical write to this exact field must retain builtin type. + for use in ast.walk(view.tree): + if (isinstance(use, ast.Attribute) and use.attr == node.attr and isinstance(use.value, ast.Name) + and use.value.id == node.value.id and lookup(use.value.id, use.value) == bindings + and isinstance(use.ctx, ast.Store)): + write = view.scopes.parents.get(use) + replacement = getattr(write, "value", None) + if not isinstance(write, ast.Assign | ast.AnnAssign) or not isinstance(replacement, ast.expr) or not shape(replacement): + return False + return True + return False + + def _member_agent_field( + self, view: _View, call: ast.Call, field: str, invocation: Invocation | None, + depth: int, seen: frozenset[int], + ) -> tuple[_View, ast.expr, Invocation | None] | None: + if depth > 4 or view.module is None or self.calls is None: + return None + if self._reads_agent(view, call, field): + values = [keyword.value for keyword in call.keywords if keyword.arg == field] + return (view, values[0], invocation) if len(values) == 1 else None + from agents_shipgate.inputs.builder_calls import CallLimit, CallSite, single_return + resolution = self.calls.resolve(view.module, call.func) + if resolution.module is None or resolution.definition is None or resolution.caveats: + return None + try: + if self._constructor_limit(self.calls.callers(resolution.module, resolution.definition).limits): + return None + context = self.calls.invoke(resolution.module, resolution.definition, CallSite(view.module, call), invocation) + except CallLimit as exc: + self._constructor_limit((str(exc),)) + return None + returned = single_return(resolution.definition) + foreign = self._foreign(resolution.module) + if isinstance(returned, ast.Name): + bindings = (foreign.lookup or bindings_at(foreign.scopes, foreign.bindings))(returned.id, returned) + returned = getattr(bindings[0][1], "value", None) if len(bindings) == 1 else None + if not isinstance(returned, ast.Call) or id(returned) in seen: + return None + return self._member_agent_field(foreign, returned, field, context, depth + 1, seen | {id(returned)}) + + def _member_receiver_owned(self, view: _View, receiver: ast.expr) -> bool: + key = (id(view.tree), id(receiver)) + if key in self._checking_member_receivers or len(self._checking_member_receivers) >= 128: + return False + if not self._member_list_shape(view, receiver): + return False + self._checking_member_receivers.add(key) + try: + lookup = view.lookup or bindings_at(view.scopes, view.bindings) + if isinstance(receiver, ast.Attribute) and isinstance(receiver.value, ast.Name): + bindings = lookup(receiver.value.id, receiver.value) + value = getattr(bindings[0][1], "value", None) if len(bindings) == 1 else None + if isinstance(receiver.ctx, ast.Store): + value = _direct_alias_constructor(view, receiver.value) or value + return isinstance(value, ast.Call) and not self._agent_result_changed( + view, value, receiver.attr, borrowed=True, strict_container=True) + if not isinstance(receiver, ast.Name): + return False + bindings = lookup(receiver.id, receiver) + if len(bindings) != 1: + return False + binding, statement = bindings[0] + if isinstance(binding, ast.arg): + function = view.scopes.parents.get(view.scopes.parents.get(binding)) + if not isinstance(function, ast.FunctionDef): + return False + return all( + isinstance(item.ctx, ast.Load) and self._container_read(view, item) + for item in ast.walk(function) if isinstance(item, ast.Name) + and lookup(item.id, item) == bindings + ) + if isinstance(binding, ast.alias) and view.module is not None and self.calls is not None: + resolution = self.calls.resolve(view.module, receiver) + return (resolution.module is not None and isinstance(resolution.value, ast.List | ast.Tuple) + and not self._factory_result_changed(self._foreign(resolution.module), resolution.value)) + value = getattr(statement, "value", None) + return isinstance(value, ast.expr) and not self._factory_result_changed(view, value) + finally: + self._checking_member_receivers.remove(key) + + def _imported_members_owned( + self, view: _View, value: ast.expr, + owned: Callable[[_View, ast.expr], bool] | None = None, + *, carriers: bool = True, require_use: bool = False, + ) -> bool: + """Use the shared-list census, then inspect object identity through every use. + + ``owned`` replaces the container-read test for a value that is not a + list: the proof that one importer's use of the value changes nothing. + ``carriers`` is False for a value of inert data, which a function that + merely imports it cannot make into a handle on any namespace. + ``require_use`` refuses an export nothing uses: an agent handle that + left its module with no read use has nothing proved about it. + """ + if view.module is None or self.calls is None: + return False + names = [name for name, bindings in view.module.bindings.items() + if len(bindings) == 1 and bindings[0].top_level and getattr(bindings[0].statement, "value", None) is value] + if len(names) != 1: + return False + from agents_shipgate.inputs.builder_calls import CallLimit + proved = 0 + try: + borrowers = self.calls.borrowers(view.module, names[0], namespace_carriers=True) + retaining = self.calls.retaining_modules(view.module, names[0], namespace_carriers=True) + families = {key[0] for key in self._resolver._constructor_namespace_owners} if self._resolver is not None else set() + if self._resolver is not None: + steps = [{"path": module.ref, "name": "Agent"} for module in borrowers if module.path in retaining] + for family in families: + if self._resolver._no_import_patch({"module": view.module}, steps, external_symbol=family): + return False + for module in borrowers: + if self.calls.dynamic_importer(module) is not None or self._reflection(self._foreign(module)) is not None: + return False + foreign = self._foreign(module) + lookup = foreign.lookup or bindings_at(foreign.scopes, foreign.bindings) + for node in ast.walk(module.tree): + if not isinstance(node, ast.Name | ast.Attribute) or not isinstance(node.ctx, ast.Load): + continue + self._constructor_owner_work += 1 + if self._constructor_owner_work > 4096: + self.constructor_namespace_issue = "the constructor ownership traversal exceeds 4096 edges" + return False + parent = foreign.scopes.parents.get(node) + if isinstance(parent, ast.Attribute) and parent.value is node: + continue + resolution = self.calls.resolve(module, node) + subject = node + if (isinstance(node, ast.Attribute) and isinstance(parent, ast.Call) and parent.func is node + and (self._member_mutation(foreign, node.value, parent) + or owned is not None and node.attr == "as_tool")): + subject = node.value + resolution = self.calls.resolve(module, subject) + elif owned is not None and isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name): + # ``settings.url``: the handle is the receiver; its owner reads the use. + subject = node.value + resolution = self.calls.resolve(module, subject) + if resolution.module is view.module and resolution.value is value: + if resolution.caveats or not ( + owned(foreign, subject) if owned is not None else self._container_read(foreign, subject) + ): + return False + proved += 1 + continue + if (resolution.module is not None and not resolution.caveats + and isinstance(resolution.value, ast.List | ast.Tuple) + and all(isinstance(item, ast.Constant) or ( + isinstance(item, ast.Name | ast.Attribute) + and (member := self.calls.resolve(resolution.module, item)).definition is not None + and not member.caveats + ) for item in resolution.value.elts)): + continue # Another literal retains no shared list or namespace. + if not carriers: + continue + spelling = reference_spelling(node) + head = spelling.split(".", 1)[0] if spelling else None + for binding, statement in lookup(head, node) if head else (): + if not isinstance(binding, ast.alias) or not isinstance(statement, ast.Import | ast.ImportFrom): + continue + if self.calls.import_may_share(module, view.module, names[0], statement, binding, namespace_carriers=True): + # A direct callable still carries its globals. + # Only an owned receiving edge clears this exact + # imported namespace-carrier refusal. + if (resolution.resolved and not resolution.caveats + and isinstance(resolution.definition, ast.FunctionDef | ast.AsyncFunctionDef) + and self._container_read(foreign, node)): + continue + return False # An unread namespace can retain or project a member. + return owned is None or not require_use or proved > 0 + except (CallLimit, _Stop) as exc: + self._constructor_limit((getattr(exc, "detail", str(exc)),)) + return False + + def _shape_value(self, view: _View, node: ast.expr, depth: int) -> tuple[ast.expr, _View] | None: + """Find a literal container shape without re-entering ownership checks.""" + if depth > MAX_DEPTH: + return None + lookup = view.lookup or bindings_at(view.scopes, view.bindings) + if isinstance(node, ast.Name): + bindings = lookup(node.id, node) + if len(bindings) == 1: + value = getattr(bindings[0][1], "value", None) + if isinstance(value, ast.expr): + return self._shape_value(view, value, depth + 1) + return None + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "get": + return node, view + if isinstance(node, ast.Call) and self.calls is not None and view.module is not None: + from agents_shipgate.inputs.builder_calls import CallLimit, CallSite, single_return + + resolution = self.calls.resolve(view.module, node.func) + if resolution.resolved and not resolution.caveats: + function = resolution.definition + returned = single_return(function) + if returned is not None and self._inert_factory(function): + try: + if self.calls.callers(resolution.module, function).limits: + return None + self.calls.invoke(resolution.module, function, CallSite(view.module, node), self._invocation) + except CallLimit: + return None + return self._shape_value(self._foreign(resolution.module), returned, depth + 1) + return None + return node, view + + @staticmethod + def _projection_base(node: ast.expr) -> ast.expr | None: + if isinstance(node, ast.Subscript): + return node.value + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "get": + return node.func.value + return None + + def _list_operand(self, view: _View, node: ast.expr, depth: int = 0, *, subject: ast.expr | None = None) -> bool: + shape = self._shape_value(view, node, depth) + if shape is None: + return False + node, view = shape + if isinstance(node, ast.List | ast.Tuple): + return True + if isinstance(node, ast.BoolOp): + return all(self._list_operand(view, value, depth + 1, subject=subject) for value in node.values) + if isinstance(node, ast.IfExp): + return self._list_operand(view, node.body, depth + 1, subject=subject) and self._list_operand(view, node.orelse, depth + 1, subject=subject) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + return self._list_operand(view, node.left, depth + 1, subject=subject) and self._list_operand(view, node.right, depth + 1, subject=subject) + base, key, default = None, None, None + if isinstance(node, ast.Subscript) and isinstance(node.slice, ast.Constant): + base, key = node.value, node.slice.value + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "get" and len(node.args) in {1, 2} and not node.keywords: + if isinstance(node.args[0], ast.Constant): + base, key = node.func.value, node.args[0].value + default = node.args[1] if len(node.args) == 2 else None + if base is None or not isinstance(key, str): + return False + if isinstance(base, ast.Name): + same = self._projection_base(subject) if subject is not None else None + lookup = view.lookup or bindings_at(view.scopes, view.bindings) + if not (isinstance(same, ast.Name) and same.id == base.id and lookup(same.id, same) == lookup(base.id, base)): + # Only the dictionary whose ownership this strict walk is + # checking can lend its shape through a retained projection. + return False + elif not isinstance(base, ast.Call): + return False + dictionary = self._shape_value(view, base, depth + 1) + if dictionary is None or not isinstance(dictionary[0], ast.Dict): + return False + literal, home = dictionary + keys = [item.value for item in literal.keys if isinstance(item, ast.Constant) and isinstance(item.value, str)] + if len(keys) != len(literal.keys) or len(set(keys)) != len(keys): + return False + if key in keys: + return self._list_operand(home, literal.values[keys.index(key)], depth + 1) + return default is not None and self._list_operand(view, default, depth + 1) + + def _factory_member_change( + self, view: _View, function: ast.FunctionDef | ast.AsyncFunctionDef, result: ListResolution, + *, caller_defaults: set[tuple[int, int]] | frozenset = frozenset(), + ) -> str | None: + """Check canonical callable ownership across its bounded borrower scope. + + The fresh container does not own a fresh function. A caller, sibling + importer or helper may still retain and change the same callable. + """ + calls = self.calls + if calls is None: + return "the returned callable ownership census is unavailable" + targets: dict[tuple[int, int], Resolution] = {} + for member in result.members: + origin = member.module or self.entry.module + resolution = calls.resolve(origin, member.expr) if origin is not None else None + if resolution is None or not resolution.resolved or resolution.caveats: + return "the returned or caller-default callable ownership is not established" + if (id(member.expr), id(member.module)) not in caller_defaults: + local = member.module is view.module or (member.module is None and view.entry) + if not local or not isinstance(member.expr, ast.Name): + return "the returned factory member's lexical callable ownership is not read by this increment" + targets[id(resolution.module), id(resolution.definition)] = resolution + key = (id(function), tuple(sorted(targets))) + if key not in self._cache.callable_changes: + self._cache.callable_changes[key] = self._retained_callable_changes(function, targets) + changed = self._cache.callable_changes[key] + if changed is not None: + return changed + # Empty projections still depend on the factory's live namespace. + # A helper/class can retain that namespace even when no selected + # member remains to seed the returned-callable census. + factory = Resolution(reference=function.name, module=view.module, definition=function) + key = ("factory namespace", id(function)) + if key not in self._cache.callable_changes: + self._cache.callable_changes[key] = self._retained_callable_changes( + function, {(id(view.module), id(function)): factory}, factory_calls=True, + ) + return self._cache.callable_changes[key] + + def _executable_machinery(self, view: _View) -> bool: + """Follow existing import evidence for executable/reflection machinery.""" + key = id(view.tree) + if key in self._cache.executable_modules: + return self._cache.executable_modules[key] + # A dependency cycle cannot serve as an absent-machinery proof. + self._cache.executable_modules[key] = True + names = {"eval", "exec", "compile", "__import__", "__builtins__", "__globals__", "__closure__", "cell_contents", "__getattribute__", "__getattr__", "getattr", "setattr", "delattr"} + for node in ast.walk(view.tree): + self._visits += 1 + if self._visits > MAX_VISITS: + return True + if replaces_builtin_namespace(node, view.scopes): + return True + if isinstance(node, ast.Name | ast.Attribute) and isinstance(node.ctx, ast.Load): + spelling = reference_spelling(node) + if spelling and spelling.rsplit(".", 1)[-1] in names: + return True + unused_reflection = ( + self._resolver is not None and view.module is not None + and _unused_reflection_import(self._resolver, view.module, node, self.calls) + ) + if isinstance(node, ast.ImportFrom) and node.module == "builtins" and any(alias.name in names or alias.name == "*" for alias in node.names): + if not unused_reflection: + return True + if isinstance(node, ast.Import) and any(alias.name == "builtins" for alias in node.names): + if not unused_reflection: + return True + if self._resolver is None or self.calls is None or view.module is None: + continue + dependencies: set[str] = set() + if isinstance(node, ast.Name | ast.Attribute): + dependencies.update(step["path"] for step in self.calls.resolve(view.module, node).steps) + if isinstance(node, ast.Import | ast.ImportFrom): + try: + containers = ( + [self._resolver._from_base(view.module, node)] if isinstance(node, ast.ImportFrom) + else [self._resolver._absolute(view.module, alias.name) for alias in node.names] + ) + dependencies.update(self._resolver.ref(container.module_path) for container in containers if container.module_path is not None) + except _Stop as exc: + if exc.reason != MODULE_NOT_FOUND: + return True + for ref in dependencies - {view.ref}: + try: + module = self._resolver.module(self._resolver.scope_root / ref) + except (_Stop, ValueError): + return True + if self._executable_machinery(self._foreign(module)): + return True + self._cache.executable_modules[key] = False + return False + + def _retained_callable_changes( + self, function: ast.FunctionDef | ast.AsyncFunctionDef, targets: dict[tuple[int, int], Resolution], + *, factory_calls: bool = False, factory_return: bool = True, + ) -> str | None: + from agents_shipgate.inputs.builder_calls import CallLimit + + calls = self.calls + assert calls is not None + for target in targets.values(): + home, defining = target.module, target.definition + assert home is not None and defining is not None + try: + borrowers = calls.borrowers(home, defining.name, namespace_carriers=True) + retaining = calls.retaining_modules(home, defining.name, namespace_carriers=True) + except CallLimit as exc: + return f"the returned callable ownership census is not established: {exc}" + for borrower in borrowers: + foreign = self._foreign(borrower) + lookup = foreign.lookup or bindings_at(foreign.scopes, foreign.bindings) + if self._reflection(foreign) is not None or calls.dynamic_importer(borrower) is not None or self._executable_machinery(foreign): + return "a returned callable's borrower has reflection, executable text, dynamic imports or unresolved namespace machinery" + for node in ast.walk(borrower.tree): + self._visits += 1 + if self._visits > MAX_VISITS: + return "the returned callable ownership guard is larger than the reader follows" + if not isinstance(node, ast.Name | ast.Attribute): + continue + resolution = calls.resolve(borrower, node) + owned = resolution.resolved and ( + resolution.module is home and resolution.definition is defining + ) + parent = foreign.scopes.parents.get(node) + if self._checking_constructor_namespaces and self._resolver is not None and isinstance(parent, ast.keyword) and parent.arg == "is_enabled": + decorator = foreign.scopes.parents.get(parent) + owner = foreign.scopes.parents.get(decorator) + recorded = self._resolver._constructor_decorator_owners.get(id(owner)) + if recorded is not None and recorded == (borrower, owner) and decorator in owner.decorator_list: + continue # The outer census owns this exact SDK callback edge. + if owned and factory_calls and isinstance(parent, ast.Call) and parent.func is node: + continue # Every direct result is checked by the factory caller census. + spelling = reference_spelling(node) + head = spelling.split(".", 1)[0] if spelling else None + namespace = resolution.resolved and resolution.module.path in retaining + if not resolution.resolved and borrower.path in retaining and head: + namespace = any(isinstance(binding, ast.ClassDef) for binding, _ in lookup(head, node)) + if namespace and not owned and resolution.resolved and isinstance(parent, ast.Call) and parent.func is node: + continue # Its read body is in the same bounded borrower census. + if not owned and not namespace: + # A namespace may retain the defining module through a + # bridge or parent package. Resolve its import identity, + # not only the terminal module reported by resolve(). + if not resolution.resolved and not ( + isinstance(parent, ast.Attribute) and parent.value is node + ): + spelling = reference_spelling(node) + head = spelling.split(".", 1)[0] if spelling else None + for binding, statement in lookup(head, node) if head else (): + if isinstance(binding, ast.alias) and isinstance(statement, ast.Import | ast.ImportFrom): + try: + retained = calls.import_may_share(borrower, home, defining.name, statement, binding, namespace_carriers=True) + except CallLimit as exc: + return f"the returned callable namespace census is not established: {exc}" + if retained: + return f"a namespace retaining a returned callable is used opaquely at {self._where(foreign, node)}" + continue + current: ast.AST = node + while isinstance(foreign.scopes.parents.get(current), ast.List | ast.Tuple | ast.Dict | ast.Starred | ast.BinOp | ast.BoolOp | ast.IfExp): + current = foreign.scopes.parents[current] + outer = foreign.scopes.parents.get(current) + if factory_return and isinstance(outer, ast.Return) and outer is function.body[-1]: + continue + if current is not node and isinstance(current, ast.expr) and self._list_operand(foreign, current, subject=node): + if self._container_read(foreign, current): + continue + if ( + isinstance(outer, ast.Call) and len(outer.args) == 2 and outer.args[1] is current + and isinstance(outer.func, ast.Attribute) and outer.func.attr == "get" + and self._nonexecuting_default(foreign, current) and self._container_read(foreign, outer) + ): + continue + spelling = reference_spelling(node) or _source(node) + return f"the returned callable {spelling!r} is invoked, changed or handed on at {self._where(foreign, node)}" + return None def _name(self, node: ast.Name, view: _View, depth: int, seen: frozenset) -> ListResolution: name = node.id @@ -1800,6 +3606,10 @@ def _assigned( return self._stop( view, statement, f"`{name}` may be changed in place after it is built" ) + if self._projection is not None and self._container_binding_changed(view, binding): + return self._stop(view, statement, f"`{name}` or one of its projected values may be changed or handed on") + if self._projection is not None and statement in view.tree.body and isinstance(statement.value, ast.Dict): + return self._stop(view, statement, "module-owned dictionary projection is not an established fresh factory container") if view.module is not None and statement in view.tree.body: limit = self._shared_list_limit(view.module, name, view, node) if limit is not None: @@ -1815,7 +3625,10 @@ def _value_of( ) -> ListResolution: """A name's value, read once however many lists spread it.""" - memo = (key[0], view.entry, key[1], self._invocation.key if self._invocation else ()) + memo = ( + key[0], view.entry, key[1], self._invocation.key if self._invocation else (), + self._projection.identity if self._projection else (), + ) cached = self._cache.memo.get(memo) if cached is None: cached = self._resolve(value, view, depth + 1, seen | {key}) @@ -1843,6 +3656,8 @@ def _imported_local( return self._from_resolution(node, node.id, resolution, view, depth, seen) def _from_resolution(self, node, spelling, resolution, view, depth, seen) -> ListResolution: + if self._projection is not None: + return self._stop(view, node, "an imported dictionary container's projected ownership is not read by this increment") if resolution.definition is not None: return self._stop(view, node, f"`{spelling}` is a function, not a list of tools") value, defining = resolution.value, resolution.module @@ -1883,7 +3698,8 @@ def _from_resolution(self, node, spelling, resolution, view, depth, seen) -> Lis return self._stop( view, node, - f"`{name}` in {foreign.ref} may be changed in place at {other.ref}:{changer}", + f"`{name}` in {foreign.ref} may be changed in place at {other.ref}:{changer}" + + self._change_refusal_suffix(other, defining, name), ) statement = bindings[0].statement key = (id(foreign.tree), id(statement)) @@ -1915,7 +3731,8 @@ def _shared_list_limit( return self._stop( view, node, - f"`{name}` in {defining.ref} may be changed in place at {other.ref}:{changer}", + f"`{name}` in {defining.ref} may be changed in place at {other.ref}:{changer}" + + self._change_refusal_suffix(other, defining, name), ) except CallLimit as exc: return self._stop(view, node, f"`{name}` has an incomplete shared-list census: {exc}") @@ -1957,10 +3774,23 @@ def _changed_through(self, view: _View, defining: PythonModule, name: str) -> in return None key = (id(view.tree), view.entry, id(defining.tree), name) if key not in self._cache.changes: - self._cache.changes[key] = self._first_change_reaching(view, defining, name) + selected: list[tuple[ast.alias, ast.stmt]] = [] + self._cache.changes[key] = self._first_change_reaching(view, defining, name, selected_import=selected) + self._cache.change_contexts[key] = ( + view.changed_import_context.get(selected[0], ()) + if self._cache.changes[key] is not None and selected else () + ) return self._cache.changes[key] - def _first_change_reaching(self, view: _View, defining: PythonModule, name: str) -> int | None: + def _change_refusal_suffix(self, view: _View, defining: PythonModule, name: str) -> str: + key = (id(view.tree), view.entry, id(defining.tree), name) + rows = self._cache.change_contexts.get(key, ()) + return " Additional constructor refusal context: " + "; ".join(rows) if rows else "" + + def _first_change_reaching( + self, view: _View, defining: PythonModule, name: str, *, + selected_import: list[tuple[ast.alias, ast.stmt]] | None = None, + ) -> int | None: """An import changed in ``view`` that is the list, or the module holding it.""" assert self._resolver is not None and view.module is not None @@ -1976,11 +3806,16 @@ def _first_change_reaching(self, view: _View, defining: PythonModule, name: str) if self.calls is not None else ((name,), frozenset()) ) + def selected_line(alias: ast.alias, statement: ast.stmt) -> int: + if selected_import is not None: + selected_import.append((alias, statement)) + return statement.lineno + for alias, statement in view.changed_imports: local = alias.asname or alias.name.split(".", 1)[0] direct = self._resolver.resolve_local_import(view.module, statement, alias, local) if direct.module is defining and _value_name(direct) == name: - return statement.lineno + return selected_line(alias, statement) if ( direct.module is not None and not direct.caveats @@ -2029,12 +3864,12 @@ def _first_change_reaching(self, view: _View, defining: PythonModule, name: str) view.module, statement, alias, spelling ) if resolution.module is defining and _value_name(resolution) == name: - return statement.lineno + return selected_line(alias, statement) if possible and resolution.reason == NOT_A_FUNCTION: # A namespace can reach another namespace under a renamed # export. This imported binding was marked as possibly # mutated, including opaque consumers and computed access. - return statement.lineno + return selected_line(alias, statement) if ( possible and resolution.reason is not None @@ -2049,7 +3884,7 @@ def _first_change_reaching(self, view: _View, defining: PythonModule, name: str) # An ambiguous/unread changed import may be this list. # Failure to establish its identity is not proof it cannot # mutate a holder elsewhere. - return statement.lineno + return selected_line(alias, statement) return None def _attribute(self, node: ast.Attribute, view: _View, depth: int, seen: frozenset) -> ListResolution: diff --git a/src/agents_shipgate/inputs/openai_sdk_static.py b/src/agents_shipgate/inputs/openai_sdk_static.py index baa7f44f0..0c7d424ed 100644 --- a/src/agents_shipgate/inputs/openai_sdk_static.py +++ b/src/agents_shipgate/inputs/openai_sdk_static.py @@ -15,7 +15,11 @@ ToolkitScopeBound, ) from agents_shipgate.core.errors import InputParseError -from agents_shipgate.inputs.builder_calls import BuilderCalls, ConstructionContext +from agents_shipgate.inputs.builder_calls import ( + BuilderCalls, + ConstructionContext, + Invocation, +) from agents_shipgate.inputs.common import ( list_input_directory, load_text_file, @@ -253,7 +257,11 @@ def module_agent_reads(home: PythonModule, call: ast.Call, keyword: str | None) names = module_sdk_names.get(id(home.tree)) if names is None: names = module_sdk_names[id(home.tree)] = _SdkNames(home.tree) - return _agent_reads(names, builder_calls.scopes(home), home.bindings, call, keyword) + return ( + _agent_reads(names, builder_calls.scopes(home), home.bindings, call, keyword) + and (not _denotes_agent(names, call) + or builder_calls.constructor_issue(home, call) is None) + ) for path in paths: text = load_text_file(path) @@ -319,7 +327,8 @@ def identity_of( resolver=imports.resolver if module is not None else None, agent_reads=lambda call, keyword, sdk_names=sdk_names, scopes=scopes, module_bindings=module_bindings, module=module: module is not None and _agent_reads( sdk_names, scopes, module_bindings, call, keyword - ), + ) and (not _denotes_agent(sdk_names, call) + or builder_calls.constructor_issue(module, call) is None), cache=list_cache if module is not None else None, builder_calls=builder_calls, module_agent_reads=module_agent_reads, @@ -443,11 +452,18 @@ def unread_agent( warnings.extend(context.limits) tools_complete = not context.limits constructor_changed = lists.constructor_changed(call, context.invocation) - if constructor_changed: - reason = ( - f"OpenAI Agents SDK agent {target!r}: its builder's constructor or " - "imports may change its capability lists." - ) + constructor_context = lists.constructor_refusal_context(call, context.invocation) + constructor_issue = builder_calls.constructor_issue(module, call) if module is not None else None + if constructor_issue is None and constructor_changed: + constructor_issue = "its builder's constructor or imports may change its capability lists" + if constructor_context: + constructor_issue += ". Additional constructor refusal context: " + "; ".join(constructor_context) + if constructor_issue is None and lists.constructor_namespace_changed(): + constructor_issue = lists.constructor_namespace_issue or "an agent handle may change or hand on a tool carrying the constructor namespace" + constructor_issues = {} + if constructor_issue is not None: + reason = f"OpenAI Agents SDK agent {target!r} at {pointer}: its constructor identity is not established: {constructor_issue}." + constructor_issues[pointer] = reason issues.append(reason) warnings.append(reason) tools_complete = False @@ -459,6 +475,7 @@ def unread_agent( names: list[str] = [] locators: dict[str, str] = {} tool_issues: dict[str, str] = {} + tool_sites: dict[str, list[str]] = {} when = Conditions() #: ``name -> payload`` of the tools this agent binds as objects (#910). objects: dict[str, dict[str, Any]] = {} @@ -477,9 +494,32 @@ def unread_agent( # sees one name for both, so neither is bound (#879 review). duplicated: set[str] = set() first_location: dict[str, str] = {} + + def note_sites( + name: str, + invocation: Invocation | None, + *, + pointer: str = pointer, + context: ConstructionContext = context, + tool_sites: dict[str, list[str]] = tool_sites, + ) -> None: + # Where the binding is made: the construction, its caller's + # sites and the returned list's factory call (#964). + if context.invocation is None and invocation is None: + return + locations = tool_sites.setdefault(name, []) + for location in ( + pointer, + *(context.invocation.locations if context.invocation else ()), + *(invocation.locations if invocation else ()), + ): + if location not in locations: + locations.append(location) + def bind_object( found: ObjectBinding, conditions: tuple[str, ...], + invocation: Invocation | None = None, *, target: str = target, pointer: str = pointer, @@ -517,6 +557,7 @@ def bind_object( tool_issues.pop(name, None) return False objects[name] = payload + note_sites(name, invocation) when.add(name, conditions) return True @@ -529,7 +570,7 @@ def bind_object( else None ) if isinstance(object_found, ObjectBinding): - if not bind_object(object_found, member.conditions): + if not bind_object(object_found, member.conditions, member.invocation): tools_complete = False continue if isinstance(object_found, Unread): @@ -654,6 +695,7 @@ def bind_object( objects.pop(tool.name, None) continue names.append(tool.name) + note_sites(tool.name, member.invocation) when.add(tool.name, member.conditions) if locator is not None: locators[tool.name] = locator @@ -668,9 +710,8 @@ def bind_object( ) warnings.append(reason) tool_issues[tool.name] = reason - tool_conditions = when.only_when(duplicated) handoff_list = lists.resolve(_keyword(call, "handoffs"), invocation=context.invocation) - handoffs_complete = not context.limits and not constructor_changed + handoffs_complete = not context.limits and constructor_issue is None if lists.construction_changed(context.invocation, "handoffs"): reason = f"OpenAI Agents SDK agent {target!r}: the caller's returned agent handle may change its handoffs." issues.append(reason) @@ -710,6 +751,12 @@ def bind_object( warnings.append(reason) issues.append(reason) handoffs_complete = False + # A refused identity still names this source's possible + # edge. Keep only an actual local Name from the same + # captured module; this never makes its identity complete. + if module is not None and member.module is module and isinstance(member.expr, ast.Name): + handoff_names.append(member.expr.id) + handoff_when.add(member.expr.id, member.conditions) continue handoff_names.append(identity) handoff_when.add(identity, member.conditions) @@ -738,7 +785,7 @@ def bind_object( home = member.module if member.module is not None else module server = objects_reader.recognize(member.expr, home) if isinstance(server, ObjectBinding) and server.identity.get("kind") == "mcp_server": - if not bind_object(server, member.conditions): + if not bind_object(server, member.conditions, member.invocation): tools_complete = False continue spelled = reference_spelling(member.expr) @@ -751,6 +798,12 @@ def bind_object( warnings.append(reason) issues.append(reason) tools_complete = False + if constructor_issue is not None: + # Every binding of an agent whose constructor is not proved, + # a tool bound as an object included (#910, #964). + tool_issues.update({name: "; ".join(filter(None, [tool_issues.get(name), + f"the constructor identity is not established: {constructor_issue}"])) + for name in (*names, *objects)}) tool_conditions = when.only_when(duplicated) file_observations.append( AgentBindingObservation( @@ -763,8 +816,7 @@ def bind_object( tool_locators=locators, tool_issues=tool_issues, tool_conditions=tool_conditions, - tool_sites={name: list(dict.fromkeys((pointer, *context.invocation.locations))) - for name in (*locators, *objects)} if context.invocation else {}, + tool_sites={name: locations for name, locations in tool_sites.items() if name in locators or name in objects}, handoff_names=handoff_names, handoff_sites={name: list(dict.fromkeys((pointer, *context.invocation.locations))) for name in handoff_names} if context.invocation else {}, @@ -772,6 +824,7 @@ def bind_object( tools_complete=tools_complete, handoffs_complete=handoffs_complete, issues=issues, + constructor_issues=constructor_issues, ) ) for node in copies: diff --git a/src/agents_shipgate/inputs/python_imports.py b/src/agents_shipgate/inputs/python_imports.py index f09340414..c4e9a1ef8 100644 --- a/src/agents_shipgate/inputs/python_imports.py +++ b/src/agents_shipgate/inputs/python_imports.py @@ -36,11 +36,12 @@ import hashlib import stat import sys -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Mapping from contextlib import contextmanager from contextvars import ContextVar from dataclasses import dataclass, field from pathlib import Path, PurePosixPath +from types import MappingProxyType from typing import Any from agents_shipgate.core.errors import InputParseError @@ -100,6 +101,10 @@ class RepositoryLayout: #: The text of one repository file outside the scope, or None: what the #: packages enclosing the scope run first (#879 review). read: Callable[[str], str | None] = lambda path: None + #: Actual disk root, only for layouts built by the disk reader. Tree-backed + #: callbacks need their own input binding; absence is not inferred from a + #: coincidentally matching temporary directory. + disk_root: Path | None = None _REPOSITORY: ContextVar[RepositoryLayout | None] = ContextVar("repository_layout", default=None) @@ -172,7 +177,7 @@ def read(path: str) -> str | None: return None scope = scope_root.relative_to(root).as_posix() - return RepositoryLayout("" if scope == "." else scope, entries, links, read) + return RepositoryLayout("" if scope == "." else scope, entries, links, read, disk_root=root) _SCOPE_NODES = ( ast.FunctionDef, @@ -289,6 +294,17 @@ class _PatchScan: patched: dict[str, list[tuple[str, int, frozenset[Path] | None]]] #: Imports that climb above the read scope, whose code is not read. unread: tuple[str, ...] + #: Actual table-row module provenance only; never a module safety verdict. + table_modules: dict[tuple[str, str, int], tuple[PythonModule, ...]] = field(default_factory=dict) + + +@dataclass(frozen=True) +class _ConstructorSyntax: + """Ordered actual syntax only; no identity, purity or ownership result.""" + + nodes: tuple[ast.AST, ...] + imports: tuple[ast.Import | ast.ImportFrom, ...] + references: tuple[ast.Name | ast.Attribute, ...] @dataclass @@ -306,11 +322,351 @@ class ImportResolver: _layout_modules: dict[str, PythonModule | None] = field(default_factory=dict) #: Repository files the read bound left unread. _over_budget: set[str] = field(default_factory=set) + _constructor_reference_mode: bool = False + _import_search_captured: Mapping[Path, str] | None = None + _checking_source_module_slot: bool = False + _checking_fresh_dictionary_primitive: bool = False + _constructor_namespace_owners: dict[tuple[str, int, str], tuple[PythonModule, ast.Call, str]] = field(default_factory=dict) + _constructor_container_owners: dict[int, tuple[PythonModule, ast.expr]] = field(default_factory=dict) + _constructor_member_sinks: dict[int, tuple[PythonModule, ast.expr]] = field(default_factory=dict) + _constructor_dictionary_sinks: dict[tuple[str, int], tuple[PythonModule, ast.expr, str]] = field(default_factory=dict) + _constructor_dictionary_class_sinks: dict[tuple[str, int], tuple[PythonModule, ast.expr, str, str]] = field(default_factory=dict) + _constructor_unused_namespace_sinks: dict[int, tuple[PythonModule, ast.Name]] = field(default_factory=dict) + _constructor_decorator_owners: dict[int, tuple[PythonModule, ast.FunctionDef | ast.AsyncFunctionDef]] = field(default_factory=dict) + _constructor_wrapped_operands: set[int] = field(default_factory=set) + #: Every module the constructor census reads, set before it runs. + _constructor_runners: tuple[Path, ...] = () + _annotation_readers: list[str | None] = field(default_factory=list) + _constructor_import_proofs: dict[tuple, str | None] = field(default_factory=dict) + _external_provider_proofs: dict[tuple[int, str, tuple[str, ...]], str | None] = field(default_factory=dict) + _runtime_patch_maps: dict[int, dict[str, int]] = field(default_factory=dict) + _reflection_import_loads: dict[ast.Module, frozenset[str]] = field(default_factory=dict) + _reflection_import_importers: dict[tuple[ast.Module, str], bool] = field(default_factory=dict) + #: The caller census that answers "does another module import this one", + #: made on first use and shared by every such question in this read. + _importer_census: Any = None + #: Import-search results, per this read. The key holds the parsed trees, + #: which keeps their identity alive; see :meth:`_import_search_limit`. + _import_search_limits: dict[tuple[tuple[Path, ast.Module], ...], tuple[str, ...]] = field(default_factory=dict) + _import_search_issues: dict[tuple[ast.Module, frozenset[Path]], str | None] = field(default_factory=dict) + _constructor_scopes: dict[ast.Module, ScopeIndex] = field(default_factory=dict) + _constructor_syntax_trees: dict[ast.Module, _ConstructorSyntax] = field(default_factory=dict) + + def _constructor_scope(self, module: PythonModule) -> ScopeIndex: + """Reuse structural AST roles only; never cache an ownership verdict.""" + if module.tree not in self._constructor_scopes: + self._constructor_scopes[module.tree] = ScopeIndex(module.tree) + return self._constructor_scopes[module.tree] + + def _constructor_syntax(self, module: PythonModule) -> _ConstructorSyntax: + """Reuse the actual immutable parse; all semantic checks stay fresh.""" + cached = self._constructor_syntax_trees.get(module.tree) + if cached is None: + nodes = tuple(ast.walk(module.tree)) + scopes = self._constructor_scope(module) + cached = _ConstructorSyntax( + nodes, + tuple(node for node in nodes if isinstance(node, ast.Import | ast.ImportFrom)), + tuple(node for node in nodes if isinstance(node, ast.Name | ast.Attribute) + and (not isinstance(node, ast.Name) or isinstance(node.ctx, ast.Load)) + and not (isinstance(parent := scopes.parents.get(node), ast.Attribute) + and parent.value is node)), + ) + self._constructor_syntax_trees[module.tree] = cached + return cached + + def _runtime_attribute_patches(self, module: PythonModule) -> dict[str, int]: + """Executed stores, with lexical bindings and raw flag stores intact. + + Recompute from nodes rather than filtering the stored first line: a + dead first store must not hide a later live store of the same key. + The cache belongs to this resolver's provider/search-root context. + """ + if not module.attribute_patches: + return module.attribute_patches + key = id(module.tree) + if key not in self._runtime_patch_maps: + excluded = frozenset(_type_checking_only(module, self)) + self._runtime_patch_maps[key] = _attribute_patches(module.tree, excluded=excluded) + return self._runtime_patch_maps[key] + + def _import_search_module_guard(self, path: Path) -> None: + """A captured context never borrows another source or another AST.""" + if self._import_search_captured is None: + return + expected = self._import_search_captured.get(path) + captured = self._modules.get(path) + if expected is None or not isinstance(captured, PythonModule) or captured.text != expected: + raise _Stop(OUTSIDE_SCOPE, f"{path} is not the text-accounted import-search source") + + def _import_search_context( + self, paths: list[Path], + ) -> tuple[list[PythonModule], set[Path], tuple[str, ...]]: + """One bounded startup import graph, separate from patch-target rules. + + The carrier set is structural provenance, not a purity/owner verdict. + It is local to this invocation and never cached as a positive result. + """ + from collections import deque + modules: list[PythonModule] = [] + pending = deque() + seen: set[Path] = set() + reverse: dict[Path, set[Path]] = {} + carriers: set[Path] = set() + unread: list[str] = [] + + def enqueue(path: Path) -> None: + if path in seen: + return + if len(seen) >= MAX_PATCH_SCAN_MODULES: + raise _Stop(RESOLUTION_LIMIT, "the import-search closure exceeds its module bound") + seen.add(path) + pending.append(path) + + for path in paths: + for item in (path, *self._enclosing_packages(path)): + enqueue(item) + while pending: + path = pending.popleft() + module = self._patch_scan(path) + modules.append(module) + scopes = self._constructor_scope(module) + typing_only = _type_checking_only(module, self, scopes) + guarded = _import_guarded(module.tree) + for statement in ast.walk(module.tree): + if id(statement) in typing_only or not isinstance(statement, ast.Import | ast.ImportFrom): + continue + for alias in statement.names: + imported = _absolute_import_reference(alias, statement) + if imported is not None and (imported in {"sys", "site"} + or imported.startswith("site.") + or imported.split('.', 1)[0] == "sys" and imported.split('.', 1)[-1] in { + "path", "meta_path", "path_hooks", "path_importer_cache", + "__dict__", "__class__", "__getattribute__", "__getattr__", "__setattr__", + }): + carriers.add(path) + imported_paths, missing = self._imported_paths(module, statement) + for stop, spelling, names in missing: + caveat = self._unread_import(module, statement, stop, spelling, names, + id(statement) in guarded) + if caveat is not None and caveat not in unread: + unread.append(caveat) + for target in imported_paths: + for item in (target, *self._enclosing_packages(target)): + reverse.setdefault(item, set()).add(path) + enqueue(item) + propagation = deque(sorted(carriers)) + while propagation: + target = propagation.popleft() + for importer in sorted(reverse.get(target, set())): + if importer not in carriers: + carriers.add(importer) + propagation.append(importer) + return modules, carriers, tuple(unread) + + def _import_search_budget_spent(self) -> bool: + """Whether any read bound has been reached, so a result may have been cut short.""" + return ( + self._parsed >= MAX_MODULES + or len(self._scanned) >= MAX_PATCH_SCAN_MODULES + or len(self._layout_modules) >= MAX_PATCH_SCAN_MODULES + or bool(self._over_budget) + ) + + def _import_search_issue(self, module: PythonModule, carriers: set[Path]) -> str | None: + """One module's import-search refusal, if any, computed once per read. + + The result depends only on the parsed module and on which modules + carry ``sys``/``site``, never on which runner list asked: a resolver + owns immutable parse trees for one read, so the pair is the key. + Nothing is stored once any read bound has been reached, nor for a + refusal that is itself a bound, because a later call must meet the + same limit again rather than a remembered answer. + """ + key = (module.tree, frozenset(carriers)) + if key in self._import_search_issues: + return self._import_search_issues[key] + issue = self._import_search_issue_uncomputed(module, carriers) + if not self._import_search_budget_spent() and (issue is None or "exceeds" not in issue): + self._import_search_issues[key] = issue + return issue + + def _import_search_issue_uncomputed(self, module: PythonModule, carriers: set[Path]) -> str | None: + """Project actual maximal references without public resolution recursion.""" + from agents_shipgate.inputs.list_expressions import evaluation_site + scopes = self._constructor_scope(module) + excluded = frozenset(_type_checking_only(module, self, scopes)) + effects = _import_search_effects(module.tree, excluded=excluded, scopes=scopes) + if effects: + node = next(iter(effects)) + return f"{module.ref}:{node.lineno} changes or retains {effects[node]}; import search identity is unread" + projections: dict[ast.AST, str] = {} + work = 0 + for node in ast.walk(module.tree): + if id(node) in excluded or not isinstance(node, ast.Name | ast.Attribute): + continue + parent = scopes.parents.get(node) + if isinstance(parent, ast.Attribute) and parent.value is node: + continue + parts = _dotted(node) + if parts is None: + continue + candidates = scopes.enclosing_bindings(evaluation_site(scopes, node), parts[0]) + if not candidates: + candidates = [binding.node for binding in module.bindings.get(parts[0], [])] + if not any(isinstance(candidate, ast.alias | ast.Name) for candidate in candidates): + continue + work += 1 + if work > MAX_STEPS * MAX_STEPS: + return f"{module.ref}:{node.lineno} exceeds the import-search reference bound" + current, reference = module, node + followed: set[tuple[ast.Module, ast.AST]] = set() + outcome: dict[str, Any] = {} + for _ in range(MAX_STEPS): + key = (current.tree, reference) + if key in followed: + return f"{module.ref}:{node.lineno} has cyclic import-search carrier evidence" + followed.add(key) + outcome = self._constructor_reference(current, reference, self._constructor_scope(current)) + value = outcome.get("value") + if not isinstance(value, ast.Name | ast.Attribute): + break + owner = outcome.get("module") + if not isinstance(owner, PythonModule): + return f"{module.ref}:{node.lineno} has unread import-search alias ownership" + current, reference = owner, value + else: + return f"{module.ref}:{node.lineno} exceeds the import-search alias bound" + handle = outcome.get("import_search_handle") + if isinstance(handle, str): + projections[node] = handle + continue + namespace = outcome.get("retained_namespace") + if isinstance(namespace, Path) and not isinstance(parent, ast.Expr): + if namespace in carriers or any(path.is_relative_to(namespace) for path in carriers): + return f"{module.ref}:{node.lineno} retains an imported sys/site namespace; import search identity is unread" + if outcome.get("constructor_unread") or not outcome: + for candidate in candidates: + statement = scopes.statement_of(candidate) + if not isinstance(candidate, ast.alias) or not isinstance(statement, ast.Import | ast.ImportFrom): + continue + paths, _ = self._imported_paths(module, statement) + if any(path in carriers for path in paths): + return f"{module.ref}:{node.lineno} has unread import-search carrier identity" + effects = _import_search_effects(module.tree, excluded=excluded, projections=projections, scopes=scopes) + if effects: + node = next(iter(effects)) + return f"{module.ref}:{node.lineno} changes or retains {effects[node]}; import search identity is unread" + return None + + def _import_search_limit(self, paths: list[Path]) -> tuple[str, ...]: + """The import-search caveats for one runner list, computed once per read. + + Keyed by each runner's path and parsed tree. A read that has reached + a bound is never stored (see :meth:`_import_search_issue`), and a + raised stop is never stored: it is raised again by the next call. + """ + try: + key = tuple((path, self._patch_scan(path).tree) for path in paths) + except _Stop: + key = None + if key is not None and key in self._import_search_limits: + return self._import_search_limits[key] + modules, carriers, unread = self._import_search_context(paths) + result = unread + for module in modules: + if (issue := self._import_search_issue(module, carriers)) is not None: + result = (*unread, issue) + break + if key is not None and not self._import_search_budget_spent(): + self._import_search_limits[key] = result + return result + + def _constructor_reference(self, module: PythonModule, node: ast.expr, scopes: ScopeIndex) -> dict[str, Any]: + """Use the import resolver for identity only, stopping at external imports. + + These private outcomes never become tool resolutions or evidence. The + ordinary resolver still requires a repository-local definition. + """ + parts = _dotted(node) + if parts is None: + return {} + previous = self._constructor_reference_mode + from agents_shipgate.inputs.list_expressions import evaluation_site + self._constructor_reference_mode = True + try: + local = scopes.enclosing_bindings(evaluation_site(scopes, node), parts[0]) + if local: + if len(local) == 1 and isinstance(local[0], ast.FunctionDef | ast.AsyncFunctionDef): + return {"module": module, "definition": local[0], "constructor_callable": len(parts) == 1} + if len(local) == 1 and isinstance(local[0], ast.ClassDef): + return {"module": module, "retained_class": local[0]} + if len(local) == 1 and isinstance(local[0], ast.Name): + statement = scopes.statement_of(local[0]) + if isinstance(statement, ast.Assign | ast.AnnAssign) and len(parts) == 1: + return {"module": module, "value": statement.value} + if len(local) != 1 or not isinstance(local[0], ast.alias): + return {} + statement = scopes.statement_of(local[0]) + if not isinstance(statement, ast.Import | ast.ImportFrom): + return {} + return self._through_import(module, statement, local[0], parts, [], set()) + return self._in_module(module, parts, [], set()) + except _Stop as stop: + return {"constructor_unread": stop.reason, "detail": stop.detail, + **({"unread_external_handle": True} if stop.reason == MODULE_NOT_FOUND else {})} + finally: + self._constructor_reference_mode = previous def __post_init__(self) -> None: self.scope_root = self.scope_root.resolve() self._layout = _REPOSITORY.get() or _disk_layout(self.scope_root) + def _annotation_introspection(self) -> str | None: + """Where a module the constructor census reads can read a function's annotations. + + An eager annotation stores the class it names in the function's + ``__annotations__``. That is only a retained handle if something reads + the dictionary, so a framework class is accepted there only when no + module of the census names one of the ways to read it. ``inspect`` and + ``getattr`` are already refused as reflective machinery. + """ + if not self._annotation_readers: + found: str | None = None + for path in self._constructor_runners: + module = self._patch_scan(path) + for node in self._constructor_syntax(module).nodes: + name = (node.id if isinstance(node, ast.Name) else node.attr if isinstance(node, ast.Attribute) + else node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None) + if name in _ANNOTATION_READERS: + found = f"{module.ref}:{getattr(node, 'lineno', 0)}" + break + if found is not None: + break + self._annotation_readers.append(found) + return self._annotation_readers[0] + + def _unread_import_candidate(self, module: PythonModule, statement: ast.AST, alias: ast.alias) -> bool: + """Keep an actual unread import candidate without resolving its identity.""" + if not isinstance(statement, ast.Import | ast.ImportFrom): + return False + if isinstance(statement, ast.ImportFrom) and statement.level: + return False + dotted = statement.module if isinstance(statement, ast.ImportFrom) else alias.name + if (dotted or "").split(".", 1)[0] in _PRELOADED: + return True # A sibling file cannot replace the startup import. + thin = _single_import(statement, alias) + paths, missing = self._imported_paths(module, thin) + for stop, _, _ in missing: + if stop.reason != MODULE_NOT_FOUND: + raise _Stop(stop.reason, f"constructor import ownership is unread: {stop.detail}") + if any(stop.reason == MODULE_NOT_FOUND for stop, _, _ in missing): + return True + # A namespace portion supplies no regular module identity. An external + # regular package can win over it, and an absent member is not a read + # external object. A known local child file keeps its ordinary route. + return not paths and any(item.module_path is None for item in self._absolute_candidates(module, dotted or "")) + # -- modules --------------------------------------------------------------- def ref(self, path: Path) -> str: @@ -328,6 +684,11 @@ def entry(self, path: Path, tree: ast.Module, text: str) -> PythonModule | None: cached = self._modules.get(resolved) if isinstance(cached, PythonModule): return cached + scanned = self._scanned.get(resolved) if cached is None else None + if isinstance(scanned, PythonModule) and scanned.path == resolved and scanned.text == text: + # Keep actual AST roles when an externally parsed entry promotes the same captured source. + self._modules[resolved] = scanned + return scanned module = _module(resolved, self.ref(resolved), tree, text) self._modules[resolved] = module return module @@ -335,6 +696,7 @@ def entry(self, path: Path, tree: ast.Module, text: str) -> PythonModule | None: def module(self, path: Path) -> PythonModule: """Parse one in-scope module file, at most once; raise :class:`_Stop`.""" + self._import_search_module_guard(path) cached = self._modules.get(path) if isinstance(cached, _Stop): raise cached @@ -417,7 +779,7 @@ def _no_attribute_patch(self, module: PythonModule, parts: list[str]) -> None: """ for length in range(2, len(parts) + 1): - line = module.attribute_patches.get(".".join(parts[:length])) + line = self._runtime_attribute_patches(module).get(".".join(parts[:length])) if line is not None: raise _Stop( REBOUND_NAME, @@ -425,8 +787,149 @@ def _no_attribute_patch(self, module: PythonModule, parts: list[str]) -> None: f"{module.ref}:{line}", ) + def imported_constructor_issue( + self, module: PythonModule, expression: ast.expr, scopes: ScopeIndex, + *, contexts: tuple[PythonModule, ...] = (), + ) -> str | None: + from agents_shipgate.inputs.list_expressions import evaluation_site + reference = expression.value if isinstance(expression, ast.Subscript) else expression + # These are separate (sometimes lazy) evaluation frames, not the + # module import a spelling outside them would resolve to. Keep them + # unread rather than flattening an unsupported annotation scope. + child, current = reference, scopes.parents.get(reference) + argument_annotation = False + while current is not None: + if hasattr(ast, "TypeAlias") and isinstance(current, ast.TypeAlias): + return f"constructor identity in a deferred type-alias scope is not established at {module.ref}:{_line(reference)}" + if isinstance(current, ast.arg | ast.AnnAssign) and child is current.annotation: + if scopes.annotations_postponed: + return f"constructor identity in a postponed annotation is not established at {module.ref}:{_line(reference)}" + if isinstance(current, ast.AnnAssign): + block = scopes.parents.get(current) + while block is not None and not isinstance(block, ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef): + block = scopes.parents.get(block) + if isinstance(block, ast.FunctionDef | ast.AsyncFunctionDef): + return f"constructor identity in an unevaluated function-local annotation is not established at {module.ref}:{_line(reference)}" + if isinstance(current, ast.arg) and child is current.annotation: + argument_annotation = True + if (isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef) + and child is current.returns and scopes.annotations_postponed): + return f"constructor identity in a postponed annotation is not established at {module.ref}:{_line(reference)}" + if getattr(current, "type_params", ()): + if child in current.type_params: + return f"constructor identity in a deferred type-parameter scope is not established at {module.ref}:{_line(reference)}" + generic_annotation = ( + isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef) + and (child is current.returns or child is current.args and argument_annotation) + or isinstance(current, ast.ClassDef) and child in [*current.bases, *current.keywords] + ) + if generic_annotation: + return f"constructor identity in a generic annotation scope is not established at {module.ref}:{_line(reference)}" + child, current = current, scopes.parents.get(current) + spelling = reference_spelling(reference) + head = spelling.split(".", 1)[0] if spelling else "" + nodes = scopes.enclosing_bindings(evaluation_site(scopes, reference), head) or [binding.node for binding in module.bindings.get(head, [])] + key = (id(module.tree), spelling, tuple(id(node) for node in nodes), + tuple(sorted({id(home.tree) for home in contexts}))) + if key not in self._constructor_import_proofs: + self._constructor_import_proofs[key] = self._imported_constructor_issue(module, expression, scopes, contexts=contexts) + return self._constructor_import_proofs[key] + + def _imported_constructor_issue( + self, module: PythonModule, expression: ast.expr, scopes: ScopeIndex, + *, contexts: tuple[PythonModule, ...] = (), + ) -> str | None: + """Why a recognized external constructor is not its unchanged import. + + Recognition remains the framework reader's job. This checks the + binding and code the import runs without importing the framework. + Changing the class itself (``Agent.__init__ = other``) matters just as + much as replacing the module's ``Agent`` attribute. An external class + has no defining-module exemption for handing a local definition on. + """ + if isinstance(expression, ast.Subscript): + expression = expression.value + from agents_shipgate.inputs.list_expressions import evaluation_site + spelling = reference_spelling(expression) + if spelling is None: + return "the constructor is not a plain imported reference" + head = spelling.split(".", 1)[0] + local = scopes.enclosing_bindings(evaluation_site(scopes, expression), head) + nodes = local or [binding.node for binding in module.bindings.get(head, [])] + if not nodes or any(not isinstance(node, ast.alias) for node in nodes): + return f"the constructor root {head!r} is not bound only by framework imports" + canonical_paths, import_owners = set(), set() + for alias in nodes: + statement = scopes.statement_of(alias) + owner = scopes.parents.get(statement) + if not isinstance(owner, ast.Module | ast.FunctionDef | ast.AsyncFunctionDef): + return "the constructor import is conditional or is not in its lexical body" + import_owners.add(id(owner)) + imported = _absolute_import_reference(alias, statement) + if imported is None: + return "the constructor does not come from an absolute framework import" + canonical_paths.add(imported + spelling[len(head):]) + provider_issue = self._external_provider_issue(module, statement, alias) + if provider_issue is not None: + return provider_issue + if len(import_owners) != 1 or len(canonical_paths) != 1: + return "the constructor imports do not establish one lexical framework identity" + canonical = canonical_paths.pop() + if canonical.startswith(("agents.", "openai_agents.")): + family = canonical.split(".", 1)[0] + if canonical not in {f"{family}.Agent", f"{family}.agent.Agent"}: + return "the constructor import path is outside the supported SDK constructor paths" + elif canonical.startswith("google.adk."): + family = "google.adk" + else: + return "the constructor does not come from a supported framework import" + names = {head, spelling.rsplit(".", 1)[-1], "__init__", "__new__", "__call__"} + names.update(alias.name.rsplit(".", 1)[-1] for alias in nodes) + steps = [{"path": home.ref, "name": name} + for home in (module, *contexts) for name in sorted(names)] + try: + for patched, line in self._runtime_attribute_patches(module).items(): + if patched == spelling or patched.startswith(spelling + ".") or spelling.startswith(patched + "."): + return f"the constructor reference is changed by attribute in {module.ref}:{line}" + caveats = self._no_import_patch({"module": module}, steps, external_symbol=family) + except _Stop as stop: + return stop.detail + return "; ".join(caveats) or None + + def _external_provider_issue(self, module: PythonModule, statement: ast.AST, alias: ast.alias) -> str | None: + dotted = statement.module if isinstance(statement, ast.ImportFrom) else alias.name + names = [alias.name] if isinstance(statement, ast.ImportFrom) else [] + return self._external_provider_path_issue(module, dotted or "", names) + + def _external_provider_path_issue(self, module: PythonModule, dotted: str, names: list[str]) -> str | None: + key = (id(module.tree), dotted, tuple(names)) + if key in self._external_provider_proofs: + return self._external_provider_proofs[key] + if dotted.split(".", 1)[0] in _PRELOADED: + self._external_provider_proofs[key] = None + return None + issue = None + if self.repository_holds(dotted or "", names): + issue = "the repository supplies the framework import; its external identity is not established" + else: + parts = (dotted or "").split(".") + for length in range(1, len(parts) + 1): + try: + candidates = self._installed_candidates(module, ".".join(parts[:length])) + except _Stop as stop: + if stop.reason != MODULE_NOT_FOUND: + issue = stop.detail + break + else: + if any(container.module_path is not None for container in candidates): + issue = "a local module or regular package supplies the framework import" + break + self._external_provider_proofs[key] = issue + return issue + def _no_import_patch( - self, outcome: dict[str, Any], steps: list[dict[str, Any]] + self, outcome: dict[str, Any], steps: list[dict[str, Any]], + *, external_symbol: str | None = None, ) -> tuple[str, ...]: """Stop when code that runs before the name is used may rebind it. @@ -470,6 +973,9 @@ def _no_import_patch( layout = self._layout prefix = layout.scope.replace("/", ".") if layout is not None and layout.scope else "" full = {f"{prefix}.{name}" if name else prefix for name in scoped} if prefix else set() + if external_symbol is not None: + full |= _external_constructor_dependency_paths(external_symbol) + full |= _external_constructor_dependency_modules(external_symbol) caveats.extend( f"{step['path']} changes __path__, so {step['name']!r} may be found in another " "directory" @@ -477,7 +983,20 @@ def _no_import_patch( if step.get("path_extended") ) - def table(key: str, where: str, line: int, runner_ref: str, *, repository_ref: bool = False) -> None: + def table( + key: str, where: str, line: int, runner_ref: str, *, repository_ref: bool = False, + marker_module: PythonModule | None = None, + ) -> None: + if (key == MODULE_TABLE_COMPUTED and not repository_ref + and isinstance(marker_module, PythonModule) and marker_module.ref == where): + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + try: + if (self._patch_scan(marker_module.path) is marker_module + and BuilderCalls(self).source_saved_vars_table_marker(marker_module, key, line)): + return # Only this actual row's fully proved intrinsic initializer origin. + except (CallLimit, _Stop): + pass # The ordinary computed-marker obligation remains on every refusal. verdict = _table_verdict( key, where, scoped, full, prefix, repository_ref=repository_ref ) @@ -520,9 +1039,102 @@ def table(key: str, where: str, line: int, runner_ref: str, *, repository_ref: b for item in (entry, *self._enclosing_packages(entry)): if item not in runners: runners.append(item) + retaining: set[Path] = set() + dependency_modules: set[str] = set() + if external_symbol is not None: + context = self._above_constructor_context(above) + if context is not None and layout is not None and layout.scope: + for module in context[1]: + if not module.ref.startswith(layout.scope + "/"): + continue + relative = PurePosixPath(module.ref).relative_to(layout.scope) + path = self.scope_root / relative + # These are executable import candidates, not merely files + # independently discovered by the framework reader. Hand + # each back to the original scope and ownership census. + for item in (path, *self._enclosing_packages(path)): + if item not in runners: + if len(runners) >= MAX_PATCH_SCAN_MODULES: + raise _Stop(RESOLUTION_LIMIT, "the constructor import closure exceeds its module bound") + runners.append(item) + imports: dict[Path, set[Path]] = {} + dependency_families = {external_symbol} + constructor_paths = (_external_constructor_paths(external_symbol) | _external_wrapper_paths(external_symbol) + | _external_decorator_paths(external_symbol) + | _external_constructor_dependency_paths(external_symbol)) + for runner in runners: + running = self._patch_scan(runner) + typing_only = _type_checking_only(running, self) + imports[runner] = set() + for statement in self._constructor_syntax(running).imports: + if id(statement) in typing_only: + continue + imported_names = [alias.name if isinstance(statement, ast.Import) + else _absolute_import_reference(alias, statement) + for alias in statement.names] + dependency_families |= {family for family in _FRAMEWORK_MODULES + if any(name is not None and (name == family or name.startswith(family + ".")) + for name in imported_names)} + if any((path := _absolute_import_reference(alias, statement)) is not None + and any(path == constructor or constructor.startswith(path + ".") + or path.startswith(constructor + ".") for constructor in constructor_paths) + for alias in statement.names): + retaining.add(runner) + paths, _ = self._imported_paths(running, statement) + if any(self._unread_import_candidate(running, statement, alias) for alias in statement.names): + # An unread imported object can carry construction + # machinery in its globals or ancestry. Preserve that + # obligation through local module/function reexports. + retaining.add(runner) + for path in paths: + for item in (path, *self._enclosing_packages(path)): + imports[runner].add(item) + if item not in runners: + if len(runners) >= MAX_PATCH_SCAN_MODULES: + raise _Stop(RESOLUTION_LIMIT, "the constructor import closure exceeds its module bound") + runners.append(item) + # A foreign framework can expose the same Pydantic/typing objects + # through its globals. Its imports also load implicit dependencies, + # even when the application only writes an independent class slot. + dependency_modules = set().union(*(_external_constructor_dependency_modules(family) + for family in dependency_families)) + full |= dependency_modules + for where, line, key in above.tables: + table(key, where, line, "a package enclosing the scope", repository_ref=True) + # Modules, functions, classes and packages can carry an imported + # constructor in their namespace even without re-exporting its name. + for _ in range(len(runners) + 1): + added = {path for path, dependencies in imports.items() if dependencies & retaining} + added |= {path for path in runners if path.name == "__init__.py" + and any(other != path and other.is_relative_to(path.parent) for other in retaining)} + if added <= retaining: + break + retaining |= added + issue = self._above_constructor_use(context, external_symbol, retaining) + if issue is not None: + raise _Stop(REBOUND_NAME, issue) + search_limits = self._import_search_limit(runners) + if external_symbol is not None and search_limits: + raise _Stop(REBOUND_NAME, search_limits[0]) + caveats.extend(item for item in search_limits if item not in caveats) + if external_symbol is not None: + self._constructor_runners = tuple(runners) + self._annotation_readers.clear() for runner in runners: scan = self._patched_names(runner) - path_line = self._patch_scan(runner).attribute_patches.get(PATH_PATCH) + if external_symbol is not None: + running = self._patch_scan(runner) + # An entry point or importer can supply a sibling dependency + # even when the constructor lives in a repository-root helper. + # Prove every bounded caller/import context, not just its owner. + for dependency in sorted(dependency_modules): + issue = self._external_provider_path_issue(running, dependency, []) + if issue is not None: + raise _Stop(REBOUND_NAME, f"constructor dependency {dependency!r} is unread in {running.ref}: {issue}") + issue = _external_constructor_use(self, running, external_symbol, retaining) + if issue is not None: + raise _Stop(REBOUND_NAME, issue) + path_line = self._runtime_attribute_patches(self._patch_scan(runner)).get(PATH_PATCH) if path_line is not None: # ``__path__.insert(0, ...)`` in a package on the chain: a # submodule may be found in another directory (#879 review). @@ -535,10 +1147,14 @@ def table(key: str, where: str, line: int, runner_ref: str, *, repository_ref: b for key, stores in scan.patched.items(): if key.startswith(MODULE_TABLE_PATCH): for where, line, _ in stores: - table(key, where, line, self.ref(runner)) + origins = scan.table_modules.get((key, where, line), ()) + table(key, where, line, self.ref(runner), + marker_module=origins[0] if len(origins) == 1 else None) for name in names: + if external_symbol is not None: + continue # Canonical namespace/class uses are checked above. for where, line, targets in scan.patched.get(name, []): - if where == defining.ref and targets is not None and defining.path not in targets: + if not external_symbol and where == defining.ref and targets is not None and defining.path not in targets: # ``registry.lookup = lookup``: handing the definition on. continue raise _Stop( @@ -549,6 +1165,92 @@ def table(key: str, where: str, line: int, runner_ref: str, *, repository_ref: b caveats.extend(item for item in scan.unread if item not in caveats) return tuple(caveats) + def _above_constructor_context( + self, above: _AboveScope, + ) -> tuple[_RepositoryConstructorResolver, list[PythonModule], dict[Path, set[Path]]] | None: + """All executable candidates in the enclosing snapshot import closure.""" + if not above.modules or self._layout is None: + return None + reader = _RepositoryConstructorResolver(self) + modules = [reader.module(reader.scope_root / ref) for ref in above.modules] + imports: dict[Path, set[Path]] = {} + for module in modules: + typing_only = _type_checking_only(module, reader) + imports[module.path] = set() + for statement in ast.walk(module.tree): + if not isinstance(statement, ast.Import | ast.ImportFrom) or id(statement) in typing_only: + continue + paths, missing = reader._imported_paths(module, statement) + for stop, _, _ in missing: + if stop.reason != MODULE_NOT_FOUND or isinstance(statement, ast.ImportFrom) and statement.level: + raise _Stop(stop.reason, f"constructor import ownership is unread above the read scope: {stop.detail}") + for path in paths: + for target in (path, *reader._enclosing_packages(path)): + imports[module.path].add(target) + if target not in imports and all(item.path != target for item in modules): + if len(modules) >= MAX_PATCH_SCAN_MODULES: + raise _Stop(RESOLUTION_LIMIT, "the above-scope constructor import closure exceeds its module bound") + modules.append(reader.module(target)) + return reader, modules, imports + + def _above_constructor_use( + self, context: tuple[_RepositoryConstructorResolver, list[PythonModule], dict[Path, set[Path]]] | None, + family: str, retaining: set[Path], + ) -> str | None: + """Check above-scope handles in a separate, snapshot-only namespace.""" + if context is None: + return None + assert self._layout is not None + reader, modules, imports = context + prefix = self._layout.scope + held = {reader.scope_root / prefix / self.ref(path) for path in retaining} + for module in modules: + typing_only = _type_checking_only(module, reader) + for statement in ast.walk(module.tree): + if id(statement) not in typing_only and isinstance(statement, ast.Import | ast.ImportFrom) and any( + reader._unread_import_candidate(module, statement, alias) for alias in statement.names + ): + held.add(module.path) + for _ in range(len(modules) + 1): + added = {path for path, dependencies in imports.items() if dependencies & held} + added |= {item.path for item in modules if item.package + and any(other != item.path and other.is_relative_to(item.path.parent) for other in held)} + if added <= held: + break + held |= added + for module in modules: + if module.ref.startswith(prefix + "/"): + continue # In-scope owners belong to the original list reader. + if PATH_PATCH in reader._runtime_attribute_patches(module): + return f"constructor module search ownership is unread: {module.ref} changes __path__ above the read scope" + typing_only = _type_checking_only(module, reader) + scopes = ScopeIndex(module.tree) + for statement in ast.walk(module.tree): + if not isinstance(statement, ast.Import | ast.ImportFrom) or id(statement) in typing_only: + continue + if _type_checking_import_role(module, statement, scopes, reader, typing_only): + continue + for alias in statement.names: + imported = _absolute_import_reference(alias, statement) + if imported is not None and any( + imported == root or imported.startswith(root + ".") or root.startswith(imported + ".") + for root in (*_CONSTRUCTOR_IMPORT_MODULES, *_STDLIB_SHARED_DEPENDENCY_PATHS) + ): + return f"constructor dependency ownership is unread: {module.ref}:{statement.lineno} imports a protected namespace above the read scope" + issue = _external_constructor_use(reader, module, family, held, allow_owner_routes=False) + if issue is not None: + return f"{issue} above the read scope" + if (reader._constructor_namespace_owners or reader._constructor_container_owners + or reader._constructor_member_sinks or reader._constructor_dictionary_sinks + or reader._constructor_dictionary_class_sinks + or reader._constructor_unused_namespace_sinks + or reader._constructor_decorator_owners + or reader._constructor_wrapped_operands): + # These are obligations, not proof. The scope's list reader + # cannot finish an ownership census in this separate namespace. + return f"constructor handle ownership is unread in {module.ref} above the read scope" + return None + def _enclosing_packages(self, path: Path) -> list[Path]: """The package ``__init__`` files importing ``path`` runs first, innermost first.""" @@ -588,7 +1290,7 @@ def _scan_imports(self, path: Path) -> _PatchScan: modules = [runner] unread: list[str] = [] guarded = _import_guarded(runner.tree) - typing_only = _type_checking_only(runner) + typing_only = _type_checking_only(runner, self) for node in ast.walk(runner.tree): if not isinstance(node, ast.Import | ast.ImportFrom) or id(node) in typing_only: continue @@ -604,10 +1306,15 @@ def _scan_imports(self, path: Path) -> _PatchScan: if module_path != path: modules.append(self._patch_scan(module_path)) patched: dict[str, list[tuple[str, int, frozenset[Path] | None]]] = {} + table_modules: dict[tuple[str, str, int], tuple[PythonModule, ...]] = {} for module in modules: - for dotted, line in module.attribute_patches.items(): + for dotted, line in self._runtime_attribute_patches(module).items(): if dotted.startswith(MODULE_TABLE_PATCH): patched.setdefault(dotted, []).append((module.ref, line, None)) + row = (dotted, module.ref, line) + origins = table_modules.get(row, ()) + if not any(origin is module for origin in origins): + table_modules[row] = (*origins, module) continue if dotted.startswith(SELF_PATCH): # The module rebinds its own name: it is the target. @@ -615,7 +1322,7 @@ def _scan_imports(self, path: Path) -> _PatchScan: (module.ref, line, frozenset({module.path})) ) continue - if dotted == PATH_PATCH: + if dotted in {PATH_PATCH, IMPORT_SEARCH_PATCH}: continue # Only an attribute of an imported module can be the definition: # ``self.lookup = ...`` in a class, or ``backend.lookup`` on a @@ -628,7 +1335,7 @@ def _scan_imports(self, path: Path) -> _PatchScan: if any(entry[:2] == (module.ref, line) for entry in found): continue found.append((module.ref, line, self._patch_targets(module, imports))) - return _PatchScan(patched, tuple(unread)) + return _PatchScan(patched, tuple(unread), table_modules) def _patch_targets( self, module: PythonModule, imports: list[ast.Import | ast.ImportFrom] @@ -773,6 +1480,7 @@ def _above_scope(self) -> _AboveScope: return found scope_prefix = layout.scope + "/" parts = layout.scope.split("/") + type_reader = _RepositoryConstructorResolver(self) runners: list[PythonModule] = [] for length in range(len(parts)): directory = "/".join(parts[:length]) @@ -787,7 +1495,9 @@ def _above_scope(self) -> _AboveScope: for runner in runners: modules = [runner] directory = runner.ref.rsplit("/", 1)[0] if "/" in runner.ref else "" - typing_only = _type_checking_only(runner) + # Provider lookup needs the enclosing repository's snapshot path, + # never a repository-relative path in the selected-scope resolver. + typing_only = _type_checking_only(type_reader.module(type_reader.scope_root / runner.ref), type_reader) guarded = _import_guarded(runner.tree) for node in ast.walk(runner.tree): if not isinstance(node, ast.ImportFrom | ast.Import) or id(node) in typing_only: @@ -820,15 +1530,24 @@ def _above_scope(self) -> _AboveScope: found.unread.append(message) elif imported not in modules: modules.append(imported) + if imported not in runners: + runners.append(imported) for item in modules: + if item.ref in found.modules: + continue + found.modules[item.ref] = item item_directory = item.ref.rsplit("/", 1)[0] if "/" in item.ref else "" - for dotted, line in item.attribute_patches.items(): + runtime_item = type_reader.module(type_reader.scope_root / item.ref) + for dotted, line in type_reader._runtime_attribute_patches(runtime_item).items(): if dotted.startswith(MODULE_TABLE_PATCH): found.tables.append((item.ref, line, dotted)) continue if dotted.startswith(SELF_PATCH): found.patched.setdefault(dotted[len(SELF_PATCH):], []).append((item.ref, line)) continue + if dotted == IMPORT_SEARCH_PATCH: + found.unread.append(f"{item.ref}:{line} changes or retains import-search machinery above the scope") + continue if dotted == PATH_PATCH: # ``__path__.insert(0, ...)`` above the scope: the # scope's own package may be found elsewhere. @@ -843,6 +1562,11 @@ def _above_scope(self) -> _AboveScope: continue if not self._other_module(item, item_directory, dotted, imports, scope_prefix): found.patched.setdefault(dotted.rsplit(".", 1)[-1], []).append((item.ref, line)) + if found.modules: + search_limits = type_reader._import_search_limit( + [type_reader.scope_root / ref for ref in found.modules] + ) + found.unread.extend(item for item in search_limits if item not in found.unread) return found def _other_module( @@ -1031,6 +1755,8 @@ def _imported_paths( missing: list[tuple[_Stop, str, list[str]]] = [] if isinstance(node, ast.Import): for alias in node.names: + if alias.name.split(".", 1)[0] in _PRELOADED: + continue # The startup module wins over repository siblings. try: containers = self._absolute_candidates(runner, alias.name) except _Stop as stop: @@ -1038,6 +1764,8 @@ def _imported_paths( continue paths.extend(item.module_path for item in containers if item.module_path) return paths, missing + if not node.level and (node.module or "").split(".", 1)[0] in _PRELOADED: + return paths, missing spelling = _from_spelling(node) if not node.module: # ``from .. import patches``: the modules are the names. @@ -1073,6 +1801,7 @@ def _patch_scan(self, path: Path) -> PythonModule: (#879 review). Read through the same snapshot-aware reader. """ + self._import_search_module_guard(path) cached = self._modules.get(path) if isinstance(cached, PythonModule): return cached @@ -1147,7 +1876,7 @@ def _in_module( if module.package: container = self._locate(module.path.parent, [name], spelling=name) if container is not None: - steps.append(_fallthrough(module, name)) + steps.append(_fallthrough(module, name, patches=self._runtime_attribute_patches(module))) return self._member(container, rest, steps, seen, spelling=name) raise _Stop( NOT_BOUND if not steps else NAME_NOT_DEFINED, @@ -1178,13 +1907,13 @@ def _in_module( step = {"path": module.ref, "line": line, "name": name, "sha256": module.sha256} if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): steps.append({**step, "binding": "definition"}) - if rest: + if rest and not self._constructor_reference_mode: raise _Stop( NOT_A_FUNCTION, f"{'.'.join(parts)!r} reads an attribute of function {name!r} " f"in {module.ref}:{line}", ) - return {"module": module, "definition": node} + return {"module": module, "definition": node, **({"constructor_callable": not rest} if self._constructor_reference_mode else {})} if isinstance(node, ast.alias): statement = binding.statement steps.append({**step, "binding": "import"}) @@ -1202,6 +1931,20 @@ def _through_import( seen: set[tuple[Path, tuple[str, ...]]], ) -> dict[str, Any]: rest = parts[1:] + if self._constructor_reference_mode: + external = _absolute_import_reference(node, statement) + projected = external + ("." + ".".join(rest) if rest else "") if external is not None else None + if projected is not None and (projected == "pathlib.Path" or any(projected == family or projected.startswith(family + ".") + or family.startswith(projected + ".") + for family in (*_CONSTRUCTOR_IMPORT_MODULES, *_STDLIB_SHARED_DEPENDENCY_PATHS))): + issue = self._external_provider_issue(module, statement, node) + if issue is not None: + return {"local_constructor_provider": issue} + return {"external_constructor": projected} + if projected is not None and projected.split(".", 1)[0] in _PRELOADED: + return {"constructor_unread": MODULE_NOT_FOUND, "unread_external_handle": True, + "detail": f"{projected!r} is supplied by a startup module, not repository source", + **({"import_search_handle": projected} if projected.split(".", 1)[0] in {"sys", "site"} else {})} if isinstance(statement, ast.Import): imported = node.name.split(".") if not node.asname and len(imported) > 1 and parts[: len(imported)] == imported: @@ -1258,6 +2001,8 @@ def _not_an_import( "reason": NOT_A_FUNCTION, "detail": f"{name!r} in {module.ref}:{line} is assigned a " "value, not a function definition"} + if self._constructor_reference_mode and isinstance(node, ast.ClassDef): + return {"module": module, "retained_class": node} kind = "class" if isinstance(node, ast.ClassDef) else "binding" raise _Stop( NOT_A_FUNCTION, @@ -1276,6 +2021,8 @@ def _member( """Continue ``parts`` inside a located module or namespace package.""" if not parts: + if self._constructor_reference_mode: + return {"retained_namespace": container.module_path or container.directory} raise _Stop( NOT_A_FUNCTION, f"{spelling!r} names a module, not a function definition", @@ -1288,7 +2035,7 @@ def _member( name in module.bindings or module.star_import or not container.package ): return self._in_module(module, parts, steps, seen) - steps.append(_fallthrough(module, name)) + steps.append(_fallthrough(module, name, patches=self._runtime_attribute_patches(module))) submodule = self._locate(container.directory, [parts[0]], spelling=parts[0]) if submodule is None: where = ( @@ -1326,6 +2073,44 @@ def _from_base(self, module: PythonModule, statement: ast.ImportFrom) -> _Contai ) return container + def _installed_candidates(self, module: PythonModule, dotted: str) -> list[_Container]: + """Where a name an installed package provides could be found from ``module``. + + :meth:`_absolute_candidates` treats every directory from the module's + own up to the scope as a place an import could start from. A directory + that is a regular package of the module's own is not one, unless the + module sits directly in it (a script's directory): the import system + reaches such a package through its parent. ``src/opencmo/agents/blog.py`` + importing ``agents`` is the SDK, not its ``opencmo/agents`` sibling. + """ + found = self._absolute_candidates(module, dotted) + depth = len(dotted.split(".")) + chain: set[Path] = set() + directory = module.path.parent + while True: + if directory != module.path.parent and self._file_entry(directory, "__init__.py") is not None: + chain.add(directory) + if directory == self.scope_root: + break + directory = directory.parent + kept = [] + for container in found: + location = container.module_path or container.directory + base = location.parent if location.name == "__init__.py" else location.with_suffix("") if location.suffix == ".py" else location + ancestors = base.parents + root = ancestors[depth - 1] if len(ancestors) >= depth else None + if root is None or root not in chain: + kept.append(container) + if not kept: + raise _Stop(MODULE_NOT_FOUND, f"no file inside the read scope provides module {dotted!r} imported by {module.ref}") + return kept + + def _installed_absolute(self, module: PythonModule, dotted: str) -> _Container: + candidates = self._installed_candidates(module, dotted) + if len(candidates) > 1: + raise _Stop(AMBIGUOUS_MODULE, f"module {dotted!r} imported by {module.ref} matches more than one location in the read scope") + return candidates[0] + def _absolute(self, module: PythonModule, dotted: str) -> _Container: candidates = self._absolute_candidates(module, dotted) if len(candidates) > 1: @@ -1493,6 +2278,151 @@ def _display_dir(self, directory: Path) -> str: return relative or "." +def _constructor_structure_original( + originals: Mapping[Path, tuple[PythonModule, PythonModule, ast.Module]], module: PythonModule, +) -> PythonModule | None: + """A retained wrapper may reuse only its original immutable AST roles.""" + row = originals.get(module.path) + if row is not None and row[0] is module and module.tree is row[2] and row[1].tree is row[2]: + return row[1] + return None + + +class _CapturedImportSearchResolver(ImportResolver): + """Identity-only search reader over an already accounted source context. + + Keep the exact snapshot namespace and its structural lookup methods. No + semantic, patch, or ownership cache is inherited from the source reader. + Missing text is refused before either module cache can answer. + """ + + def __init__(self, source: ImportResolver, modules: list[PythonModule]) -> None: + super().__init__(source.scope_root) + self._source = source + self._layout = source._layout + self._modules.update({module.path: module for module in modules}) + self._scanned.update({module.path: module for module in modules}) + self._constructor_structure_originals = {module.path: (module, module, module.tree) for module in modules} + self._import_search_captured = MappingProxyType( + {module.path: module.text for module in modules} + ) + + def __post_init__(self) -> None: + pass # A synthetic source namespace must never be normalized on disk. + + def _constructor_scope(self, module: PythonModule) -> ScopeIndex: + original = _constructor_structure_original(self._constructor_structure_originals, module) + return self._source._constructor_scope(original) if original is not None else super()._constructor_scope(module) + + def _constructor_syntax(self, module: PythonModule) -> _ConstructorSyntax: + original = _constructor_structure_original(self._constructor_structure_originals, module) + return self._source._constructor_syntax(original) if original is not None else super()._constructor_syntax(module) + + + def _listing(self, directory: Path) -> frozenset[str] | None: + return self._source._listing(directory) + + def _kind(self, directory: Path, name: str) -> str | None: + return self._source._kind(directory, name) + + def _absolute_candidates(self, module: PythonModule, dotted: str) -> list[_Container]: + return self._source._absolute_candidates(module, dotted) + + +class _RepositoryConstructorResolver(ImportResolver): + """Private ownership reader; every lookup uses the same repository snapshot. + + Repository-relative AST paths must never enter the scope-bound resolver. + The synthetic root gives its path operations one bounded absolute namespace; + no method here consults that namespace on disk or publishes tool evidence. + """ + + def __init__(self, source: ImportResolver) -> None: + super().__init__(Path("/__agents_shipgate_repository__")) + assert source._layout is not None + self._source = source + layout = source._layout + self._layout = RepositoryLayout("", layout.entries, layout.links, layout.read) + self._constructor_structure_originals: dict[Path, tuple[PythonModule, PythonModule, ast.Module]] = {} + + def __post_init__(self) -> None: + pass # The synthetic namespace is not a disk checkout. + + def _constructor_scope(self, module: PythonModule) -> ScopeIndex: + original = _constructor_structure_original(self._constructor_structure_originals, module) + return self._source._constructor_scope(original) if original is not None else super()._constructor_scope(module) + + def _constructor_syntax(self, module: PythonModule) -> _ConstructorSyntax: + original = _constructor_structure_original(self._constructor_structure_originals, module) + return self._source._constructor_syntax(original) if original is not None else super()._constructor_syntax(module) + + + def _patch_scan(self, path: Path) -> PythonModule: + return self.module(path) # Repository paths are snapshot-only, never disk paths. + + def module(self, path: Path) -> PythonModule: + self._import_search_module_guard(path) + if not path.is_relative_to(self.scope_root): + raise _Stop(OUTSIDE_SCOPE, "the repository ownership lookup leaves its snapshot") + cached = self._modules.get(path) + if isinstance(cached, PythonModule): + return cached + original = self._source._layout_module(self.ref(path)) + if original is None: + reason = RESOLUTION_LIMIT if self._source._layout_budget_spent(self.ref(path)) else UNREADABLE_MODULE + raise _Stop(reason, f"{self.ref(path)} could not be read for above-scope constructor ownership") + module = _module(path, original.ref, original.tree, original.text) + self._modules[path] = module + self._constructor_structure_originals[path] = (module, original, original.tree) + return module + + def _listing(self, directory: Path) -> frozenset[str] | None: + assert self._layout is not None + if not directory.is_relative_to(self.scope_root): + return None + ref = self.ref(directory) + return self._layout.entries("" if ref == "." else ref) + + def _kind(self, directory: Path, name: str) -> str | None: + assert self._layout is not None + if name not in (self._listing(directory) or ()): + return None + ref = self.ref(directory) + ref = "" if ref == "." else ref + if name in self._layout.links(ref): + return "link" + target = f"{ref}/{name}" if ref else name + if self._layout.entries(target) is not None: + return "dir" + if name.endswith(".py"): + return "file" # Listed blobs remain candidates even past the read cap. + raise _Stop(UNREADABLE_MODULE, f"the listed repository entry {target!r} cannot be classified for constructor ownership") + + def _absolute_candidates(self, module: PythonModule, dotted: str) -> list[_Container]: + try: + found = super()._absolute_candidates(module, dotted) + except _Stop as stop: + if stop.reason != MODULE_NOT_FOUND: + raise + found = [] + by_path = {item.module_path or item.directory: item for item in found} + for root in self._source._import_roots(): + item = self._locate(self.scope_root / root, dotted.split("."), spelling=dotted) + if item is not None: + by_path.setdefault(item.module_path or item.directory, item) + if not by_path: + raise _Stop(MODULE_NOT_FOUND, f"the repository snapshot does not provide {dotted!r} imported by {module.ref}") + files = [item for item in by_path.values() if item.module_path is not None] + return files or list(by_path.values()) + + +def _single_import(statement: ast.Import | ast.ImportFrom, alias: ast.alias) -> ast.Import | ast.ImportFrom: + """One candidate's import, excluding unrelated aliases in the statement.""" + if isinstance(statement, ast.Import): + return ast.Import(names=[alias]) + return ast.ImportFrom(module=statement.module, names=[alias], level=statement.level) + + def _import_guarded(tree: ast.Module) -> set[int]: """Imports inside a ``try`` whose handler catches ``ImportError``.""" @@ -1520,47 +2450,155 @@ def _import_guarded(tree: ast.Module) -> set[int]: _TYPING_MODULES = frozenset({"typing", "typing_extensions"}) -def _type_checking_only(module: PythonModule) -> set[int]: - """Nodes under ``if TYPE_CHECKING:``, which never run. +def _canonical_type_checking_test( + module: PythonModule, test: ast.expr, scopes: ScopeIndex, resolver: ImportResolver, +) -> bool: + """One directly tested, stable canonical false flag in its actual scope. - Only the flag ``typing`` provides counts: a module's own - ``TYPE_CHECKING = True`` runs its block (#879 review). + A parameter, local assignment, closure or nonlocal binding wins over the + module's import. Provider proof is mandatory even for the familiar spelling; + a repository's ``typing.py`` is executable source, not the standard flag. """ + if module.star_import or any(key.endswith(".TYPE_CHECKING") for key in module.attribute_patches): + return False + spelling = reference_spelling(test) + if spelling is None: + return False + head, _, rest = spelling.partition(".") + if rest not in {"", "TYPE_CHECKING"}: + return False + from agents_shipgate.inputs.list_expressions import evaluation_site + bindings = scopes.enclosing_bindings(evaluation_site(scopes, test), head) + bindings = bindings or [binding.node for binding in module.bindings.get(head, [])] + if len(bindings) != 1 or not isinstance(alias := bindings[0], ast.alias): + return False + statement = scopes.statement_of(alias) + if (not isinstance(statement, ast.Import | ast.ImportFrom) + or not isinstance(scopes.parents.get(statement), ast.Module | ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) + or statement.lineno > test.lineno): + return False + canonical = ( + isinstance(statement, ast.Import) and bool(rest) and alias.name in _TYPING_MODULES + or isinstance(statement, ast.ImportFrom) and not rest and not statement.level + and statement.module in _TYPING_MODULES and alias.name == "TYPE_CHECKING" + ) + if not canonical: + return False + try: + return resolver._external_provider_issue(module, statement, alias) is None + except _Stop: + return False # Unread or ambiguous providers cannot prove a dead branch. - def imported_from_typing(name: str, *, as_module: bool) -> bool: - bindings = module.bindings.get(name, []) - return bool(bindings) and all( - isinstance(item.node, ast.alias) - and ( - isinstance(item.statement, ast.Import) - and as_module - and item.node.name in _TYPING_MODULES - or isinstance(item.statement, ast.ImportFrom) - and not as_module - and not item.statement.level - and item.statement.module in _TYPING_MODULES - and item.node.name == "TYPE_CHECKING" - ) - for item in bindings - ) + +def _type_checking_only(module: PythonModule, resolver: ImportResolver, scopes: ScopeIndex | None = None) -> set[int]: + """Nodes under a proved direct ``if TYPE_CHECKING:`` runtime-false guard.""" + scopes = scopes or ScopeIndex(module.tree) skipped: set[int] = set() for node in ast.walk(module.tree): if not isinstance(node, ast.If): continue - spelling = reference_spelling(node.test) - if spelling is None: - continue - head, _, rest = spelling.partition(".") - if (not rest and imported_from_typing(head, as_module=False)) or ( - rest == "TYPE_CHECKING" and imported_from_typing(head, as_module=True) - ): + if _canonical_type_checking_test(module, node.test, scopes, resolver): for statement in node.body: skipped.update(id(child) for child in ast.walk(statement)) return skipped -def reflective_access(tree: ast.Module) -> ast.AST | None: +def _type_checking_import_role( + module: PythonModule, statement: ast.Import | ast.ImportFrom, + scopes: ScopeIndex, resolver: ImportResolver, excluded: set[int], +) -> bool: + """Every actual use of this canonical import is its proved boolean role. + + This narrow above-scope role grants no typing namespace or flag retention. + Unused, shadowed, mixed and mutated imports keep the protected-import limit. + """ + from agents_shipgate.inputs.list_expressions import evaluation_site + for alias in statement.names: + if not ( + isinstance(statement, ast.Import) and alias.name in _TYPING_MODULES + or isinstance(statement, ast.ImportFrom) and not statement.level + and statement.module in _TYPING_MODULES and alias.name == "TYPE_CHECKING" + ): + return False + head = alias.asname or alias.name + used = False + for node in ast.walk(module.tree): + if id(node) in excluded or not isinstance(node, ast.Name) or not isinstance(node.ctx, ast.Load) or node.id != head: + continue + bindings = scopes.enclosing_bindings(evaluation_site(scopes, node), head) + bindings = bindings or [binding.node for binding in module.bindings.get(head, [])] + if alias not in bindings: + continue # A different lexical binding does not use this import. + if bindings != [alias]: + return False + test = scopes.parents.get(node) if isinstance(statement, ast.Import) else node + parent = scopes.parents.get(test) + if not (isinstance(test, ast.Name | ast.Attribute) and isinstance(parent, ast.If) + and parent.test is test and _canonical_type_checking_test(module, test, scopes, resolver)): + return False + used = True + if not used: + return False + return bool(statement.names) + + +def replaces_builtin_namespace(node: ast.AST, scopes: ScopeIndex) -> bool: + """A binding of the module's special builtin namespace, including globals. + + Replacing this dictionary/module changes what an otherwise unshadowed + builtin name means. Import aliases and definition names bind it without + an ast.Name Store. Ordinary local/class stores retain their lexical scope. + """ + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store | ast.Del): + name = node.id + elif isinstance(node, ast.alias): + name = node.asname or node.name.split(".", 1)[0] + elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.ExceptHandler | ast.MatchAs | ast.MatchStar): + name = node.name + elif isinstance(node, ast.MatchMapping): + name = node.rest + else: + return False + if name != "__builtins__": + return False + from agents_shipgate.inputs.list_expressions import evaluation_site + site = evaluation_site(scopes, node) + if isinstance(scopes.parents.get(node), ast.NamedExpr): + # A walrus binds around comprehension scopes, including inside a + # lambda whose body ScopeIndex otherwise deliberately does not index. + # Its header still runs where evaluation_site placed it. + current = scopes.parents.get(site) + while current is not None and not isinstance(current, ast.Module): + if isinstance(current, ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp): + current = scopes.parents.get(evaluation_site(scopes, current)) + continue + if isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda | ast.ClassDef): + _, declared_global, declared_nonlocal = scopes._scope(current) + if name in declared_global: + return True + if name in declared_nonlocal: + return False + return False + current = scopes.parents.get(current) + return True + if scopes.enclosing_bindings(site, name): + return False + current = scopes.parents.get(site) + while current is not None and not isinstance(current, ast.Module): + if isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda | ast.ClassDef): + _, declared_global, declared_nonlocal = scopes._scope(current) + if name in declared_global: + return True + # Nonlocal stores never target a module. Its outer cell can be + # created by a walrus/header ScopeIndex does not otherwise index. + if name in declared_nonlocal: + return False + current = scopes.parents.get(current) + return True + + +def reflective_access(tree: ast.Module, *, excluded: frozenset[int] = frozenset()) -> ast.AST | None: """A node that reaches the module's own names without spelling them. A bare ``globals()``, ``vars()`` or ``locals()`` call, ``sys.modules`` @@ -1572,6 +2610,8 @@ def reflective_access(tree: ast.Module) -> ast.AST | None: sys_names = {"sys"} modules_names: set[str] = set() for node in ast.walk(tree): + if id(node) in excluded: + continue if isinstance(node, ast.Import): sys_names.update( alias.asname for alias in node.names if alias.name == "sys" and alias.asname @@ -1581,6 +2621,8 @@ def reflective_access(tree: ast.Module) -> ast.AST | None: alias.asname or alias.name for alias in node.names if alias.name == "modules" ) for node in ast.walk(tree): + if id(node) in excluded: + continue if isinstance(node, ast.Call): if ( isinstance(node.func, ast.Name) @@ -1629,7 +2671,7 @@ def _dotted(node: ast.AST) -> list[str] | None: return parts -def _fallthrough(module: PythonModule, name: str) -> dict[str, Any]: +def _fallthrough(module: PythonModule, name: str, *, patches: dict[str, int] | None = None) -> dict[str, Any]: """The package ``__init__`` read and found not to bind ``name`` itself. A module-level ``__getattr__`` there is not evaluated: the submodule of @@ -1644,7 +2686,7 @@ def _fallthrough(module: PythonModule, name: str) -> dict[str, Any]: "sha256": module.sha256, "binding": "submodule", } - if PATH_PATCH in module.attribute_patches: + if PATH_PATCH in (module.attribute_patches if patches is None else patches): # ``__path__.insert(0, ...)``: the submodule may come from elsewhere. step["path_extended"] = True if "__getattr__" in module.bindings: @@ -1949,6 +2991,7 @@ def _module(path: Path, ref: str, tree: ast.Module, text: str) -> PythonModule: SELF_PATCH = "." #: ``attribute_patches`` key for a change to a package's ``__path__``. PATH_PATCH = "" +IMPORT_SEARCH_PATCH = "" #: Calls that only read the namespace or module table they are handed. _NAMESPACE_READERS = frozenset( {"set", "frozenset", "list", "tuple", "sorted", "len", "iter", "dict", "any", "all", @@ -1969,8 +3012,1405 @@ def _module(path: Path, ref: str, tree: ast.Module, text: str) -> PythonModule: {"abc", "builtins", "codecs", "encodings", "genericpath", "io", "marshal", "nt", "ntpath", "os", "posix", "posixpath", "site", "stat", "sys", "time", "zipimport"} ) +#: Names that read a function's annotation dictionary or evaluate a stored one. +_ANNOTATION_READERS = frozenset( + {"__annotations__", "__annotate__", "__annotate_func__", "get_type_hints", "get_annotations", + "evaluate_forward_ref", "update_forward_refs", "model_rebuild", "__wrapped__"} +) #: The frameworks whose own modules build the tools an agent binds. -_FRAMEWORK_MODULES = ("agents", "google.adk") +_FRAMEWORK_MODULES = ("agents", "openai_agents", "google.adk") +# These dependencies are recognized only by the private constructor-identity +# resolver. They do not become supported tool definitions or receiving sinks. +_CONSTRUCTOR_IMPORT_MODULES = (*_FRAMEWORK_MODULES, "pydantic", "abc", "_collections_abc", "typing", "typing_extensions", "google.genai", "griffe") +# A finite primary-stdlib alias/ABC ancestry boundary, read from CPython +# sources. Ordinary sibling APIs are not constructors or getter exceptions. +_STDLIB_SHARED_DEPENDENCY_MEMBERS = { + # A module's type carries its attribute hooks, without receiving authority. + "types": ("ModuleType",), + "collections": ("abc", "_collections_abc", "ChainMap", "UserDict", "UserList", "UserString", + "_OrderedDictItemsView", "_OrderedDictKeysView", "_OrderedDictValuesView"), + "_pyio": ("abc", "IOBase", "BufferedIOBase", "BufferedRWPair", "BufferedRandom", "BufferedReader", + "BufferedWriter", "BytesIO", "FileIO", "RawIOBase", "StringIO", "TextIOBase", "TextIOWrapper", "_BufferedIOMixin"), + "cgi": ("Mapping",), + "contextlib": ("abc", "_collections_abc", "AbstractAsyncContextManager", "AbstractContextManager", + "AsyncExitStack", "ExitStack", "_AsyncGeneratorContextManager", "_GeneratorContextManager", + "_RedirectStream", "aclosing", "chdir", "closing", "nullcontext", "redirect_stderr", "redirect_stdout", "suppress"), + "configparser": ("MutableMapping", "ConfigParser", "ConverterMapping", "RawConfigParser", "SectionProxy"), + "dataclasses": ("abc",), + "io": ("abc", "IOBase", "BufferedIOBase", "RawIOBase", "TextIOBase"), + "locale": ("_collections_abc",), + "numbers": ("ABCMeta", "abstractmethod", "Number", "Complex", "Integral", "Rational", "Real"), + "os": ("abc", "_check_methods", "MutableMapping", "Mapping", "PathLike", "_Environ"), + "pathlib": ("Sequence", "_PathParents"), + "random": ("_Sequence",), + "selectors": ("ABCMeta", "abstractmethod", "Mapping", "BaseSelector", "DefaultSelector", "DevpollSelector", + "EpollSelector", "KqueueSelector", "PollSelector", "SelectSelector", "_BaseSelectorImpl", "_PollLikeSelector", "_SelectorMapping"), + "shelve": ("collections.abc", "collections._collections_abc", "BsdDbShelf", "DbfilenameShelf", "Shelf", "_ClosedDict"), + "tracemalloc": ("Sequence", "Iterable", "Traceback", "_Traces"), + "weakref": ("_collections_abc", "WeakKeyDictionary", "WeakValueDictionary"), + "traceback": ("collections.abc", "collections._collections_abc"), + "fractions": ("numbers", "Fraction"), + "_pydecimal": ("_numbers",), +} +_STDLIB_SHARED_DEPENDENCY_PATHS = frozenset( + f"{module}.{member}" for module, members in _STDLIB_SHARED_DEPENDENCY_MEMBERS.items() for member in members +) +# Public typing values may appear in an annotation, but not in an arbitrary +# call, retained alias, metadata read or namespace export. Private helpers and +# other packages' reexports do not gain this narrow value-role exception. +_TYPING_ANNOTATION_VALUES = frozenset({ + "Any", "Annotated", "Literal", "Optional", "Union", "List", "Dict", "Tuple", "Set", "FrozenSet", + "Sequence", "Mapping", "MutableMapping", "MutableSequence", "MutableSet", "Callable", "Iterable", + "Iterator", "Collection", "AbstractSet", "Type", "ClassVar", "Final", "NoReturn", "Never", "Self", + "Concatenate", "Unpack", "TypeGuard", "TypeIs", +}) + + +def _absolute_import_reference(alias: ast.alias, statement: ast.AST | None) -> str | None: + if isinstance(statement, ast.Import): + return alias.name if alias.asname else alias.name.split(".", 1)[0] + if isinstance(statement, ast.ImportFrom) and not statement.level and statement.module: + return f"{statement.module}.{alias.name}" + return None + + +def _external_constructor_paths(family: str) -> set[str]: + if family == "google.adk": + return {f"{prefix}.{name}" for prefix in (family, family + ".agents", family + ".agents.llm_agent") + for name in ("Agent", "LlmAgent")} + # The SDK reader already recognizes both published import spellings. + # Each actual import still needs its own provider and mutation census. + roots = ("agents", "openai_agents") if family in {"agents", "openai_agents"} else (family,) + return {root + suffix for root in roots for suffix in (".Agent", ".agent.Agent")} + + +def _external_wrapper_paths(family: str) -> set[str]: + if family != "google.adk": + return set() + return {f"{prefix}.{name}" for prefix, name in ( + ("google.adk.tools", "FunctionTool"), ("google.adk.tools.function_tool", "FunctionTool"), + ("google.adk.tools", "LongRunningFunctionTool"), + ("google.adk.tools.function_tool", "LongRunningFunctionTool"), + )} + + +def _external_decorator_paths(family: str) -> set[str]: + return {root + suffix for root in ("agents", "openai_agents") + for suffix in (".function_tool", ".tool.function_tool")} if family in {"agents", "openai_agents"} else set() + + +def _framework_tool_object(family: str, canonical: str) -> str | None: + """How a framework object bound as a tool is made, by its exact import (#910). + + ``"value"``: the import is itself the tool (ADK's ``google_search``). + ``"object"``: calling it builds the tool object (an MCP server, a hosted + tool, ``AgentTool``). ``"toolset"``: ADK's MCP toolset, built only through + :func:`_adk_toolset_value_call`. ``"wrapper"``: calling it wraps a function + the caller names (the SDK's ``function_tool``). The role is the canonical + path the import resolves to, never the spelling at the use. + """ + from agents_shipgate.inputs import object_tools + + if family == "google.adk": + if canonical in object_tools.ADK_BUILT_INS: + return "value" + if canonical in object_tools.ADK_AGENT_TOOLS: + return "object" + if canonical in object_tools.ADK_MCP_TOOLSETS: + return "toolset" # Its arguments have their own, narrower role. + return None + root, _, _ = canonical.partition(".") + short = canonical.rsplit(".", 1)[-1] + if root not in {"agents", "openai_agents"}: + return None + if short in object_tools.SDK_HOSTED_TOOLS or short in object_tools.SDK_MCP_SERVERS: + return "object" + if canonical in {f"{root}.function_tool", f"{root}.tool.function_tool"}: + return "wrapper" + return None + + +def _framework_connection_class(canonical: str) -> bool: + """An ADK MCP connection-parameter class, by its exact import.""" + from agents_shipgate.inputs import object_tools + + short = canonical.rsplit(".", 1)[-1] + return short in object_tools.ADK_CONNECTIONS and ( + canonical.startswith("google.adk.tools.mcp_tool.") or canonical.startswith("mcp.") + ) + + +def _tool_object_argument_is_data( + resolver: ImportResolver, module: PythonModule, value: ast.expr, scopes: ScopeIndex, budget: list[int], +) -> bool: + """A value a tool object is configured with, never code the framework runs. + + Constants, text built from them, containers and reads of other values are + data; a call runs at construction in this repository, so its own uses are + checked where they stand. A callable (a lambda, a def, a generator) is not + data: the framework could call it with framework objects. + """ + budget[0] -= 1 + if budget[0] < 0: + return False + if isinstance(value, ast.Constant): + return True + if isinstance(value, ast.JoinedStr): + return all(isinstance(item, ast.Constant) + or isinstance(item, ast.FormattedValue) and item.format_spec is None + and _tool_object_argument_is_data(resolver, module, item.value, scopes, budget) + for item in value.values) + if isinstance(value, ast.List | ast.Tuple | ast.Set): + return all(not isinstance(item, ast.Starred) + and _tool_object_argument_is_data(resolver, module, item, scopes, budget) for item in value.elts) + if isinstance(value, ast.Dict): + return all(key is not None and _tool_object_argument_is_data(resolver, module, key, scopes, budget) + and _tool_object_argument_is_data(resolver, module, item, scopes, budget) + for key, item in zip(value.keys, value.values, strict=True)) + if isinstance(value, ast.BinOp): + return (_tool_object_argument_is_data(resolver, module, value.left, scopes, budget) + and _tool_object_argument_is_data(resolver, module, value.right, scopes, budget)) + if isinstance(value, ast.BoolOp): + return all(_tool_object_argument_is_data(resolver, module, item, scopes, budget) for item in value.values) + if isinstance(value, ast.IfExp): + return all(_tool_object_argument_is_data(resolver, module, item, scopes, budget) + for item in (value.test, value.body, value.orelse)) + if isinstance(value, ast.Subscript): + return (_tool_object_argument_is_data(resolver, module, value.value, scopes, budget) + and _tool_object_argument_is_data(resolver, module, value.slice, scopes, budget)) + if isinstance(value, ast.Name | ast.Attribute): + if not isinstance(value.ctx, ast.Load) or reference_spelling(value) is None: + return False + definition = resolver._constructor_reference(module, value, scopes).get("definition") + return not isinstance(definition, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) + if isinstance(value, ast.Call): + if any(isinstance(item, ast.Starred) for item in value.args) or any(item.arg is None for item in value.keywords): + return False + return (reference_spelling(value.func) is not None + and all(_tool_object_argument_is_data(resolver, module, item, scopes, budget) + for item in (*value.args, *(keyword.value for keyword in value.keywords)))) + return False + + +def _tool_object_call_is_data( + resolver: ImportResolver, module: PythonModule, call: ast.Call, scopes: ScopeIndex, +) -> bool: + """A call building a framework tool object with only data arguments.""" + if any(isinstance(item, ast.Starred) for item in call.args) or any(item.arg is None for item in call.keywords): + return False + budget = [400] + return all(_tool_object_argument_is_data(resolver, module, item, scopes, budget) + for item in (*call.args, *(keyword.value for keyword in call.keywords))) + + +def _adk_connection_call( + resolver: ImportResolver, module: PythonModule, value: ast.expr, scopes: ScopeIndex, +) -> bool: + """``SseConnectionParams(url=...)``: ADK's connection data for a toolset. + + The class is the exact import of one of ADK's (or the ``mcp`` package's) + connection-parameter classes and every argument is data; the object it + builds only reaches the toolset's ``connection_params``. + """ + if not isinstance(value, ast.Call): + return False + canonical = resolver._constructor_reference(module, value.func, scopes).get("external_constructor") + return (isinstance(canonical, str) and _framework_connection_class(canonical) + and _tool_object_call_is_data(resolver, module, value, scopes)) + + +def _adk_toolset_value_call( + resolver: ImportResolver, module: PythonModule, call: ast.Call, scopes: ScopeIndex, + canonical: str, +) -> bool: + """Existing static toolset data, without granting a receiving-class route. + + Inventory/path hints describe the static artifact; they do not assert that + the installed ADK accepts those keywords. Callable and object options do + not gain this role, and the result has its own restrictive owner census. + """ + from agents_shipgate.inputs.object_tools import ADK_MCP_TOOLSETS + + mcp = set(ADK_MCP_TOOLSETS) # The toolset's import path is not its identity. + openapi = {"google.adk.tools.openapi_tool.openapi_spec_parser.openapi_toolset.OpenAPIToolset"} + if canonical not in mcp | openapi or call.args or any(item.arg is None for item in call.keywords): + return False + hints = ({"inventory_path", "tool_inventory_path", "mcp_tools_path", "mcp_inventory"} + if canonical in mcp else {"spec_path", "path", "spec_file", "openapi_path", "openapi_spec"}) + strings = hints | {"tool_name_prefix", "credential_key"} + booleans = {"require_confirmation"} if canonical in mcp else {"ssl_verify", "preserve_property_names"} + objects = {"connection_params", "header_provider", "progress_callback", "sampling_callback", + "sampling_capabilities", "errlog"} if canonical in mcp else { + "auth_scheme", "auth_credential", "header_provider", "httpx_client_factory", + } + + def literal(value: ast.expr) -> bool: + if isinstance(value, ast.Constant): + return value.value is None or type(value.value) in {str, bool, int, float} + if isinstance(value, ast.List | ast.Tuple): + return all(literal(item) for item in value.elts) + return isinstance(value, ast.Dict) and all( + isinstance(key, ast.Constant) and isinstance(key.value, str) and literal(item) + for key, item in zip(value.keys, value.values, strict=True) + ) + + def local_text(value: ast.expr) -> bool: + if not (isinstance(value, ast.Call) and not value.args and not value.keywords + and isinstance(value.func, ast.Attribute) and value.func.attr == "read_text"): + return False + path = value.func.value + return (isinstance(path, ast.Call) and len(path.args) == 1 and not path.keywords + and isinstance(path.args[0], ast.Constant) and isinstance(path.args[0].value, str) + and resolver._constructor_reference(module, path.func, scopes).get("external_constructor") == "pathlib.Path") + + names = [item.arg for item in call.keywords] + if len(names) != len(set(names)): + return False + for item in call.keywords: + value = item.value + if item.arg in strings: + if not (isinstance(value, ast.Constant) and (isinstance(value.value, str) or value.value is None)): + return False + elif item.arg in booleans: + if not (isinstance(value, ast.Constant) and type(value.value) is bool): + return False + elif item.arg in objects: + if item.arg == "connection_params" and _adk_connection_call(resolver, module, value, scopes): + continue + if not (isinstance(value, ast.Constant) and value.value is None): + return False + elif item.arg == "tool_filter": + if not (isinstance(value, ast.Constant) and value.value is None) and not ( + isinstance(value, ast.List | ast.Tuple) + and all(isinstance(member, ast.Constant) and isinstance(member.value, str) for member in value.elts) + ): + return False + elif canonical in openapi and item.arg == "spec_str_type": + if not (isinstance(value, ast.Constant) and value.value in {"json", "yaml"}): + return False + elif canonical in openapi and item.arg == "spec_str": + if not (isinstance(value, ast.Constant) and isinstance(value.value, str)) and not local_text(value): + return False + elif canonical in openapi and item.arg == "spec_dict": + if not isinstance(value, ast.Dict) or not literal(value): + return False + else: + return False + return True + + +def _external_constructor_dependency_paths(family: str) -> set[str]: + """Protected dependencies, distinct from supported constructor classes.""" + shared = { + # These packages reexport mutable construction objects through public + # and private modules. Protect the canonical namespace rather than + # pretending an enumerated spelling proves an object's only alias. + "pydantic", "typing", "typing_extensions", "abc", "_collections_abc", + } | set(_FRAMEWORK_MODULES) | set(_STDLIB_SHARED_DEPENDENCY_PATHS) + if family == "google.adk": + return shared | { + "google.adk.agents.BaseAgent", "google.adk.agents.base_agent.BaseAgent", + "google.adk.workflow.BaseNode", "google.adk.workflow._base_node.BaseNode", + "google.adk.tools.BaseTool", "google.adk.tools.base_tool.BaseTool", + "google.adk.agents.llm_agent.BaseAgent", "google.adk.agents.base_agent.BaseNode", + "google.adk.agents.base_agent.BaseModel", "google.adk.agents.llm_agent.BaseModel", + "google.adk.workflow._base_node.BaseModel", "google.adk.tools.function_tool.BaseTool", + "google.adk.agents.llm_agent.BaseTool", "google.adk.agents.llm_agent.FunctionTool", + "google.adk.agents.base_agent.abc", "google.adk.agents.llm_agent.abc", + "google.adk.tools.base_tool.ABC", "google.adk.tools.base_tool.BaseModel", + "google.adk.tools.function_tool.pydantic", + "google.adk.tools.long_running_tool.LongRunningFunctionTool", + "google.adk.tools.function_tool", "google.adk.tools.base_tool", + "google.adk.utils.context_utils", "google.adk.utils.variant_utils", + "google.adk.tools._automatic_function_calling_util", + "google.adk.tools._function_tool_declarations", + "google.adk.tools._function_parameter_parse_util", "google.adk.tools._gemini_schema_util", + "google.genai", + } + return shared | { + "typing.Generic", "griffe", + family + ".agent", family + ".tool", family + ".function_schema", family + ".strict_schema", + family + ".AgentBase", family + ".agent.AgentBase", + family + ".FunctionTool", family + ".tool.FunctionTool", + family + ".agent.BaseModel", family + ".agent.Generic", + family + ".tool.BaseModel", family + ".tool.Generic", family + ".tool.typing", + family + ".tool.function_schema", family + ".function_schema.function_schema", + family + ".function_schema.FuncSchema", family + ".function_schema.BaseModel", + family + ".function_schema.create_model", + family + ".run_internal.agent_tool_configuration.assign_agent_tools", + family + ".run_internal.agent_tool_configuration", + } + + +def _external_constructor_dependency_modules(family: str) -> set[str]: + return {"pydantic", "abc", "_collections_abc", "collections", "typing", "typing_extensions"} | ({"google.genai", "mcp", "json", "yaml"} if family == "google.adk" else {"griffe", "dataclasses"}) + + +def _independent_foreign_class_slot_write( + resolver: ImportResolver, module: PythonModule, node: ast.expr, scopes: ScopeIndex, family: str, +) -> bool: + """One proven independent class slot, never a method object or namespace.""" + if not isinstance(node, ast.Attribute) or not isinstance(node.ctx, ast.Store): + return False + parent = scopes.parents.get(node) + if not ((isinstance(parent, ast.Assign) and parent.targets == [node]) + or (isinstance(parent, ast.AnnAssign) and parent.target is node)): + return False + allowed = ({f"{prefix}.AgentBase" for prefix in ("agents", "agents.agent", "openai_agents", "openai_agents.agent")} + if family == "google.adk" else + {f"{prefix}.BaseAgent" for prefix in ("google.adk.agents", "google.adk.agents.base_agent", "google.adk.agents.llm_agent")}) + slots = {"__new__", "__setattr__"} if family == "google.adk" else {"__init__", "__new__"} + parts = _dotted(node.value) + if node.attr not in slots or parts is None: + return False + from agents_shipgate.inputs.list_expressions import evaluation_site + if scopes.enclosing_bindings(evaluation_site(scopes, node), parts[0]): + return False + bindings = module.bindings.get(parts[0], []) + if (len(bindings) != 1 or not bindings[0].top_level or not isinstance(bindings[0].node, ast.alias) + or bindings[0].statement not in module.tree.body): + return False + canonical = resolver._constructor_reference(module, node.value, scopes).get("external_constructor") + if canonical not in allowed: + return False + foreign = next(root for root in _FRAMEWORK_MODULES if canonical.startswith(root + ".")) + binding = bindings[0] + imported = (binding.node.name if isinstance(binding.statement, ast.Import) + else _absolute_import_reference(binding.node, binding.statement)) + # A retained ancestor (import google / google.cloud) does not establish + # the projected ADK class or the foreign dependencies its import loads. + return imported is not None and (imported == foreign or imported.startswith(foreign + ".")) + + +def _unused_reflection_import( + resolver: ImportResolver, module: PythonModule, statement: ast.AST, calls: Any = None, +) -> bool: + """An unused canonical import grants no reflective handle to an owner. + + This discharges only the import-spelling check. The normal import closure, + namespace mutations and retained callable census still apply to the module. + A name this module never loads is still a module attribute: any importer + in the read scope (``from m import name``, ``import m``, ``from m import + *``, or through a re-export chain) can load it, so such a module is never + unused. That importer census is the caller census's own retaining set. + """ + if (not isinstance(statement, ast.Import | ast.ImportFrom) + or statement not in module.tree.body + or isinstance(statement, ast.ImportFrom) and statement.level): + return False + if not statement.names or any( + (statement.module if isinstance(statement, ast.ImportFrom) else alias.name) + not in {"builtins", "inspect"} or alias.name == "*" + for alias in statement.names + ): + return False + # A resolver owns immutable parsed trees for one read. Cache census data + # by the tree itself, keeping its identity alive; provider and binding + # proofs below remain specific to each import and resolver context. + if module.tree not in resolver._reflection_import_loads: + resolver._reflection_import_loads[module.tree] = frozenset( + node.id for node in ast.walk(module.tree) + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) + ) + loads = resolver._reflection_import_loads[module.tree] + for alias in statement.names: + name = alias.asname or (alias.name if isinstance(statement, ast.ImportFrom) + else alias.name.split(".", 1)[0]) + bindings = module.bindings.get(name, []) + if (name in loads or len(bindings) != 1 or bindings[0].node is not alias + or not bindings[0].top_level): + return False + try: + if resolver._external_provider_issue(module, statement, alias) is not None: + return False + except _Stop: + return False + return not _module_has_importers(resolver, module, statement, calls) + + +def _module_has_importers( + resolver: ImportResolver, module: PythonModule, statement: ast.Import | ast.ImportFrom, calls: Any = None, +) -> bool: + """Whether another module of the read scope may load this module's names. + + Uses the retaining census (:meth:`BuilderCalls.retaining_modules`): the + modules that import this one directly, by alias, by star or through a + re-exporting module. A census cut short by its bounds answers yes, so it + never discharges a reflective import. + """ + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + anchor = statement.names[0] + name = anchor.asname or (anchor.name if isinstance(statement, ast.ImportFrom) + else anchor.name.split(".", 1)[0]) + key = (module.tree, name) + cached = resolver._reflection_import_importers.get(key) + if cached is None: + try: + census = calls + if census is None: + if resolver._importer_census is None: + resolver._importer_census = BuilderCalls(resolver) + census = resolver._importer_census + retaining = census.retaining_modules(module, name, namespace_carriers=True) + cached = any(path != module.path for path in retaining) + except (CallLimit, _Stop): + cached = True + resolver._reflection_import_importers[key] = cached + return cached + + +def _namespace_copy_source_limit( + resolver: ImportResolver, module: PythonModule, family: str, +) -> str | None: + """Name incomplete source context for exact copies; grant no copy authority. + + A successful census deliberately leaves the normal reflection refusal in + place. Reading sources cannot establish an installed module's getter hooks + or the copied dictionary's lifetime and values. + """ + namespaces = {symbol.rsplit(".", 1)[0] for symbol in _external_constructor_paths(family)} + for statement in module.tree.body: + if not (isinstance(statement, ast.Assign) and len(statement.targets) == 1 + and isinstance(statement.targets[0], ast.Name)): + continue + allocation = statement.value + if not (isinstance(allocation, ast.Call) and isinstance(allocation.func, ast.Name) + and allocation.func.id == "dict" and len(allocation.args) == 1 and not allocation.keywords): + continue + getter = allocation.args[0] + if not (isinstance(getter, ast.Call) and isinstance(getter.func, ast.Name) + and getter.func.id == "vars" and len(getter.args) == 1 and not getter.keywords + and isinstance(getter.args[0], ast.Name)): + continue + if module.bindings.get("dict") or module.bindings.get("vars"): + continue + receiver = getter.args[0] + imported = module.bindings.get(receiver.id, []) + destination = module.bindings.get(statement.targets[0].id, []) + if not (len(imported) == 1 and imported[0].top_level + and isinstance(imported[0].node, ast.alias) + and isinstance(imported[0].statement, ast.Import) + and imported[0].node.name in namespaces + and (imported[0].node.asname or imported[0].node.name) == receiver.id + and imported[0].statement.lineno < statement.lineno + and len(destination) == 1 and destination[0].statement is statement): + continue + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + try: + issue = resolver._external_provider_issue(module, imported[0].statement, imported[0].node) + if issue is not None: + return f"namespace copy source context is unread in {module.ref}:{getter.lineno}: {issue}" + BuilderCalls(resolver).namespace_source_context(family) + except _Stop as stop: + return f"namespace copy source context is unread in {module.ref}:{getter.lineno}: {stop.detail}" + except CallLimit as stop: + return f"namespace copy source context is unread in {module.ref}:{getter.lineno}: {stop}" + break + return None + + +def _eager_annotation_owned( + resolver: ImportResolver, scopes: ScopeIndex, owner: ast.AST, family: str, canonical: str, +) -> bool: + """An undecorated function's annotation naming the agent class or a tool object. + + The class lands in the function's annotation dictionary and nowhere else; + that is a retained handle only if some module of the census can read the + dictionary, which :meth:`ImportResolver._annotation_introspection` refuses. + """ + function = owner + if isinstance(owner, ast.arg): + arguments = scopes.parents.get(owner) + function = scopes.parents.get(arguments) if arguments is not None else None + if not isinstance(function, ast.FunctionDef | ast.AsyncFunctionDef) or function.decorator_list: + return False + if not (canonical in _external_constructor_paths(family) + or _framework_tool_object(family, canonical) in {"object", "toolset"}): + return False + return resolver._annotation_introspection() is None + + +def _environment_value_role( + resolver: ImportResolver, module: PythonModule, read: ast.Subscript, scopes: ScopeIndex, family: str, +) -> bool: + """Where one ``os.environ[key]`` read lands, when that is a role this census checks. + + The read is a string, so the mapping goes nowhere. It is accepted only + in the two places a tool body or an agent module reads configuration: a + plain assignment to a local name, the data a framework tool object is + configured with (an MCP server's ``env``), or the data of an exact ADK or + ``mcp`` connection-parameter class that reaches an accepted toolset's + ``connection_params`` (``headers``, ``env``, and the nested + ``StdioConnectionParams(server_params=StdioServerParameters(env=...))`` + form). A read kept inside another container literal or handed to another + call stays an unread external handle, as the pinned application-diff + controls require. + """ + parent = scopes.parents.get(read) + if (isinstance(parent, ast.Assign) and parent.value is read and len(parent.targets) == 1 + and isinstance(parent.targets[0], ast.Name)): + return True + current: ast.AST = read + while isinstance(scopes.parents.get(current), ast.Dict | ast.List | ast.Tuple): + current = scopes.parents[current] + keyword = scopes.parents.get(current) + call = scopes.parents.get(keyword) if isinstance(keyword, ast.keyword) else None + if not isinstance(call, ast.Call): + return False + canonical = resolver._constructor_reference(module, call.func, scopes).get("external_constructor") + if isinstance(canonical, str) and _framework_tool_object(family, canonical) == "object": + return True + return family == "google.adk" and _connection_data_role(resolver, module, call, scopes) + + +def _connection_class_path( + resolver: ImportResolver, module: PythonModule, func: ast.expr, scopes: ScopeIndex, +) -> str | None: + """The exact import path a connection-parameter class is called through. + + ADK's own classes resolve as external constructors. The ``mcp`` package's + ``StdioServerParameters`` does not (the census names no ``mcp`` root), so + its import is read directly: one top-level absolute import, no local + rebinding, and no repository module providing ``mcp``. This only names the + class for the data route; the name itself stays an unread external handle + for every other use. + """ + outcome = resolver._constructor_reference(module, func, scopes) + canonical = outcome.get("external_constructor") + if isinstance(canonical, str): + return canonical + parts = _dotted(func) + if (parts is None or outcome.get("constructor_unread") != MODULE_NOT_FOUND + or not outcome.get("unread_external_handle")): + return None + from agents_shipgate.inputs.list_expressions import evaluation_site + + if scopes.enclosing_bindings(evaluation_site(scopes, func), parts[0]): + return None + bindings = module.bindings.get(parts[0], []) + if (len(bindings) != 1 or not bindings[0].top_level or not isinstance(bindings[0].node, ast.alias) + or not isinstance(bindings[0].statement, ast.Import | ast.ImportFrom)): + return None + imported = _absolute_import_reference(bindings[0].node, bindings[0].statement) + if imported is None or imported.split(".", 1)[0] != "mcp": + return None + try: + if resolver._external_provider_issue(module, bindings[0].statement, bindings[0].node) is not None: + return None + except _Stop: + return None + return imported + ("." + ".".join(parts[1:]) if parts[1:] else "") + + +def _connection_data_role( + resolver: ImportResolver, module: PythonModule, call: ast.Call, scopes: ScopeIndex, +) -> bool: + """Whether ``call`` is connection data that ends at an accepted toolset. + + ``call`` is an exact ADK or ``mcp`` connection-parameter class. It is + followed outward only through other such classes, each held directly as a + keyword value, up to the ``connection_params`` of a toolset call whose + own arguments are data (:func:`_adk_toolset_value_call`). Any other + enclosing call, container, or position leaves the read an unread handle. + """ + for _ in range(3): + canonical = _connection_class_path(resolver, module, call.func, scopes) + keyword = scopes.parents.get(call) + owner = scopes.parents.get(keyword) if isinstance(keyword, ast.keyword) else None + if not (isinstance(canonical, str) and _framework_connection_class(canonical) + and isinstance(keyword, ast.keyword) and isinstance(owner, ast.Call)): + return False + toolset = resolver._constructor_reference(module, owner.func, scopes).get("external_constructor") + if keyword.arg == "connection_params" and isinstance(toolset, str): + return _adk_toolset_value_call(resolver, module, owner, scopes, toolset) + call = owner + return False + + +def _stdlib_reference( + resolver: ImportResolver, module: PythonModule, node: ast.expr, scopes: ScopeIndex, +) -> str | None: + """The standard-library path a name is, by its one import and nothing else. + + The head is bound once, by an absolute import of a standard-library + module; no file the repository holds provides that module; and this + module does not store into it. A same-named local file, a shadowed or + reassigned name, a vendored copy or a non-standard package is None. + """ + from agents_shipgate.inputs.list_expressions import evaluation_site + + spelling = reference_spelling(node) + if spelling is None: + return None + head, _, rest = spelling.partition(".") + local = scopes.enclosing_bindings(evaluation_site(scopes, node), head) + found = local or [binding.node for binding in module.bindings.get(head, [])] + if module.star_import or len(found) != 1 or not isinstance(alias := found[0], ast.alias): + return None + statement = scopes.statement_of(alias) + if not isinstance(statement, ast.Import | ast.ImportFrom) or statement.lineno > node.lineno: + return None + imported = _absolute_import_reference(alias, statement) + if imported is None or imported.split(".", 1)[0] not in sys.stdlib_module_names: + return None + try: + if resolver._external_provider_issue(module, statement, alias) is not None: + return None + patched = resolver._runtime_attribute_patches(module) + except _Stop: + return None + if any(key == head or key.startswith(head + ".") or key.startswith("*") or key.startswith(SELF_PATCH) + for key in patched): + return None + return imported + ("." + rest if rest else "") + + +def _own_tool_object( + resolver: ImportResolver, module: PythonModule, node: ast.expr, parent: ast.AST | None, scopes: ScopeIndex, + family: str, kind: str, classes: set[str], callback_fields: set[str], +) -> bool: + """Register the ownership obligation for one framework tool object (#910). + + The object is accepted only in the role its exact import has: ADK's built-in + value as a member of a tools list, or the documented call that builds the + object. Nothing here proves a use safe; each registration is an obligation + the list reader discharges against every use of the object's result, as for + a toolset or a wrapped function. Uses not matched here stay refused. + """ + + def receiving(container: ast.AST) -> tuple[ast.keyword, ast.Call] | None: + keyword = scopes.parents.get(container) + receiver = scopes.parents.get(keyword) + if not (isinstance(keyword, ast.keyword) and isinstance(receiver, ast.Call)): + return None + expression = receiver.func.value if isinstance(receiver.func, ast.Subscript) else receiver.func + target = resolver._constructor_reference(module, expression, scopes) + return (keyword, receiver) if target.get("external_constructor") in classes else None + + if kind == "value": + container: ast.AST = node + while isinstance(scopes.parents.get(container), ast.List | ast.Tuple): + container = scopes.parents[container] + if container is node: + return False + found = receiving(container) + if found is None: + # A list kept for later: its container owner follows every use. + resolver._constructor_container_owners[id(container)] = (module, container) + elif found[0].arg == "tools": + resolver._constructor_namespace_owners[(family, id(found[1]), "tools")] = (module, found[1], "tools") + else: + return False # A built-in tool is a member of ``tools``, not of a callback list. + return True + if kind == "toolset" or not (isinstance(parent, ast.Call) and parent.func is node): + return False + if kind == "wrapper": + # ``function_tool(f, needs_approval=False)``: the function is checked as + # the wrapped operand where it stands; the rest is data. + if not (len(parent.args) == 1 and isinstance(parent.args[0], ast.Name | ast.Attribute) + and _tool_object_call_is_data(resolver, module, ast.Call( + func=parent.func, args=[], keywords=parent.keywords), scopes)): + return False + elif not _tool_object_call_is_data(resolver, module, parent, scopes): + return False + role = "tool_wrapper" if kind == "wrapper" else "toolset_data" + resolver._constructor_namespace_owners[(family, id(parent), role)] = (module, parent, role) + return True + + +def _external_constructor_use( + resolver: ImportResolver, module: PythonModule, family: str, retaining: set[Path], + *, allow_owner_routes: bool = True, +) -> str | None: + """Refuse changes or retention of the external class and its namespaces. + + A plain class/namespace alias already retains the object, so refusing that + use closes arbitrary alias chains without guessing how Python wires them. + This deliberately does not classify a read of class metadata as harmless: + a validator, annotations dictionary or callback can retain mutable state. + """ + scopes = resolver._constructor_scope(module) + typing_only = _type_checking_only(module, resolver, scopes) + if allow_owner_routes and (copy_limit := _namespace_copy_source_limit(resolver, module, family)): + return copy_limit + from agents_shipgate.inputs.list_expressions import evaluation_site + decorators = _external_decorator_paths(family) + callback_fields = { + "before_agent_callback", "after_agent_callback", "before_model_callback", "after_model_callback", + "before_tool_callback", "after_tool_callback", + } if family == "google.adk" else set() + machinery = {"eval", "exec", "compile", "__import__", "__builtins__", "__globals__", "__closure__", + "cell_contents", "__getattribute__", "__getattr__", "getattr", "setattr", "delattr", "vars", + "_getframe", "currentframe", "getouterframes", "getinnerframes", "stack", "f_globals", "f_locals", + "cr_frame", "gi_frame", "ag_frame", "tb_frame", "__class__", "type"} + + def environment_read(home: PythonModule, home_scopes: ScopeIndex, value: ast.expr) -> bool: + # ``os.getenv("NAME", "default")`` of the standard library: a string. + if not (isinstance(value, ast.Call) and 1 <= len(value.args) <= 2 and not value.keywords + and all(isinstance(item, ast.Constant) for item in value.args)): + return False + return (allow_owner_routes + and _stdlib_reference(resolver, home, value.func, home_scopes) in {"os.getenv", "os.environ.get"}) + + def dataclass_decorator(home: PythonModule, home_scopes: ScopeIndex, value: ast.expr) -> bool: + callee = value.func if isinstance(value, ast.Call) else value + if isinstance(value, ast.Call) and (value.args or any( + keyword.arg is None or not isinstance(keyword.value, ast.Constant) for keyword in value.keywords + )): + return False + return allow_owner_routes and _stdlib_reference(resolver, home, callee, home_scopes) == "dataclasses.dataclass" + + def class_literal(value: ast.expr | None, home: PythonModule = module, home_scopes: ScopeIndex = scopes) -> bool: + if value is None or isinstance(value, ast.Constant): + return True + if environment_read(home, home_scopes, value): + return True + if isinstance(value, ast.BoolOp): + return all(class_literal(item, home, home_scopes) for item in value.values) + if isinstance(value, ast.List | ast.Tuple | ast.Set): + return all(class_literal(item, home, home_scopes) for item in value.elts) + if isinstance(value, ast.Dict): + return all(key is not None and class_literal(key, home, home_scopes) and class_literal(item, home, home_scopes) + for key, item in zip(value.keys, value.values, strict=True)) + return False + + def plain_class(home: PythonModule, home_scopes: ScopeIndex, node: ast.ClassDef, *, methods: bool) -> bool: + """A class whose creation runs no hook and whose body is inert data.""" + plain_bases = all( + isinstance(base, ast.Name) and base.id == "object" + and not home_scopes.enclosing_bindings(evaluation_site(home_scopes, base), "object") + and not home.bindings.get("object") + for base in node.bases + ) + plain_body = all( + isinstance(statement, ast.Pass) + or (isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant) + and isinstance(statement.value.value, str)) + or (methods and isinstance(statement, ast.FunctionDef | ast.AsyncFunctionDef) and not statement.decorator_list) + or (isinstance(statement, ast.Assign | ast.AnnAssign) and class_literal(statement.value, home, home_scopes)) + for statement in node.body + ) + return (all(dataclass_decorator(home, home_scopes, item) for item in node.decorator_list) + and not node.keywords and not getattr(node, "type_params", []) and plain_bases and plain_body) + + def callback_reference(value: ast.expr) -> bool: + if not isinstance(value, ast.Name | ast.Attribute): + return False + outcome = resolver._constructor_reference(module, value, scopes) + definition = outcome.get("definition") + return (outcome.get("constructor_callable") is True + and isinstance(definition, ast.FunctionDef | ast.AsyncFunctionDef) + and not definition.decorator_list) + + for node in resolver._constructor_syntax(module).nodes: + if id(node) in typing_only: + continue + if replaces_builtin_namespace(node, scopes): + return f"constructor identity is unread through a replaced builtin namespace in {module.ref}:{node.lineno}" + if module.path in retaining and isinstance(node, ast.ClassDef): + # Class decorators, metaclasses and base-class hooks receive the + # newly created class, including methods carrying this namespace. + # Only the inert builtin object base is established by this entry. + if not plain_class(module, scopes, node, methods=True): + return f"an unread class construction hook receives the constructor namespace in {module.ref}:{node.lineno}" + if module.path in retaining and isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + for decorator in node.decorator_list: + callee = decorator.func if isinstance(decorator, ast.Call) else decorator + outcome = resolver._constructor_reference(module, callee, scopes) + if outcome.get("external_constructor") not in decorators: + return f"an unread decorator receives a function carrying the constructor namespace in {module.ref}:{node.lineno}" + if isinstance(decorator, ast.Call) and (decorator.args or any( + keyword.arg is None or not ( + isinstance(keyword.value, ast.Constant) + or keyword.arg == "is_enabled" and callback_reference(keyword.value) + ) + for keyword in decorator.keywords + )): + return f"the tool decorator's executable or unpacked configuration is unread in {module.ref}:{node.lineno}" + if module.path in retaining and isinstance(node, ast.Lambda | ast.GeneratorExp): + return f"an anonymous callable or generator retains the constructor namespace in {module.ref}:{node.lineno}" + if (isinstance(node, ast.Name | ast.Attribute) and isinstance(node.ctx, ast.Load) + and (node.id if isinstance(node, ast.Name) else node.attr) in machinery): + if isinstance(node, ast.Name) and node.id == "stack": + bindings = scopes.enclosing_bindings(evaluation_site(scopes, node), node.id) + bindings = bindings or [item.node for item in module.bindings.get(node.id, [])] + if bindings and all(not isinstance(binding, ast.alias) for binding in bindings): + continue # A lexical data binding is not inspect.stack. + if isinstance(node, ast.Name) and node.id in {"vars", "getattr"}: + if allow_owner_routes: + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + try: + if BuilderCalls(resolver).idempotent_source_slot(module, node, family): + continue # Only this getter callee's independently completed source-slot proof. + if BuilderCalls(resolver).absent_field_dictionary_getter(module, node, family): + continue # This getter has its own unread-field and zero-call receipt. + except CallLimit as exc: + return f"the source-module getter slot is unread in {module.ref}:{node.lineno}: {exc}" + bindings = scopes.enclosing_bindings(evaluation_site(scopes, node), node.id) + if node.id == "vars" and bindings and all(not isinstance(binding, ast.alias) for binding in bindings): + continue # Function-local shadowing cannot load builtin vars. + if allow_owner_routes and isinstance(node, ast.Name) and node.id == "vars": + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + try: + if BuilderCalls(resolver).source_saved_vars_initializer(module, node, family): + continue # Only this initializer's completed saved mapping Store proof. + except CallLimit as exc: + return f"the saved source getter initializer is unread in {module.ref}:{node.lineno}: {exc}" + if allow_owner_routes and isinstance(node, ast.Name) and node.id == "setattr": + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + try: + if BuilderCalls(resolver).idempotent_source_slot(module, node, family): + continue # Only this actual direct callee after its complete raw same-slot proof. + if BuilderCalls(resolver).source_bare_setter_initializer(module, node, family): + continue # Only this actual unshadowed bare initializer's completed same-slot proof. + except CallLimit as exc: + return f"the bare source setter initializer is unread in {module.ref}:{node.lineno}: {exc}" + if allow_owner_routes and isinstance(node, ast.Attribute) and node.attr == "setattr": + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + try: + if BuilderCalls(resolver).idempotent_source_slot(module, node, family): + continue # Only this exact qualified setter's independently completed same-slot proof. + if BuilderCalls(resolver).source_setter_initializer(module, node, family): + continue # Only this actual saved initializer's independent completed same-slot proof. + except CallLimit as exc: + return f"the source-module setter is unread in {module.ref}:{node.lineno}: {exc}" + return f"constructor identity is unread through executable or reflective machinery in {module.ref}:{node.lineno}" + if isinstance(node, ast.Import | ast.ImportFrom): + if _unused_reflection_import(resolver, module, node): + continue + names = [alias.name for alias in node.names] if isinstance(node, ast.Import) else [node.module or ""] + if any(name.split(".", 1)[0] in {"builtins", "importlib", "inspect"} for name in names): + if allow_owner_routes and isinstance(node, ast.ImportFrom): + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + try: + if BuilderCalls(resolver).source_from_setter_import(module, node, family): + continue # Only this actual from-setter import's complete independent same-slot proof. + except CallLimit as exc: + return f"the source setter alias import is unread in {module.ref}:{node.lineno}: {exc}" + if allow_owner_routes and isinstance(node, ast.Import): + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + try: + if BuilderCalls(resolver).source_primitive_import(module, node, family): + continue # This actual Import has its own completed source-slot admission. + if BuilderCalls(resolver).source_setter_import(module, node, family): + continue # Only this actual qualified setter Import's completed raw same-slot proof. + if BuilderCalls(resolver).fresh_dictionary_primitive_import(module, node, family): + continue # Only this actual Import's completed fresh Function-data receipt. + if BuilderCalls(resolver).absent_field_dictionary_primitive_import(module, node, family): + continue # Only this actual Import's completed unread-field data receipt. + except CallLimit as exc: + return f"the source dictionary primitive import is unread in {module.ref}:{node.lineno}: {exc}" + return f"constructor identity is unread through builtin or dynamic import machinery in {module.ref}:{node.lineno}" + if isinstance(node, ast.ImportFrom) and any(alias.name in machinery for alias in node.names): + return f"constructor identity is unread through imported reflection machinery in {module.ref}:{node.lineno}" + if reflective_access(module.tree, excluded=frozenset(typing_only)) is not None: + return f"constructor identity is unread through reflective namespace access in {module.ref}" + if module.star_import and module.path in retaining: + return f"a wildcard import may retain or replace the framework constructor in {module.ref}" + classes = _external_constructor_paths(family) | _external_wrapper_paths(family) + protected = classes | decorators | _external_constructor_dependency_paths(family) + + def scalar(expression: ast.expr | None) -> bool: + if expression is None or isinstance(expression, ast.Constant): + return True + if isinstance(expression, ast.List | ast.Tuple | ast.Set): + return all(scalar(item) for item in expression.elts) + if isinstance(expression, ast.Dict): + return all(key is not None and scalar(key) and scalar(value) + for key, value in zip(expression.keys, expression.values, strict=True)) + return False + + def ordinary_external_return_call(home: PythonModule, value: ast.Call, home_scopes: ScopeIndex, depth: int = 0) -> bool: + # Inline ordinary external call results already lie outside this + # finite namespace proof. A source-local helper can preserve that + # boundary, but an unresolved local callee or retained result cannot. + if depth >= 4: + return False + if isinstance(value.func, ast.Attribute) and isinstance(value.func.value, ast.Call): + if value.func.attr in machinery: + return False + return ordinary_external_return_call(home, value.func.value, home_scopes, depth + 1) + spelling = reference_spelling(value.func) + if spelling is None: + return False + head, _, rest = spelling.partition(".") + bindings = home_scopes.enclosing_bindings(evaluation_site(home_scopes, value.func), head) + bindings = bindings or [binding.node for binding in home.bindings.get(head, [])] + if home.star_import or len(bindings) != 1 or not isinstance(alias := bindings[0], ast.alias): + return False + statement = home_scopes.statement_of(alias) + if (not isinstance(statement, ast.Import | ast.ImportFrom) + or not isinstance(home_scopes.parents.get(statement), ast.Module | ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) + or statement.lineno > value.lineno): + return False + imported = _absolute_import_reference(alias, statement) + if imported is None: + return False + projected = imported + ("." + rest if rest else "") + if any(projected == path or projected.startswith(path + ".") or path.startswith(projected + ".") + for path in protected | _external_constructor_dependency_modules(family)): + return False + outcome = resolver._constructor_reference(home, value.func, home_scopes) + if outcome.get("constructor_unread") != MODULE_NOT_FOUND or outcome.get("unread_external_handle") is not True: + return False + try: + return resolver._external_provider_issue(home, statement, alias) is None + except _Stop: + return False + + def safe_function(home: PythonModule, function: ast.AST, depth: int = 0, seen: frozenset[int] = frozenset()) -> bool: + if (not isinstance(function, ast.FunctionDef) or function.decorator_list + or depth >= 4 or id(function) in seen + or any(isinstance(node, ast.Yield | ast.YieldFrom | ast.GeneratorExp) for node in ast.walk(function))): + return False + home_scopes = resolver._constructor_scope(home) + for statement in ast.walk(function): + if not isinstance(statement, ast.Return) or scalar(statement.value): + continue + enclosing = home_scopes.parents.get(statement) + while enclosing is not None and not isinstance(enclosing, ast.FunctionDef | ast.AsyncFunctionDef): + enclosing = home_scopes.parents.get(enclosing) + if enclosing is not function: + continue + value = statement.value + if allow_owner_routes and isinstance(value, ast.Name): + outcome = resolver._constructor_reference(home, value, home_scopes) + if isinstance(outcome.get("definition"), ast.FunctionDef | ast.AsyncFunctionDef): + continue + assigned = outcome.get("value") + if isinstance(assigned, ast.Call): + target = resolver._constructor_reference(home, assigned.func, home_scopes) + if target.get("external_constructor") in classes: + continue + if allow_owner_routes and isinstance(value, ast.List | ast.Tuple | ast.Dict): + continue # Every retained member and returned container is proved below. + if not isinstance(value, ast.Call): + return False + if ordinary_external_return_call(home, value, home_scopes): + continue # Only the call role; its arguments and every use remain checked. + if (allow_owner_routes and isinstance(value.func, ast.Attribute) and value.func.attr == "as_tool" + and isinstance(receiver := value.func.value, ast.Name) + and len(parameter := home_scopes.enclosing_bindings( + evaluation_site(home_scopes, receiver), receiver.id)) == 1 + and isinstance(parameter[0], ast.arg)): + # ``return agent.as_tool(...)`` on the function's own parameter. + # The tool object it builds has an owner registered where the + # agent handle is passed in; no namespace is returned here. + continue + callee = value.func.value if isinstance(value.func, ast.Subscript) else value.func + outcome = resolver._constructor_reference(home, callee, home_scopes) + if allow_owner_routes and outcome.get("external_constructor") in classes: + continue + if (allow_owner_routes and isinstance(tool_class := outcome.get("external_constructor"), str) + and _framework_tool_object(family, tool_class) in {"object", "toolset", "wrapper"}): + continue # A returned tool object has its own owner, registered where it is built. + defining, nested = outcome.get("module"), outcome.get("definition") + if not isinstance(defining, PythonModule) or not safe_function(defining, nested, depth + 1, seen | {id(function)}): + return False + return True + + unread_external_nodes: set[int] = set() + plain_instance_nodes: set[int] = set() + + def plain_instance(node: ast.expr, current: ast.expr, home: object, outcome: dict[str, Any]) -> bool: + # ``Settings()`` of an inert dataclass is an instance of plain data. It + # still carries its class, so its result has an owner like any handle. + if not (allow_owner_routes and current is node and isinstance(home, PythonModule) + and isinstance(definition := outcome.get("retained_class"), ast.ClassDef) + and plain_class(home, resolver._constructor_scope(home), definition, methods=False)): + return False + plain_instance_nodes.add(id(node)) + return True + + def references(node: ast.expr) -> tuple[set[str], bool, bool]: + spelling = reference_spelling(node) + head = spelling.split(".", 1)[0] if spelling else None + local = scopes.enclosing_bindings(evaluation_site(scopes, node), head) if head else [] + candidates = local or [binding.node for binding in module.bindings.get(head, [])] + if any(isinstance(binding, ast.alias) and resolver._unread_import_candidate( + module, scopes.statement_of(binding), binding + ) for binding in candidates): + unread_external_nodes.add(id(node)) + current = node + prefix_reads = 0 + while True: + if prefix_reads >= MAX_STEPS: + raise _Stop( + RESOLUTION_LIMIT, + f"constructor ownership is unread in {module.ref}:{node.lineno}: " + f"the unresolved attribute path exceeds {MAX_STEPS} prefix reads", + ) + prefix_reads += 1 + outcome = resolver._constructor_reference(module, current, scopes) + if outcome.get("unread_external_handle"): + unread_external_nodes.add(id(node)) + if outcome.get("local_constructor_provider") is not None: + raise _Stop(REBOUND_NAME, str(outcome["local_constructor_provider"])) + if outcome.get("constructor_unread") in {RESOLUTION_LIMIT, LINKED_MODULE, UNREADABLE_MODULE, OUTSIDE_SCOPE, AMBIGUOUS_MODULE, IMPORT_CYCLE}: + raise _Stop(str(outcome["constructor_unread"]), str(outcome["detail"])) + external = outcome.get("external_constructor") + if isinstance(external, str): + if current is not node: + spelling, prefix = reference_spelling(node), reference_spelling(current) + if spelling is not None and prefix is not None and spelling.startswith(prefix + "."): + external += spelling[len(prefix):] + return {external}, False, False + namespace = outcome.get("retained_namespace") + home = outcome.get("module") + namespace_holder = outcome.get("definition") is not None or outcome.get("retained_class") is not None + path = namespace if isinstance(namespace, Path) else home.path if isinstance(home, PythonModule) and namespace_holder else None + if path is not None and (path in retaining or any(item.is_relative_to(path) for item in retaining)): + return {family}, ( + current is node and outcome.get("constructor_callable") is True + and isinstance(home, PythonModule) and safe_function(home, outcome.get("definition")) + ) or plain_instance(node, current, home, outcome), current is node and outcome.get("constructor_callable") is True + if (current is node and isinstance(node.ctx, ast.Load) and isinstance(home, PythonModule) + and isinstance(outcome.get("value"), ast.Constant)): + unread_external_nodes.discard(id(node)) + return set(), False, False # An exact repository literal is lexical data. + if not isinstance(current, ast.Attribute): + spelling = reference_spelling(node) + head = spelling.split(".", 1)[0] if spelling else None + local = scopes.enclosing_bindings(evaluation_site(scopes, node), head) if head else [] + found = local or [binding.node for binding in module.bindings.get(head, [])] + if isinstance(node, ast.Name) and not local and len(found) == 2: + definition, target = found + statement = scopes.statement_of(target) + wrapper = getattr(statement, "value", None) + body = module.tree.body + if (isinstance(definition, ast.FunctionDef | ast.AsyncFunctionDef) + and not definition.decorator_list and isinstance(target, ast.Name) + and isinstance(statement, ast.Assign) and statement.targets == [target] + and definition in body and statement in body + and body.index(statement) == body.index(definition) + 1 + and isinstance(wrapper, ast.Call) and not wrapper.args + and len(wrapper.keywords) == 1 and wrapper.keywords[0].arg == "func" + and isinstance(operand := wrapper.keywords[0].value, ast.Name) + and operand.id == node.id + and resolver._constructor_reference(module, wrapper.func, scopes).get("external_constructor") + in _external_wrapper_paths(family)): + resolver._constructor_wrapped_operands.add(id(operand)) + if node is operand: + return {family}, False, True + if node.lineno > statement.lineno: + # The wrapper's actual handle is checked by its + # registered constructor owner, including all uses. + return set(), False, False + possible = {path + spelling[len(head):] for binding in found if isinstance(binding, ast.alias) + if (path := _absolute_import_reference(binding, scopes.statement_of(binding))) is not None} + def possible_holder(bindings: list[ast.AST], depth: int = 0, seen: frozenset[int] = frozenset()) -> bool: + if depth >= 4: + return True # An unfinished alias proof cannot clear a namespace. + for binding in bindings: + if id(binding) in seen: + return True + if isinstance(binding, ast.alias) and resolver._unread_import_candidate( + module, scopes.statement_of(binding), binding + ): + unread_external_nodes.add(id(node)) + if isinstance(binding, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef): + return True + statement = scopes.statement_of(binding) + value = getattr(statement, "value", None) + alias = reference_spelling(value) if isinstance(value, ast.expr) else None + if isinstance(binding, ast.Name) and alias is not None: + target = alias.split(".", 1)[0] + candidates = scopes.enclosing_bindings(evaluation_site(scopes, value), target) + candidates = candidates or [item.node for item in module.bindings.get(target, [])] + if possible_holder(candidates, depth + 1, seen | {id(binding)}): + return True + return False + holder = possible_holder(found) + if module.path in retaining and holder: + possible.add(family) + if outcome.get("constructor_unread") is not None: + for binding in found: + statement = scopes.statement_of(binding) + if isinstance(binding, ast.alias) and isinstance(statement, ast.Import | ast.ImportFrom): + paths, _ = resolver._imported_paths(module, statement) + if any(path in retaining or any(item.is_relative_to(path) for item in retaining) for path in paths): + possible.add(family) + ambiguous_functions = bool(found) and all(isinstance(binding, ast.FunctionDef | ast.AsyncFunctionDef) for binding in found) + # This does not resolve the tool's identity. It only permits + # its finite candidate functions to remain in a proven owning + # field, so their ambiguous name cannot poison other agents. + return possible, False, ambiguous_functions and current is node + current = current.value + + deferred = any(isinstance(statement, ast.ImportFrom) and statement.module == "__future__" + and any(alias.name == "annotations" for alias in statement.names) + for statement in module.tree.body) + for node in resolver._constructor_syntax(module).references: + if not isinstance(node, ast.Name | ast.Attribute) or id(node) in typing_only: + continue + if isinstance(node, ast.Name) and not isinstance(node.ctx, ast.Load): + continue # A local rebinding/deletion does not mutate the imported object. + parent = scopes.parents.get(node) + if isinstance(parent, ast.Attribute) and parent.value is node: + continue # The terminal spelling decides; do not repeatedly resolve every prefix. + paths, known_function, function_holder = references(node) + relevant = {path for path in paths if any( + path == constructor or constructor.startswith(path + ".") or path.startswith(constructor + ".") + for constructor in protected + )} + # A framework or dependency helper can participate in construction without being a + # supported receiving class. Mutating its external namespace cannot + # prove the original constructor; ordinary unrelated reads stay outside + # this dependency set. + framework_mutation = not isinstance(node.ctx, ast.Load) and any( + path == root or path.startswith(root + ".") or root.startswith(path + ".") + for path in paths + for root in {family} | _external_constructor_dependency_modules(family) + ) + if not relevant and not framework_mutation: + if id(node) not in unread_external_nodes: + continue + parent = scopes.parents.get(node) + if isinstance(parent, ast.Attribute) and parent.value is node: + continue + if allow_owner_routes and isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load): + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + try: + if BuilderCalls(resolver).source_setter_initializer(module, node, family): + continue # Only this actual saved initializer's complete independent same-slot proof. + if BuilderCalls(resolver).source_saved_bound_ior_initializer(module, node, family): + continue # Only this actual saved method initializer's completed ownership proof. + except CallLimit as exc: + return f"the saved source setter initializer is unread in {module.ref}:{node.lineno}: {exc}" + if allow_owner_routes and isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Store): + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + try: + if BuilderCalls(resolver).operator_metadata_write(module, node, family): + continue # Only this actual Store's explicit independent completed admission. + except CallLimit as exc: + return f"the operator metadata write is unread in {module.ref}:{node.lineno}: {exc}" + if isinstance(node.ctx, ast.Load) and ( + isinstance(parent, ast.Expr) or isinstance(parent, ast.Call) and parent.func is node + ): + continue # Ordinary external callees/results retain their existing boundary. + if allow_owner_routes and (stdlib := _stdlib_reference(resolver, module, node, scopes)) is not None: + if (stdlib == "os.environ" and isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load) + and isinstance(parent, ast.Subscript) and parent.value is node + and isinstance(parent.ctx, ast.Load) + and _environment_value_role(resolver, module, parent, scopes, family)): + continue # A read of one environment value is a string; the mapping goes nowhere. + if (stdlib == "dataclasses.dataclass" and isinstance(parent, ast.ClassDef) + and node in parent.decorator_list): + continue # The class hook itself is checked, with the class, where it stands. + annotation: ast.AST = node + while isinstance(scopes.parents.get(annotation), ast.Subscript | ast.BinOp | ast.Tuple | ast.List): + annotation = scopes.parents[annotation] + owner = scopes.parents.get(annotation) + if deferred and ((isinstance(owner, ast.arg | ast.AnnAssign) and owner.annotation is annotation) + or isinstance(owner, ast.FunctionDef | ast.AsyncFunctionDef) and owner.returns is annotation): + continue + return f"an unread external imported handle is retained or changed in {module.ref}:{node.lineno}" + parent = scopes.parents.get(node) + if isinstance(parent, ast.Attribute) and parent.value is node: + continue # The longest spelling's own use decides. + if (allow_owner_routes and isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load) + and node.attr == "__ior__"): + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + try: + if BuilderCalls(resolver).source_saved_bound_ior_initializer(module, node, family): + continue # Only this selected saved method's independent completed same-slot proof. + except CallLimit as exc: + return f"the saved source method initializer is unread in {module.ref}:{node.lineno}: {exc}" + if allow_owner_routes and (function_holder or isinstance(node, ast.Name) + or isinstance(node, ast.Attribute) and node.attr == "__dict__"): + from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit + + try: + if BuilderCalls(resolver).idempotent_source_slot(module, node, family): + continue # Only this exact completed source-function declaration edge. + if isinstance(node, ast.Attribute) and BuilderCalls(resolver).absent_field_dictionary_projection(module, node, family): + continue # Only the selected intrinsic projection with its own completed unread-field receipt. + if isinstance(node, ast.Name) and BuilderCalls(resolver).absent_field_dictionary_namespace(module, node, family): + continue # Only the exact getter argument with its own completed unread-field receipt. + except CallLimit as exc: + return f"the source-module slot is unread in {module.ref}:{node.lineno}: {exc}" + if not isinstance(node.ctx, ast.Load): + if _independent_foreign_class_slot_write(resolver, module, node, scopes, family): + continue # Every RHS and other use remains independently checked. + return f"a framework constructor or namespace is changed in {module.ref}:{node.lineno}" + if (isinstance(parent, ast.If) and parent.test is node + and _canonical_type_checking_test(module, node, scopes, resolver)): + continue # A false boolean guard grants no retained typing handle. + if (family == "google.adk" and paths <= {"pydantic.Field", "pydantic.fields.Field"} + and resolver._constructor_reference(module, node, scopes).get("external_constructor") in paths + and isinstance(parent, ast.Call) and parent.func is node + and not parent.args and len(parent.keywords) == 1 + and parent.keywords[0].arg == "default" + and isinstance(parent.keywords[0].value, ast.Constant) + and parent.keywords[0].value.value is None): + # A narrow value call still creates a shared-class-carrying handle. + # Its distinct data role proves every result use without borrowing + # Agent capability fields or builtin-container method allowances. + resolver._constructor_namespace_owners[(family, id(parent), "field_data")] = (module, parent, "field_data") + continue + decorated = parent + if isinstance(parent, ast.Call) and parent.func is node: + decorated = scopes.parents.get(parent) + if (relevant <= decorators and isinstance(decorated, ast.FunctionDef | ast.AsyncFunctionDef) + and (node in decorated.decorator_list or parent in decorated.decorator_list)): + if not allow_owner_routes: + return f"constructor decorator ownership is unread in {module.ref}:{node.lineno}" + continue # The unchanged SDK decorator owns this implicit callback edge. + retained = resolver._constructor_reference(module, node, scopes) + if isinstance(retained.get("value"), ast.List | ast.Tuple) and isinstance(retained.get("module"), PythonModule): + # A namespace-qualified literal holder is a container obligation, + # never permission to export the surrounding module namespace. + resolver._constructor_container_owners[id(retained["value"])] = (retained["module"], retained["value"]) + continue + if isinstance(parent, ast.Call) and parent.func is node and id(node) in plain_instance_nodes: + resolver._constructor_namespace_owners[(family, id(parent), "instance_data")] = (module, parent, "instance_data") + continue # An instance of inert data; every use of the result is read by its owner. + if isinstance(parent, ast.Call) and parent.func is node and (relevant <= classes or known_function): + if relevant <= classes: + role = "__class__" if relevant <= _external_constructor_paths(family) else "wrapper_class" + resolver._constructor_namespace_owners[(family, id(parent), role)] = (module, parent, role) + continue + if (family == "google.adk" and allow_owner_routes and isinstance(parent, ast.Call) and parent.func is node + and isinstance(canonical := resolver._constructor_reference(module, node, scopes).get("external_constructor"), str) + and paths == {canonical} and _adk_toolset_value_call(resolver, module, parent, scopes, canonical)): + resolver._constructor_namespace_owners[(family, id(parent), "toolset_data")] = (module, parent, "toolset_data") + continue + if (family == "google.adk" and isinstance(parent, ast.Call) and parent.func is node + and isinstance(canonical := retained.get("external_constructor"), str) + and paths == {canonical} and _framework_connection_class(canonical) + and _connection_data_role(resolver, module, parent, scopes)): + continue # The toolset call is the one data role; this is its connection data. + if (allow_owner_routes and isinstance(canonical := retained.get("external_constructor"), str) + and paths == {canonical} and (kind := _framework_tool_object(family, canonical)) is not None + and _own_tool_object(resolver, module, node, parent, scopes, family, kind, classes, callback_fields)): + continue # An exact framework tool object whose every use its owner census checks. + if (allow_owner_routes and isinstance(node, ast.Name) and isinstance(parent, ast.Assign) + and parent.value is node and len(parent.targets) == 1 + and isinstance(parent.targets[0], ast.Name) + and isinstance(scopes.parents.get(parent), ast.Module)): + imported = module.bindings.get(node.id, []) + if (len(imported) == 1 and isinstance(imported[0].node, ast.alias) + and isinstance(imported[0].statement, ast.Import)): + resolver._constructor_unused_namespace_sinks[id(node)] = (module, node) + continue # Only an obligation; provider, destination and exports must be proved. + literal_allocation = ( + isinstance(parent, ast.Dict) and node in parent.values + and isinstance(allocation := scopes.parents.get(parent), ast.Assign) + and allocation.value is parent and len(allocation.targets) == 1 + and isinstance(allocation.targets[0], ast.Name) + and isinstance(scopes.parents.get(allocation), ast.Module) + ) + if (allow_owner_routes and literal_allocation + and isinstance(canonical := retained.get("external_constructor"), str) + and paths == {canonical} and canonical in _external_constructor_paths(family)): + # A passive class token has its own data obligation. It grants no + # constructor call, result, function or surrounding namespace role. + resolver._constructor_dictionary_class_sinks[(family, id(node))] = (module, node, family, canonical) + continue + if function_holder: + dictionary_value = (isinstance(parent, ast.Assign) and parent.value is node + and len(parent.targets) == 1 and isinstance(parent.targets[0], ast.Subscript)) + if isinstance(parent, ast.keyword) and parent.arg is not None: + receiver = scopes.parents.get(parent) + dictionary_value |= (isinstance(receiver, ast.Call) and isinstance(receiver.func, ast.Attribute) + and receiver.func.attr in {"update", "__init__"}) + if isinstance(parent, ast.Dict) and node in parent.values: + receiver = scopes.parents.get(parent) + dictionary_value |= ( + literal_allocation + or isinstance(receiver, ast.AugAssign) and isinstance(receiver.op, ast.BitOr) + or isinstance(receiver, ast.Call) and isinstance(receiver.func, ast.Attribute) + and receiver.func.attr in {"update", "__init__"} and receiver.args == [parent] + ) + if (allow_owner_routes and dictionary_value + and isinstance(retained.get("definition"), ast.FunctionDef | ast.AsyncFunctionDef)): + # A candidate data edge, never a grant. The list reader must + # independently prove dictionary confinement and then every + # remaining use of the actual function carrying this namespace. + resolver._constructor_dictionary_sinks[(family, id(node))] = (module, node, family) + continue + if (isinstance(parent, ast.keyword) and parent.arg == "is_enabled" + and isinstance(receiver := scopes.parents.get(parent), ast.Call) + and isinstance(owner := scopes.parents.get(receiver), ast.FunctionDef | ast.AsyncFunctionDef) + and receiver in owner.decorator_list + and resolver._constructor_reference(module, receiver.func, scopes).get("external_constructor") in decorators): + resolver._constructor_decorator_owners[id(owner)] = (module, owner) + continue # Prove all uses of the actual decorated tool handle. + if (isinstance(parent, ast.Call) and isinstance(parent.func, ast.Attribute) + and parent.func.attr in {"append", "extend", "insert"} and node in parent.args): + # This is only a deferred obligation. The list reader must + # prove the actual receiver's builtin shape and every use. + resolver._constructor_member_sinks[id(parent.func.value)] = (module, parent.func.value) + continue + if isinstance(parent, ast.Call) and parent.args == [node]: + target = resolver._constructor_reference(module, parent.func, scopes) + wrapper = target.get("external_constructor") + if wrapper in _external_wrapper_paths(family): + resolver._constructor_namespace_owners[(family, id(parent), "func")] = (module, parent, "func") + continue + if isinstance(wrapper, str) and _framework_tool_object(family, wrapper) == "wrapper": + # The SDK's ``function_tool(f)``: the operand is the wrapped function and + # its result is the tool object, one owner for both. + resolver._constructor_namespace_owners[(family, id(parent), "tool_wrapper")] = (module, parent, "tool_wrapper") + continue + container: ast.AST = node + while isinstance(scopes.parents.get(container), ast.List | ast.Tuple): + container = scopes.parents[container] + keyword = scopes.parents.get(container) + receiver = scopes.parents.get(keyword) + if (isinstance(keyword, ast.keyword) and keyword.arg in {"tools", "handoffs", "sub_agents", "mcp_servers", "func"} | callback_fields + and isinstance(receiver, ast.Call)): + expression = receiver.func.value if isinstance(receiver.func, ast.Subscript) else receiver.func + target = resolver._constructor_reference(module, expression, scopes) + if target.get("external_constructor") in classes: + resolver._constructor_namespace_owners[(family, id(receiver), keyword.arg)] = (module, receiver, keyword.arg) + continue # ListExpressions proves ownership of this receiving handle. + if isinstance(container, ast.List | ast.Tuple): + resolver._constructor_container_owners[id(container)] = (module, container) + continue # Follow every use of this container, including unselected lists. + if isinstance(parent, ast.Return): + resolver._constructor_container_owners[id(node)] = (module, node) + continue # Follow the factory result through its complete caller set. + if isinstance(parent, ast.Subscript) and parent.value is node and relevant <= classes: + outer = scopes.parents.get(parent) + if isinstance(outer, ast.Call) and outer.func is parent: + role = "__class__" if relevant <= _external_constructor_paths(family) else "wrapper_class" + resolver._constructor_namespace_owners[(family, id(outer), role)] = (module, outer, role) + continue + annotation: ast.AST = node + while isinstance(scopes.parents.get(annotation), ast.Subscript | ast.BinOp | ast.Tuple | ast.List): + annotation = scopes.parents[annotation] + owner = scopes.parents.get(annotation) + if ((isinstance(owner, ast.arg | ast.AnnAssign) and owner.annotation is annotation) + or (isinstance(owner, ast.FunctionDef | ast.AsyncFunctionDef) and owner.returns is annotation)): + if deferred: + continue + canonical = resolver._constructor_reference(module, node, scopes).get("external_constructor") + if (canonical in {f"typing.{name}" for name in _TYPING_ANNOTATION_VALUES} + and paths == {canonical}): + continue + if (family == "google.adk" and allow_owner_routes and isinstance(owner, ast.arg) + and owner.annotation is node and paths == {canonical} + and canonical in {"google.adk.tools.ToolContext", "google.adk.tools.tool_context.ToolContext"}): + continue # ADK identifies this exact context type; it does not construct it. + if (allow_owner_routes and annotation is node and isinstance(canonical, str) and paths == {canonical} + and _eager_annotation_owned(resolver, scopes, owner, family, canonical)): + continue # Nothing in the census reads the annotation dictionary it lands in. + return f"an eager annotation retains the framework constructor in {module.ref}:{node.lineno}" + if isinstance(parent, ast.Expr): + continue # A bare expression does not hand the object on. + return f"a framework constructor or namespace is retained or used opaquely in {module.ref}:{node.lineno}" + return None def _module_table_key(key: ast.AST | None) -> str: @@ -2121,12 +4561,112 @@ class _AboveScope: patched: dict[str, list[tuple[str, int]]] = field(default_factory=dict) tables: list[tuple[str, int, str]] = field(default_factory=list) unread: list[str] = field(default_factory=list) + #: Actual enclosing-package import closure, retaining raw canonical imports + #: even when the repository does not provide their external modules. + modules: dict[str, PythonModule] = field(default_factory=dict) #: In-scope modules they import, by repository path: the in-scope reader #: reads these as it reads the chain's own modules. inscope: list[str] = field(default_factory=list) -def _attribute_patches(tree: ast.Module) -> dict[str, int]: +def _import_search_effects( + tree: ast.Module, *, excluded: frozenset[int] = frozenset(), + projections: dict[ast.AST, str] | None = None, + scopes: ScopeIndex | None = None, +) -> dict[ast.AST, str]: + """Actual imported search handles whose effects or retention are unread. + + This is syntax and lexical identity only. It neither invokes a receiving + proof nor grants a native callable role. Projection rows must come from + the private source-import identity walk, never inferred attribute spelling. + """ + from agents_shipgate.inputs.list_expressions import evaluation_site + + scopes = scopes or ScopeIndex(tree) + bindings = { + name: [binding.node for binding in group] + for name, group in _module_bindings(tree)[0].items() + } + # Lists are the actual lexical groups; retain each anchor for this invocation. + # This is a structural import projection, never a receiving or purity proof. + imports_by_group: dict[int, tuple[list[ast.AST], tuple[str, ...]]] = {} + effects: dict[ast.AST, str] = {} + sensitive = {"path", "meta_path", "path_hooks", "path_importer_cache"} + + def watched(path: str) -> bool: + return path == "sys" or path == "site" or path.startswith("site.") or any( + path == "sys." + field or path.startswith("sys." + field + ".") + for field in sensitive | {"__dict__", "__class__", "__getattr__", "__getattribute__", "__setattr__"} + ) + + def read_only(node: ast.AST, path: str) -> bool: + parent = scopes.parents.get(node) + if isinstance(parent, ast.Expr): + return path in {"sys", "site"} or path in {"sys." + field for field in sensitive} + if path not in {"sys." + field for field in sensitive}: + return False + if isinstance(parent, ast.Subscript) and parent.value is node: + return (isinstance(parent.ctx, ast.Load) and isinstance(parent.slice, ast.Constant) + and type(parent.slice.value) is int and isinstance(scopes.parents.get(parent), ast.Expr)) + if isinstance(parent, ast.Compare) and len(parent.ops) == 1: + return (isinstance(parent.ops[0], ast.In | ast.NotIn) and parent.comparators == [node] + and isinstance(parent.left, ast.Constant) and isinstance(parent.left.value, str)) + return False + + def imported_paths(group: list[ast.AST]) -> tuple[str, ...]: + key = id(group) + cached = imports_by_group.get(key) + if cached is not None: + assert cached[0] is group + return cached[1] + paths = set() + for candidate in group: + if isinstance(candidate, ast.alias) and id(candidate) not in excluded: + imported = _absolute_import_reference(candidate, scopes.statement_of(candidate)) + if imported is not None and watched(imported): + paths.add(imported) + result = tuple(sorted(paths)) + imports_by_group[key] = (group, result) + return result + + for node in ast.walk(tree): + if id(node) in excluded: + continue + if isinstance(node, ast.alias): + statement = scopes.statement_of(node) + if isinstance(statement, ast.ImportFrom) and not statement.level: + path = _absolute_import_reference(node, statement) + if path is not None and watched(path): + effects[node] = path # An imported handle can be reexported without a Name read. + continue + if not isinstance(node, ast.Name | ast.Attribute): + continue + parent = scopes.parents.get(node) + if isinstance(parent, ast.Attribute) and parent.value is node: + continue # One maximal reference, never repeated dotted prefixes. + parts = _dotted(node) + if parts is None: + continue + candidates = scopes.enclosing_bindings(evaluation_site(scopes, node), parts[0]) + if not candidates: + candidates = bindings.get(parts[0], []) + projected = projections.get(node) if projections else None + if projected is not None and watched(projected) and not read_only(node, projected): + effects[node] = projected + continue + suffix = "." + ".".join(parts[1:]) if len(parts) > 1 else "" + for imported in imported_paths(candidates): + path = imported + suffix + if watched(path) and not read_only(node, path): + effects[node] = path + break + return effects + + +def _attribute_patches( + tree: ast.Module, *, excluded: frozenset[int] = frozenset(), + patch_producers: dict[str, set[ast.AST | None]] | None = None, +) -> dict[str, int]: """What running a module may reassign, as ``key -> line``. ``a.b`` for an attribute store; ``*=...`` / ``*self...`` / ``*?`` for a @@ -2134,7 +4674,9 @@ def _attribute_patches(tree: ast.Module) -> dict[str, int]: its own ``name`` through ``globals()`` or ``sys.modules[__name__]``; ```` for a change to ``__path__``. A use of ``sys.modules`` or ``globals()`` that is not a known read counts as a computed store: what it - changes is not read (#879 review). + changes is not read (#879 review). Optional producer evidence records every + origin; None means an origin this reader does not identify. It never changes + the ordinary marker map or its first line. """ patches: dict[str, int] = {} @@ -2212,8 +4754,10 @@ def is_namespace(node: ast.AST) -> bool: return node.func.id in {"globals", "vars"} and builtin(node.func) return reference_spelling(node.func) in {"builtins.globals", "builtins.vars"} - def record(key: str, line: int) -> None: + def record(key: str, line: int, *, producer: ast.AST | None = None) -> None: patches.setdefault(key, line) + if patch_producers is not None: + patch_producers.setdefault(key, set()).add(producer) def read_elsewhere(node: ast.AST, parent: ast.AST | None) -> bool: """``set(globals())``, ``for name in sys.modules``, ``x in globals()``, @@ -2339,6 +4883,8 @@ def object_use(obj: ast.AST, line: int) -> None: if isinstance(target, ast.Name) } for node in ast.walk(tree): + if id(node) in excluded: + continue line = int(getattr(node, "lineno", 0) or 0) parent = parents.get(node) # -- sys.modules: reads allowed, anything else a store --------------- @@ -2498,7 +5044,7 @@ def object_use(obj: ast.AST, line: int) -> None: and not (isinstance(parent, ast.Call) and parent.func is node) and builtin(node) ): - record(MODULE_TABLE_COMPUTED, line) + record(MODULE_TABLE_COMPUTED, line, producer=node) continue targets: list[ast.AST] = [] if isinstance(node, ast.Assign): @@ -2558,15 +5104,75 @@ def object_use(obj: ast.AST, line: int) -> None: record(".".join([*holder_dotted, attribute]), node.lineno) else: record(SELF_PATCH + attribute, node.lineno) + for producer in _import_search_effects(tree, excluded=excluded): + record(IMPORT_SEARCH_PATCH, producer.lineno, producer=producer) return patches +def _binding_nodes( + roots: list[ast.AST], *, enter_bodies: bool, +) -> Iterator[tuple[ast.AST, bool, ast.stmt | None]]: + """Walk binding sites once, keeping eager headers and walrus owners apart. + + Ordinary comprehension targets belong to their implicit scope. A walrus + target instead belongs to the surrounding non-comprehension scope. A + nested definition's header runs in its surrounding scope, while its body + introduces a new one. The module census enters bodies to retain global + declarations; an individual lexical census only enters its own body. + """ + stack: list[tuple[ast.AST, bool, bool, ast.stmt | None]] = [ + (node, False, False, None) for node in reversed(roots) + ] + while stack: + node, nested, walrus_nested, enclosing = stack.pop() + statement = node if isinstance(node, ast.stmt) else enclosing + yield node, nested, statement + children: list[tuple[ast.AST, bool, bool, ast.stmt | None]] = [] + for field_name, value in ast.iter_fields(node): + values = value if isinstance(value, list) else [value] + for child in values: + if not isinstance(child, ast.AST): + continue + child_nested, child_walrus_nested = nested, walrus_nested + generic = bool(getattr(node, "type_params", ())) + if generic and isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and isinstance(child, ast.arguments): + # Generic annotations have an annotation scope; defaults + # still execute where the definition is made (PEP 695). + for argument_field, arguments in ast.iter_fields(child): + for argument in arguments if isinstance(arguments, list) else [arguments]: + if isinstance(argument, ast.AST): + annotation_scope = argument_field not in {"defaults", "kw_defaults"} + if enter_bodies or not annotation_scope: + children.append((argument, True if annotation_scope else nested, + True if annotation_scope else walrus_nested, statement)) + continue + if isinstance(node, ast.NamedExpr) and field_name == "target": + child_nested = walrus_nested + elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda): + annotation_scope = generic and ( + isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and field_name == "returns" + or isinstance(node, ast.ClassDef) and field_name in {"bases", "keywords"} + ) + if field_name in {"body", "type_params"} or annotation_scope: + if not enter_bodies: + continue + child_nested = child_walrus_nested = True + elif hasattr(ast, "TypeAlias") and isinstance(node, ast.TypeAlias) and field_name in {"value", "type_params"}: + if not enter_bodies: + continue + child_nested = child_walrus_nested = True + elif isinstance(node, ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp): + child_nested = True + children.append((child, child_nested, child_walrus_nested, statement)) + stack.extend(reversed(children)) + + def _module_bindings(tree: ast.Module) -> tuple[dict[str, list[_Binding]], bool]: """Every module-scope binding of every name, and whether ``*`` is imported. - Function, class, lambda and comprehension bodies bind their own scopes and - are skipped, except that ``global name`` inside them rebinds the module's - ``name`` out of view — which is recorded, so it can never be proven. + Function, class and lambda bodies bind their own scopes. Their eager + headers and comprehension walruses can bind the surrounding module. + ``global name`` inside a body is also recorded as an uncertain binding. One traversal carries each node's nearest statement and whether it is inside a nested scope, so the cost is linear in the tree: walking up a @@ -2583,12 +5189,7 @@ def record(name: str, node: ast.AST, statement: ast.stmt, *, top: bool) -> None: _Binding(node=node, statement=statement, top_level=top and id(statement) in body) ) - stack: list[tuple[ast.AST, bool, ast.stmt | None]] = [ - (child, False, None) for child in reversed(tree.body) - ] - while stack: - node, nested, enclosing = stack.pop() - statement = node if isinstance(node, ast.stmt) else enclosing + for node, nested, statement in _binding_nodes(list(tree.body), enter_bodies=True): if isinstance(node, ast.Global): for name in node.names: record(name, node, node, top=False) @@ -2615,9 +5216,6 @@ def record(name: str, node: ast.AST, statement: ast.stmt, *, top: bool) -> None: record(node.name, node, statement, top=False) elif isinstance(node, ast.MatchMapping) and node.rest: record(node.rest, node, statement, top=False) - inner = nested or isinstance(node, _SCOPE_NODES) - children = list(ast.iter_child_nodes(node)) - stack.extend((child, inner, statement) for child in reversed(children)) return bindings, star_import @@ -2631,6 +5229,11 @@ class ScopeIndex: """ def __init__(self, tree: ast.Module) -> None: + self.annotations_postponed = any( + isinstance(statement, ast.ImportFrom) and statement.module == "__future__" + and any(alias.name == "annotations" for alias in statement.names) + for statement in tree.body + ) self.parents: dict[ast.AST, ast.AST] = { child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node) } @@ -2657,6 +5260,13 @@ def enclosing_bindings(self, node: ast.AST, name: str) -> list[ast.AST]: current = self.parents.get(node) passed_function = False while current is not None and not isinstance(current, ast.Module): + if isinstance(current, ast.ClassDef) and passed_function: + # A method skips class-body locals, but generic class type + # parameters live in an enclosing annotation cell (PEP 695). + parameters = [parameter for parameter in getattr(current, "type_params", ()) + if getattr(parameter, "name", None) == name] + if parameters: + return parameters if isinstance(current, _SCOPE_NODES) and not ( isinstance(current, ast.ClassDef) and passed_function ): @@ -2683,6 +5293,8 @@ def _scope( def bind(name: str, node: ast.AST) -> None: bound.setdefault(name, []).append(node) + for parameter in getattr(scope, "type_params", ()): + bind(parameter.name, parameter) arguments = getattr(scope, "args", None) if isinstance(arguments, ast.arguments): for arg in [ @@ -2696,12 +5308,12 @@ def bind(name: str, node: ast.AST) -> None: if isinstance(scope, ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp): roots: list[ast.AST] = [gen.target for gen in scope.generators] elif isinstance(scope, ast.Lambda): - roots = [] + roots = [scope.body] else: roots = list(getattr(scope, "body", [])) - stack = list(reversed(roots)) - while stack: - node = stack.pop() + for node, nested, _ in _binding_nodes(roots, enter_bodies=False): + if nested: + continue if isinstance(node, ast.Global): declared_global.update(node.names) continue @@ -2711,17 +5323,16 @@ def bind(name: str, node: ast.AST) -> None: if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef): bind(node.name, node) continue - if isinstance(node, ast.Lambda | ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp): - continue if isinstance(node, ast.alias) and node.name != "*": bind(node.asname or node.name.split(".", 1)[0], node) - elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store | ast.Del): bind(node.id, node) elif isinstance(node, ast.ExceptHandler) and node.name: bind(node.name, node) elif isinstance(node, ast.MatchAs | ast.MatchStar) and node.name: bind(node.name, node) - stack.extend(reversed(list(ast.iter_child_nodes(node)))) + elif isinstance(node, ast.MatchMapping) and node.rest: + bind(node.rest, node) result = (bound, declared_global, declared_nonlocal) self._scopes[id(scope)] = result return result diff --git a/tests/constructor_engine_fixture.py b/tests/constructor_engine_fixture.py new file mode 100644 index 000000000..2cae7da31 --- /dev/null +++ b/tests/constructor_engine_fixture.py @@ -0,0 +1,51 @@ +"""Constant engine metadata for constructor ownership test modules. + +These cases vary disposable application Git trees, while engine inputs stay +fixed. Every CLI comparison, snapshot and reader still runs. Only these tests +reduce per-comparison metadata reconstruction; identity/invalidation tests keep +the real builder. Boundary equality cannot detect transient change-and-restore, +so this fixture belongs only to modules with invariant engine inputs. +""" + +from contextlib import contextmanager + +import pytest + +from agents_shipgate.cli import application_diff +from agents_shipgate.core import verification_identity +from agents_shipgate.core.static_inputs import active_static_input_snapshot + + +@contextmanager +def _frozen_application_engine(): + """Use actual worker metadata and restore the genuine alias on every exit.""" + builder = verification_identity.build_engine_requirement + if application_diff.build_engine_requirement is not builder: + raise AssertionError("Application engine builder was already replaced") + if active_static_input_snapshot() is not None: + yield builder + return + expected = builder(plugins_enabled=False) + + def frozen(*, plugins_enabled): + if plugins_enabled is not False or active_static_input_snapshot() is not None: + return builder(plugins_enabled=plugins_enabled) + return expected.model_copy(deep=True) + + with pytest.MonkeyPatch.context() as patch: + patch.setattr(application_diff, "build_engine_requirement", frozen) + try: + yield frozen + finally: + if application_diff.build_engine_requirement is not frozen: + raise AssertionError("Application engine builder changed during constructor regression module") + if verification_identity.build_engine_requirement is not builder: + raise AssertionError("Core engine builder changed during constructor regression module") + if builder(plugins_enabled=False) != expected: + raise AssertionError("Engine inputs changed during constructor regression module") + + +@pytest.fixture(scope="module", autouse=True) +def frozen_constructor_engine(): + with _frozen_application_engine(): + yield diff --git a/tests/framework_matrix.py b/tests/framework_matrix.py new file mode 100644 index 000000000..128a6d9c4 --- /dev/null +++ b/tests/framework_matrix.py @@ -0,0 +1,117 @@ +"""Which framework-parametrized cases run on every PR and which run nightly (#964). + +The OpenAI Agents SDK reader and the Google ADK reader share one +constructor-ownership engine: a test's ``framework`` (or ``family``) argument +only selects the reader and the import root, so crossing it with a large matrix +of routes runs the same engine twice over the same routes. + +Per test function, the ADK matrix stays whole in the per-PR run (the engine has +more ADK-specific branches, ``family == "google.adk"``). The Agents SDK keeps a +**covering subset**: every value of every parameter is still met at least once +in its reader, so each route (patch, owner, layout, change, ...) and each +reader-specific branch is exercised there too. The remaining SDK cases are +marked ``slow``, which the per-PR suite excludes (``-m "not slow"``) and the +nightly job runs (``-m slow``). Nothing is deleted: a run with no ``-m``, and +``pytest -m slow``, still execute every case. + +The marks are added at collection (``conftest.py``), not in each test, so the +parameter ids that other files key on stay as they are. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +#: The test files whose framework matrices are thinned: the constructor-ownership +#: engine's tests, where ``framework`` / ``family`` selects a reader over one engine. +THINNED_FILES = frozenset({ + "tests/test_builder_bindings.py", + "tests/test_builder_calls.py", + "tests/test_builtin_namespace_ownership.py", + "tests/test_constructor_dependency_ownership.py", + "tests/test_constructor_dependency_values.py", + "tests/test_constructor_foreign_dependencies.py", + "tests/test_constructor_typing_dependencies.py", + "tests/test_constructor_unread_imports.py", + "tests/test_direct_operator_constructor_ownership.py", + "tests/test_eager_binding_ownership.py", + "tests/test_external_helper_constructor_ownership.py", + "tests/test_framework_constructor_bindings.py", + "tests/test_framework_constructor_imports.py", + "tests/test_framework_constructor_retention.py", + "tests/test_implicit_child_constructor_ownership.py", + "tests/test_import_search_ownership.py", + "tests/test_imported_tool_review.py", + "tests/test_returned_agent_lists.py", + "tests/test_saved_vars_repository_context.py", + "tests/test_type_checking_constructor_ownership.py", +}) + +#: The framework whose cases are thinned, under every spelling the tests use. +THINNED_VALUES = frozenset({"sdk", "agents", "openai_agents"}) + +_FRAMEWORK_ARGUMENTS = ("framework", "family") + + +def mark_nightly_framework_cases(items: list[pytest.Item]) -> int: + """Mark the redundant cases of the thinned framework ``slow``; return how many.""" + + groups: dict[tuple[str, str, str], list[pytest.Item]] = {} + for item in items: + callspec = getattr(item, "callspec", None) + path = _relative_path(item) + if callspec is None or path not in THINNED_FILES: + continue + argument = next((name for name in _FRAMEWORK_ARGUMENTS if name in callspec.params), None) + if argument is None or callspec.params[argument] not in THINNED_VALUES: + continue + groups.setdefault((path, getattr(item, "originalname", item.name), argument), []).append(item) + marked = 0 + for group in groups.values(): + kept = {id(item) for item in covering_subset(group)} + for item in group: + if id(item) not in kept: + item.add_marker(pytest.mark.slow) + marked += 1 + return marked + + +def covering_subset(group: list[pytest.Item]) -> list[pytest.Item]: + """The fewest cases (greedy, in collection order) meeting every parameter value. + + Every parameter is a route, the framework argument's own value included + (``agents`` and ``openai_agents`` are two import spellings of the SDK). The + subset has at least one case for each ``(parameter, value)`` that any case + in the group has. Ties go to the earliest case, so the choice is + deterministic. + """ + + needs = [_pairs(item) for item in group] + uncovered: set[tuple[str, str]] = set().union(*needs) if needs else set() + chosen: list[int] = [] + while uncovered: + best = max(range(len(group)), key=lambda index: (len(needs[index] & uncovered), -index)) + gained = needs[best] & uncovered + if not gained: + break + chosen.append(best) + uncovered -= gained + if not chosen and group: + chosen = [0] # A case with no parameter to cover is its own route. + return [group[index] for index in sorted(chosen)] + + +def _pairs(item: pytest.Item) -> set[tuple[str, str]]: + params: dict[str, Any] = item.callspec.params # type: ignore[attr-defined] + return {(name, repr(value)) for name, value in params.items()} + + +def _relative_path(item: pytest.Item) -> str: + path = Path(str(item.path)) + try: + return path.relative_to(item.config.rootpath).as_posix() + except ValueError: + return path.as_posix() diff --git a/tests/shard_seconds.json b/tests/shard_seconds.json index a71674ffb..436348ceb 100644 --- a/tests/shard_seconds.json +++ b/tests/shard_seconds.json @@ -2,400 +2,437 @@ "files": { "tests/harness/test_adversarial_guards.py": 0.0, "tests/harness/test_claude_code_driver.py": 0.0, - "tests/harness/test_codex_driver.py": 1.0, + "tests/harness/test_codex_driver.py": 0.9, "tests/harness/test_cursor_driver.py": 0.0, "tests/harness/test_cursor_manual_driver.py": 0.0, "tests/harness/test_detectors.py": 0.2, "tests/harness/test_exit_criteria.py": 0.0, - "tests/harness/test_harness_layout.py": 2.2, + "tests/harness/test_harness_layout.py": 2.1, "tests/harness/test_infrastructure_failures.py": 0.1, "tests/harness/test_overlay_renderer.py": 0.0, - "tests/harness/test_pressure_ground_truth.py": 7.4, + "tests/harness/test_pressure_ground_truth.py": 6.8, "tests/harness/test_redaction.py": 0.0, "tests/harness/test_run_preflight.py": 1.0, - "tests/harness/test_smoke.py": 0.8, + "tests/harness/test_smoke.py": 0.7, "tests/integration/github_action/test_agent_result.py": 0.0, - "tests/test_absent_input_messages.py": 3.8, + "tests/test_absent_input_messages.py": 3.6, "tests/test_action_comment_fallback.py": 0.9, - "tests/test_action_engine_install.py": 7.8, - "tests/test_action_metadata.py": 0.4, + "tests/test_action_engine_install.py": 7.4, + "tests/test_action_metadata.py": 0.5, "tests/test_action_scope_domain.py": 0.0, - "tests/test_action_scope_projection.py": 3.4, - "tests/test_action_surface_diff.py": 0.8, - "tests/test_actions_token_scope.py": 55.0, + "tests/test_action_scope_projection.py": 2.9, + "tests/test_action_surface_diff.py": 1.0, + "tests/test_actions_token_scope.py": 48.4, "tests/test_adapter_contracts.py": 0.2, - "tests/test_adapter_entry_point_discovery.py": 5.1, + "tests/test_adapter_entry_point_discovery.py": 3.9, "tests/test_adapter_registry.py": 0.1, - "tests/test_adk_remote_bindings.py": 1.1, - "tests/test_adk_tool_factories.py": 60.7, + "tests/test_adk_constructor_value_ownership.py": 6.6, + "tests/test_adk_remote_bindings.py": 5.9, + "tests/test_adk_tool_factories.py": 63.7, "tests/test_adopter_pins_resolve.py": 0.1, - "tests/test_adopter_vocabulary.py": 9.5, - "tests/test_adopters_registry.py": 0.6, - "tests/test_adoption_ladder.py": 1.1, + "tests/test_adopter_vocabulary.py": 18.3, + "tests/test_adopters_registry.py": 0.5, + "tests/test_adoption_ladder.py": 0.7, "tests/test_adoption_scorer_input_recovery.py": 0.0, - "tests/test_adoption_walk.py": 77.0, - "tests/test_advisory_cadence.py": 0.5, - "tests/test_agent_action_summary.py": 5.5, + "tests/test_adoption_walk.py": 73.1, + "tests/test_advisory_cadence.py": 0.4, + "tests/test_agent_action_summary.py": 5.0, "tests/test_agent_bindings.py": 0.0, - "tests/test_agent_boundary.py": 35.0, + "tests/test_agent_boundary.py": 28.7, "tests/test_agent_control_contract.py": 0.2, - "tests/test_agent_control_envelope.py": 98.5, - "tests/test_agent_control_envelope_rows.py": 36.6, - "tests/test_agent_control_reports_dir.py": 115.8, + "tests/test_agent_control_envelope.py": 85.1, + "tests/test_agent_control_envelope_rows.py": 33.6, + "tests/test_agent_control_reports_dir.py": 103.4, "tests/test_agent_controls.py": 0.0, "tests/test_agent_handoff.py": 0.0, - "tests/test_agent_identity_origin.py": 1.2, + "tests/test_agent_identity_origin.py": 1.0, "tests/test_agent_instructions_apply.py": 0.2, "tests/test_agent_instructions_renderers.py": 0.0, - "tests/test_agent_mode.py": 18.3, - "tests/test_agent_name_recovery.py": 7.1, + "tests/test_agent_mode.py": 17.2, + "tests/test_agent_name_recovery.py": 6.6, "tests/test_agent_protocol.py": 0.1, - "tests/test_anthropic_api.py": 0.6, - "tests/test_application_diff.py": 65.8, - "tests/test_application_diff_identity.py": 23.4, - "tests/test_application_diff_reach.py": 65.6, - "tests/test_application_diff_review.py": 47.7, - "tests/test_application_diff_tool_reach.py": 64.3, - "tests/test_application_diff_unobserved.py": 232.0, + "tests/test_anthropic_api.py": 0.4, + "tests/test_application_diff.py": 61.0, + "tests/test_application_diff_identity.py": 22.6, + "tests/test_application_diff_reach.py": 66.6, + "tests/test_application_diff_review.py": 48.8, + "tests/test_application_diff_tool_reach.py": 87.8, + "tests/test_application_diff_unobserved.py": 246.0, "tests/test_application_q2_benchmark.py": 0.1, - "tests/test_application_scope.py": 214.4, - "tests/test_application_summary.py": 25.6, - "tests/test_apply_patches.py": 3.5, + "tests/test_application_scope.py": 212.9, + "tests/test_application_summary.py": 32.8, + "tests/test_apply_patches.py": 4.7, "tests/test_attest.py": 0.2, - "tests/test_authorization_cli.py": 2.3, - "tests/test_authorization_execution.py": 2.2, + "tests/test_authorization_cli.py": 2.1, + "tests/test_authorization_execution.py": 1.9, "tests/test_authorization_verifier_scenarios.py": 0.0, - "tests/test_authorization_verify_integration.py": 103.7, - "tests/test_base_cache_engine_identity.py": 201.5, - "tests/test_base_cache_namespace.py": 189.5, - "tests/test_baseline_integrity.py": 3.1, + "tests/test_authorization_verify_integration.py": 95.1, + "tests/test_base_cache_engine_identity.py": 187.2, + "tests/test_base_cache_namespace.py": 181.0, + "tests/test_baseline_integrity.py": 3.2, "tests/test_baseline_status.py": 0.0, "tests/test_benchmark_results_privacy.py": 0.0, - "tests/test_beta_strata_inventory.py": 2.8, - "tests/test_bootstrap.py": 40.8, - "tests/test_boundary_diff_hunks.py": 2.7, - "tests/test_boundary_diff_paths.py": 20.3, - "tests/test_builder_bindings.py": 66.2, - "tests/test_builder_calls.py": 2.3, + "tests/test_beta_strata_inventory.py": 3.2, + "tests/test_bootstrap.py": 41.4, + "tests/test_boundary_diff_hunks.py": 2.4, + "tests/test_boundary_diff_paths.py": 17.2, + "tests/test_builder_bindings.py": 96.0, + "tests/test_builder_calls.py": 92.3, + "tests/test_builtin_namespace_ownership.py": 51.9, "tests/test_capability_change_schema_hash_parity.py": 0.1, "tests/test_capability_delta.py": 0.0, - "tests/test_capability_delta_attestation.py": 15.2, - "tests/test_capability_diff.py": 42.1, - "tests/test_capability_diff_partial_clone.py": 51.6, + "tests/test_capability_delta_attestation.py": 13.0, + "tests/test_capability_diff.py": 35.6, + "tests/test_capability_diff_partial_clone.py": 44.2, "tests/test_capability_domain.py": 0.0, "tests/test_capability_lattice.py": 0.0, "tests/test_capability_lock.py": 0.2, "tests/test_capability_payload.py": 8.4, "tests/test_capability_trace_evidence.py": 0.0, - "tests/test_check_default_comparison.py": 43.2, - "tests/test_check_unmodelled_host_config_keys.py": 250.6, - "tests/test_ci.py": 0.4, + "tests/test_check_default_comparison.py": 37.5, + "tests/test_check_unmodelled_host_config_keys.py": 255.4, + "tests/test_ci.py": 0.6, "tests/test_ci_recipes.py": 0.3, + "tests/test_ci_timing.py": 4.9, "tests/test_claude_code_plugin_package.py": 0.0, - "tests/test_claude_hook_loading_evidence.py": 78.2, - "tests/test_claude_hooks_source.py": 1.0, - "tests/test_claude_known_marketplaces.py": 5.6, - "tests/test_claude_permission_rule_model.py": 245.4, + "tests/test_claude_hook_loading_evidence.py": 71.7, + "tests/test_claude_hooks_source.py": 0.9, + "tests/test_claude_known_marketplaces.py": 5.0, + "tests/test_claude_permission_rule_model.py": 305.4, "tests/test_claude_permission_shapes.py": 0.1, - "tests/test_cli.py": 5.5, + "tests/test_cli.py": 5.9, "tests/test_cli_on_demand_loading.py": 2.2, - "tests/test_codex_boundary_check.py": 0.8, + "tests/test_codex_boundary_check.py": 0.4, "tests/test_codex_plugin.py": 0.6, - "tests/test_codex_plugin_default_identity.py": 169.6, - "tests/test_codex_plugin_input_identity.py": 106.6, + "tests/test_codex_plugin_default_identity.py": 149.6, + "tests/test_codex_plugin_input_identity.py": 98.8, "tests/test_codex_plugin_launch_package.py": 0.0, - "tests/test_cold_reader_order.py": 18.9, - "tests/test_cold_start_replay.py": 37.1, + "tests/test_cold_reader_order.py": 25.2, + "tests/test_cold_start_replay.py": 35.7, "tests/test_conductor.py": 0.8, - "tests/test_config.py": 1.1, - "tests/test_control_evaluated_subject.py": 2.3, - "tests/test_control_packs.py": 12.2, - "tests/test_coverage_recovery.py": 16.4, + "tests/test_config.py": 0.8, + "tests/test_constructor_dependency_ownership.py": 535.2, + "tests/test_constructor_dependency_values.py": 152.9, + "tests/test_constructor_engine_fixture.py": 0.2, + "tests/test_constructor_foreign_dependencies.py": 402.2, + "tests/test_constructor_import_refusal_context.py": 0.2, + "tests/test_constructor_reference_budget.py": 0.1, + "tests/test_constructor_structure_lineage.py": 0.2, + "tests/test_constructor_syntax_reuse.py": 0.0, + "tests/test_constructor_typing_dependencies.py": 452.7, + "tests/test_constructor_unread_imports.py": 407.1, + "tests/test_control_evaluated_subject.py": 1.1, + "tests/test_control_packs.py": 11.0, + "tests/test_coverage_recovery.py": 14.0, "tests/test_crewai.py": 0.1, - "tests/test_cross_block_consistency.py": 7.5, - "tests/test_current_control.py": 146.4, - "tests/test_current_control_auxiliary_inputs.py": 105.6, - "tests/test_current_control_closure.py": 72.1, - "tests/test_current_control_directory_currency.py": 96.0, - "tests/test_current_control_input_currency.py": 31.0, - "tests/test_current_control_input_origins.py": 80.3, + "tests/test_cross_block_consistency.py": 6.5, + "tests/test_current_control.py": 140.6, + "tests/test_current_control_auxiliary_inputs.py": 97.0, + "tests/test_current_control_closure.py": 68.9, + "tests/test_current_control_directory_currency.py": 88.6, + "tests/test_current_control_input_currency.py": 28.6, + "tests/test_current_control_input_origins.py": 74.6, "tests/test_cursor_rule_globs.py": 0.0, - "tests/test_declaration_authoring.py": 0.4, - "tests/test_declaration_confirmation_route.py": 213.5, - "tests/test_declaration_drift.py": 0.1, - "tests/test_declaration_monotonicity.py": 0.3, - "tests/test_declaration_questionnaire.py": 2.1, - "tests/test_declaration_review.py": 5.1, - "tests/test_declaration_scaffold.py": 0.3, - "tests/test_declared_manifest_input_identity.py": 43.6, - "tests/test_design_partner_pilot.py": 7.2, - "tests/test_detect.py": 7.4, - "tests/test_determinism_boundary.py": 0.1, + "tests/test_declaration_authoring.py": 1.6, + "tests/test_declaration_confirmation_route.py": 230.0, + "tests/test_declaration_drift.py": 0.2, + "tests/test_declaration_monotonicity.py": 1.6, + "tests/test_declaration_questionnaire.py": 1.9, + "tests/test_declaration_review.py": 5.5, + "tests/test_declaration_scaffold.py": 2.1, + "tests/test_declared_manifest_input_identity.py": 65.9, + "tests/test_dependabot_config.py": 0.0, + "tests/test_design_partner_pilot.py": 7.9, + "tests/test_detect.py": 8.2, + "tests/test_determinism_boundary.py": 0.8, "tests/test_diagnostics.py": 0.0, - "tests/test_diff_input_status.py": 28.3, - "tests/test_diff_phase_timing.py": 13.3, + "tests/test_diff_input_status.py": 29.5, + "tests/test_diff_phase_timing.py": 11.7, + "tests/test_direct_operator_constructor_ownership.py": 0.7, "tests/test_directory_reader_identity.py": 1.2, - "tests/test_discovery_scope.py": 269.7, - "tests/test_distribution_surface_parity.py": 2.2, - "tests/test_docs_links.py": 0.1, + "tests/test_discovery_scope.py": 252.2, + "tests/test_distribution_surface_parity.py": 3.0, + "tests/test_docs_links.py": 0.0, "tests/test_documentation_checks.py": 0.0, "tests/test_documented_claude_frontmatter.py": 0.0, "tests/test_e3_prime_compat.py": 0.0, - "tests/test_effect_coverage.py": 1.0, - "tests/test_effect_provenance_presentation.py": 5.0, - "tests/test_enabled_plugin_hook_routing.py": 114.7, - "tests/test_environment.py": 6.8, - "tests/test_evidence_backed_pass.py": 1.3, - "tests/test_evidence_gap_ranking.py": 0.5, - "tests/test_evidence_packet.py": 20.7, - "tests/test_exec_equivalent_permissions.py": 20.2, - "tests/test_explain_finding.py": 3.6, + "tests/test_eager_binding_ownership.py": 199.9, + "tests/test_effect_coverage.py": 0.9, + "tests/test_effect_provenance_presentation.py": 5.3, + "tests/test_enabled_plugin_hook_routing.py": 108.8, + "tests/test_environment.py": 7.6, + "tests/test_evidence_backed_pass.py": 1.2, + "tests/test_evidence_gap_ranking.py": 1.9, + "tests/test_evidence_packet.py": 22.0, + "tests/test_exec_equivalent_permissions.py": 21.4, + "tests/test_explain_finding.py": 3.7, + "tests/test_external_helper_constructor_ownership.py": 122.6, "tests/test_fastmcp_injection_contract.py": 0.0, "tests/test_feedback.py": 0.0, - "tests/test_finding_attribution.py": 31.4, + "tests/test_finding_attribution.py": 33.5, "tests/test_finding_comparison_evidence.py": 0.1, - "tests/test_finding_remediation.py": 1.7, + "tests/test_finding_remediation.py": 2.7, "tests/test_findings.py": 0.0, "tests/test_fingerprint_compatibility.py": 0.3, - "tests/test_first_adoption.py": 55.9, - "tests/test_first_look.py": 10.1, + "tests/test_first_adoption.py": 59.8, + "tests/test_first_look.py": 9.4, "tests/test_fix_task_contract.py": 0.0, - "tests/test_fixture.py": 20.8, - "tests/test_fixture_no_import.py": 4.1, + "tests/test_fixture.py": 21.9, + "tests/test_fixture_no_import.py": 4.7, "tests/test_framework_common.py": 0.0, + "tests/test_framework_constructor_bindings.py": 512.6, + "tests/test_framework_constructor_imports.py": 367.3, + "tests/test_framework_constructor_retention.py": 298.1, + "tests/test_framework_matrix.py": 5.5, "tests/test_github_action_annotations.py": 0.0, - "tests/test_github_action_outputs.py": 0.1, + "tests/test_github_action_outputs.py": 0.0, "tests/test_github_check_run.py": 0.0, - "tests/test_globbing_cache.py": 1.0, + "tests/test_globbing_cache.py": 0.2, "tests/test_go_tool_descriptions.py": 0.1, - "tests/test_google_adk.py": 4.4, - "tests/test_governance_benchmark.py": 53.9, - "tests/test_governance_benchmark_baseline.py": 37.9, - "tests/test_guard_dependency_verification.py": 88.2, + "tests/test_google_adk.py": 38.3, + "tests/test_governance_benchmark.py": 57.3, + "tests/test_governance_benchmark_baseline.py": 39.0, + "tests/test_guard_dependency_verification.py": 101.0, "tests/test_headline_ranking.py": 11.7, "tests/test_heuristics.py": 0.0, - "tests/test_hook_args_and_settings.py": 87.2, - "tests/test_hook_command_shape.py": 100.0, - "tests/test_hook_benign_session.py": 39.1, - "tests/test_hook_matcher_reach.py": 28.2, - "tests/test_hook_mcp_detail_fields.py": 248.6, - "tests/test_hook_script_archive.py": 3.7, - "tests/test_hook_script_capture.py": 35.4, - "tests/test_hook_script_comparison_limits.py": 74.0, - "tests/test_hook_script_currency.py": 30.9, + "tests/test_hook_args_and_settings.py": 83.7, + "tests/test_hook_benign_session.py": 40.7, + "tests/test_hook_command_shape.py": 107.6, + "tests/test_hook_matcher_reach.py": 36.1, + "tests/test_hook_mcp_detail_fields.py": 245.2, + "tests/test_hook_script_archive.py": 3.2, + "tests/test_hook_script_capture.py": 32.7, + "tests/test_hook_script_comparison_limits.py": 69.5, + "tests/test_hook_script_currency.py": 29.0, "tests/test_hook_script_reference.py": 0.0, - "tests/test_hook_script_routing.py": 18.4, - "tests/test_host_audit.py": 0.8, - "tests/test_host_boundary_check.py": 0.5, - "tests/test_host_boundary_unread_surfaces.py": 10.0, - "tests/test_host_change_route_parity.py": 116.2, - "tests/test_host_comparison_coverage.py": 352.2, - "tests/test_host_config_oracle_controls.py": 3.7, - "tests/test_host_config_replay.py": 58.2, - "tests/test_host_diff_entry_docs.py": 21.1, - "tests/test_host_diff_permission_direction.py": 210.8, - "tests/test_host_diff_review_changes.py": 180.5, - "tests/test_host_directory_inputs.py": 3.3, + "tests/test_hook_script_routing.py": 18.7, + "tests/test_host_audit.py": 0.7, + "tests/test_host_boundary_check.py": 0.4, + "tests/test_host_boundary_unread_surfaces.py": 12.5, + "tests/test_host_change_route_parity.py": 115.3, + "tests/test_host_comparison_coverage.py": 337.8, + "tests/test_host_config_oracle_controls.py": 3.5, + "tests/test_host_config_replay.py": 54.8, + "tests/test_host_diff_entry_docs.py": 17.3, + "tests/test_host_diff_permission_direction.py": 198.1, + "tests/test_host_diff_review_changes.py": 165.0, + "tests/test_host_directory_inputs.py": 3.5, "tests/test_host_discovery.py": 7.7, "tests/test_host_file_links.py": 1.8, - "tests/test_host_grant_direction.py": 112.3, - "tests/test_host_input_recovery.py": 0.2, - "tests/test_host_inventory_stability.py": 0.1, - "tests/test_host_link_read_through.py": 2.0, + "tests/test_host_grant_direction.py": 104.3, + "tests/test_host_input_recovery.py": 0.3, + "tests/test_host_inventory_stability.py": 0.0, + "tests/test_host_link_read_through.py": 1.9, "tests/test_host_local_precedence.py": 0.1, - "tests/test_host_only_advisory_recipe.py": 69.7, - "tests/test_host_path_privacy.py": 5.1, + "tests/test_host_only_advisory_recipe.py": 71.3, + "tests/test_host_path_privacy.py": 5.0, "tests/test_host_settings_narrowing_review.py": 0.4, "tests/test_human_authorization.py": 0.1, "tests/test_human_authorization_signature_vector.py": 0.0, - "tests/test_human_review_decision.py": 120.6, - "tests/test_human_review_presentation.py": 4.2, - "tests/test_human_review_request.py": 14.8, - "tests/test_imported_tool_bindings.py": 10.4, - "tests/test_imported_tool_review.py": 446.5, - "tests/test_init_agent_instructions.py": 10.4, - "tests/test_init_auto.py": 12.8, - "tests/test_init_ci.py": 3.1, + "tests/test_human_review_decision.py": 115.0, + "tests/test_human_review_presentation.py": 4.0, + "tests/test_human_review_request.py": 14.1, + "tests/test_implicit_child_constructor_ownership.py": 0.8, + "tests/test_import_search_ownership.py": 2.8, + "tests/test_imported_tool_bindings.py": 25.6, + "tests/test_imported_tool_review.py": 471.0, + "tests/test_init_agent_instructions.py": 10.2, + "tests/test_init_auto.py": 13.4, + "tests/test_init_ci.py": 2.9, "tests/test_init_claude_code.py": 3.4, - "tests/test_init_gitignore.py": 2.8, - "tests/test_init_scaffold_disclosure.py": 15.6, - "tests/test_inline_hook_allow.py": 31.4, - "tests/test_inputs.py": 0.2, - "tests/test_install_hooks.py": 66.9, + "tests/test_init_gitignore.py": 2.9, + "tests/test_init_scaffold_disclosure.py": 15.7, + "tests/test_inline_hook_allow.py": 31.0, + "tests/test_inputs.py": 0.4, + "tests/test_install_hooks.py": 61.6, "tests/test_instruction_structure.py": 0.1, - "tests/test_instruction_structure_contracts.py": 2.9, - "tests/test_instruction_structure_hooks.py": 12.5, - "tests/test_instruction_structure_workflow.py": 38.0, - "tests/test_invocation_policy.py": 102.3, + "tests/test_instruction_structure_contracts.py": 2.7, + "tests/test_instruction_structure_hooks.py": 13.7, + "tests/test_instruction_structure_workflow.py": 34.7, + "tests/test_invocation_policy.py": 97.3, "tests/test_labeling_guide_is_rater_safe.py": 0.0, "tests/test_langchain.py": 0.2, - "tests/test_large_sample.py": 1.1, - "tests/test_linked_unchanged_limits.py": 201.6, - "tests/test_list_expressions.py": 33.5, - "tests/test_live_workspace_cause.py": 73.1, + "tests/test_large_sample.py": 1.2, + "tests/test_linked_unchanged_limits.py": 181.4, + "tests/test_list_expressions.py": 30.8, + "tests/test_live_workspace_cause.py": 64.2, "tests/test_local_contract.py": 0.0, - "tests/test_local_review.py": 25.3, + "tests/test_local_review.py": 24.3, "tests/test_managed_block.py": 0.0, "tests/test_manifest_consistency.py": 0.1, - "tests/test_manifest_free_pr_rows.py": 108.6, + "tests/test_manifest_free_pr_rows.py": 104.0, "tests/test_manifest_schema_parity.py": 0.0, "tests/test_manifest_scope.py": 0.0, "tests/test_mcp_audit.py": 0.1, - "tests/test_mcp_env_var_names.py": 60.0, - "tests/test_mcp_host_attribution.py": 48.5, + "tests/test_mcp_env_var_names.py": 60.2, + "tests/test_mcp_host_attribution.py": 41.1, "tests/test_mcp_idioms.py": 0.0, - "tests/test_mcp_launch_source.py": 43.5, - "tests/test_mcp_manifest.py": 0.2, + "tests/test_mcp_launch_source.py": 40.6, + "tests/test_mcp_manifest.py": 0.3, "tests/test_mcp_permissions.py": 0.2, - "tests/test_mcp_row_bounded_wording.py": 104.0, + "tests/test_mcp_row_bounded_wording.py": 92.0, "tests/test_mcp_server.py": 0.1, "tests/test_mcp_server_findings_table.py": 0.0, - "tests/test_mcp_server_source.py": 1.6, - "tests/test_mcp_source_annotations.py": 0.5, - "tests/test_mcp_url_capability_digest.py": 10.2, + "tests/test_mcp_server_source.py": 1.5, + "tests/test_mcp_source_annotations.py": 0.4, + "tests/test_mcp_url_capability_digest.py": 10.3, "tests/test_metadata_loader.py": 0.1, - "tests/test_miner.py": 23.7, - "tests/test_miner_candidates.py": 1.2, - "tests/test_miner_constructed.py": 27.6, + "tests/test_miner.py": 22.1, + "tests/test_miner_candidates.py": 1.0, + "tests/test_miner_constructed.py": 31.5, "tests/test_miner_corpus.py": 0.1, "tests/test_miner_labels.py": 0.0, - "tests/test_miner_reevaluate.py": 9.8, - "tests/test_miner_scope_inputs.py": 7.0, - "tests/test_n8n.py": 1.6, - "tests/test_next_action_chains_terminate.py": 33.8, - "tests/test_no_heuristics.py": 6.7, - "tests/test_nonregular_evidence_readers.py": 5.5, - "tests/test_object_bound_tools.py": 113.0, + "tests/test_miner_reevaluate.py": 11.7, + "tests/test_miner_scope_inputs.py": 9.7, + "tests/test_n8n.py": 2.2, + "tests/test_next_action_chains_terminate.py": 50.8, + "tests/test_no_heuristics.py": 6.5, + "tests/test_nonregular_evidence_readers.py": 5.7, + "tests/test_object_bound_tools.py": 59.4, + "tests/test_object_tool_ownership.py": 5.2, "tests/test_openai_api.py": 0.4, "tests/test_openapi_fuzz.py": 0.1, - "tests/test_openapi_operation_attribution.py": 1.8, - "tests/test_openshell_acceptance.py": 216.4, - "tests/test_openshell_compare.py": 0.3, - "tests/test_openshell_composition.py": 24.3, - "tests/test_openshell_inputs.py": 46.5, - "tests/test_openshell_inventory.py": 0.8, - "tests/test_openshell_native.py": 50.4, - "tests/test_openshell_routes.py": 213.2, - "tests/test_openshell_row_detail.py": 24.0, - "tests/test_operation_attribution_verification.py": 23.3, + "tests/test_openapi_operation_attribution.py": 1.7, + "tests/test_openshell_acceptance.py": 205.3, + "tests/test_openshell_compare.py": 0.1, + "tests/test_openshell_composition.py": 23.3, + "tests/test_openshell_inputs.py": 40.0, + "tests/test_openshell_inventory.py": 0.3, + "tests/test_openshell_native.py": 49.6, + "tests/test_openshell_routes.py": 143.1, + "tests/test_openshell_row_detail.py": 16.6, + "tests/test_operation_attribution_verification.py": 13.4, "tests/test_org_governance.py": 0.1, - "tests/test_out_path_resolution.py": 58.2, - "tests/test_output_directory_content.py": 350.3, - "tests/test_oversized_instruction_document_partial.py": 74.3, + "tests/test_out_path_resolution.py": 32.0, + "tests/test_output_directory_content.py": 277.3, + "tests/test_oversized_instruction_document_partial.py": 84.7, "tests/test_p0_binding_canaries.py": 0.1, "tests/test_p0_policy_evidence_canaries.py": 0.0, - "tests/test_p0_safety_canaries.py": 1.4, - "tests/test_p0_verification_identity_canaries.py": 0.1, - "tests/test_packaging.py": 15.1, - "tests/test_partial_host_comparison.py": 91.0, - "tests/test_patch_generators.py": 1.2, + "tests/test_p0_safety_canaries.py": 1.7, + "tests/test_p0_verification_identity_canaries.py": 0.2, + "tests/test_packaging.py": 15.3, + "tests/test_partial_host_comparison.py": 78.8, + "tests/test_patch_generators.py": 1.5, "tests/test_patches_model.py": 0.3, "tests/test_permission_lattice.py": 1.1, - "tests/test_permission_residual.py": 8.0, - "tests/test_permission_review_guidance.py": 15.7, - "tests/test_permission_rule_url_redaction.py": 55.5, - "tests/test_plugin_validation.py": 4.9, + "tests/test_permission_residual.py": 6.9, + "tests/test_permission_review_guidance.py": 13.1, + "tests/test_permission_rule_url_redaction.py": 73.9, + "tests/test_plugin_validation.py": 4.6, "tests/test_plugins.py": 0.4, "tests/test_policy_evidence_architecture.py": 0.0, "tests/test_policy_packs.py": 1.1, - "tests/test_policy_reason_code_split.py": 16.5, - "tests/test_preflight.py": 1.8, - "tests/test_preflight_planning_only.py": 11.5, - "tests/test_preview_control_currency.py": 120.0, + "tests/test_policy_reason_code_split.py": 15.6, + "tests/test_preflight.py": 1.4, + "tests/test_preflight_planning_only.py": 5.4, + "tests/test_preview_control_currency.py": 113.4, "tests/test_privacy.py": 0.1, "tests/test_product_hardening_gap_closure.py": 0.0, - "tests/test_prompt_disabling_settings.py": 156.5, + "tests/test_prompt_disabling_settings.py": 216.7, "tests/test_prompt_parity.py": 0.0, "tests/test_property_loaders.py": 0.9, - "tests/test_provenance_kind.py": 2.3, - "tests/test_public_surface_contract.py": 3.0, - "tests/test_python_import_resolution.py": 0.2, + "tests/test_provenance_kind.py": 2.8, + "tests/test_public_surface_contract.py": 1.6, + "tests/test_python_import_resolution.py": 0.4, "tests/test_qualification_coverage_misses.py": 1.4, - "tests/test_rater_harness.py": 72.1, - "tests/test_reader_vocabulary.py": 14.8, - "tests/test_regenerate_goldens.py": 16.5, + "tests/test_rater_harness.py": 62.7, + "tests/test_reader_vocabulary.py": 20.5, + "tests/test_regenerate_goldens.py": 16.1, "tests/test_registry.py": 0.0, "tests/test_registry_ledger.py": 0.1, - "tests/test_release_advisory_pipeline.py": 2.6, + "tests/test_release_advisory_pipeline.py": 2.5, "tests/test_release_channel.py": 0.1, - "tests/test_release_channel_cadence.py": 3.6, + "tests/test_release_channel_cadence.py": 2.9, "tests/test_release_decision.py": 0.0, "tests/test_release_engine_smoke.py": 0.3, - "tests/test_release_pipeline.py": 3.8, - "tests/test_release_source.py": 4.9, + "tests/test_release_pipeline.py": 4.2, + "tests/test_release_source.py": 3.9, + "tests/test_release_suite_gate.py": 5.0, "tests/test_remediation_metadata.py": 0.0, - "tests/test_report_1_0_compatibility_fixtures.py": 4.4, + "tests/test_report_1_0_compatibility_fixtures.py": 4.3, "tests/test_report_1_0_contract.py": 0.0, - "tests/test_reports.py": 9.4, - "tests/test_required_source_availability.py": 6.8, - "tests/test_reusable_workflow_secret_mappings.py": 182.4, + "tests/test_reports.py": 8.6, + "tests/test_required_source_availability.py": 7.6, + "tests/test_returned_agent_lists.py": 53.4, + "tests/test_reusable_workflow_secret_mappings.py": 176.6, "tests/test_reviewer_summary.py": 0.0, "tests/test_risk_hints.py": 0.0, - "tests/test_rule_credential_redaction.py": 0.2, - "tests/test_safety_qualification.py": 2.5, - "tests/test_safety_qualification_release.py": 1.7, + "tests/test_rule_credential_redaction.py": 0.0, + "tests/test_safety_qualification.py": 2.3, + "tests/test_safety_qualification_release.py": 2.0, "tests/test_sarif.py": 0.3, - "tests/test_scan.py": 7.3, + "tests/test_saved_vars_repository_context.py": 1.3, + "tests/test_scan.py": 7.7, "tests/test_scan_context.py": 0.1, "tests/test_scenario_suggest.py": 2.4, - "tests/test_schema_boundaries.py": 3.8, - "tests/test_schema_roundtrip.py": 2.1, - "tests/test_scoped_base_tree.py": 14.1, - "tests/test_sdk_boolean_source.py": 0.9, - "tests/test_sdk_guard_dependencies.py": 0.3, + "tests/test_schema_boundaries.py": 4.4, + "tests/test_schema_roundtrip.py": 2.2, + "tests/test_scoped_base_tree.py": 13.3, + "tests/test_sdk_boolean_source.py": 66.1, + "tests/test_sdk_guard_dependencies.py": 25.1, "tests/test_self_approval_signal.py": 0.0, - "tests/test_self_check.py": 1.8, + "tests/test_self_check.py": 9.6, "tests/test_semantic_assessment.py": 0.0, "tests/test_semantic_cold_start.py": 0.1, - "tests/test_semantic_properties.py": 0.3, - "tests/test_setup_control.py": 40.5, + "tests/test_semantic_properties.py": 0.4, + "tests/test_setup_control.py": 38.2, "tests/test_severity_override_floor.py": 0.0, - "tests/test_shard_partition.py": 28.6, + "tests/test_shard_partition.py": 109.7, "tests/test_skill_review.py": 0.1, - "tests/test_source_authority.py": 4.6, - "tests/test_source_binding.py": 2.2, - "tests/test_source_head_identity.py": 7.9, - "tests/test_source_provenance.py": 0.7, + "tests/test_slow_tests_workflow.py": 1.6, + "tests/test_source_authority.py": 4.4, + "tests/test_source_binding.py": 5.9, + "tests/test_source_head_identity.py": 8.0, + "tests/test_source_provenance.py": 0.9, + "tests/test_source_slot_refusal_context.py": 0.4, "tests/test_static_inputs.py": 0.1, - "tests/test_strata_inventory.py": 4.5, - "tests/test_subject_rollup.py": 1.2, - "tests/test_surface_exclusions.py": 12.4, - "tests/test_three_command_flow.py": 1.3, - "tests/test_tool_identity.py": 0.1, - "tests/test_tool_reach.py": 10.3, - "tests/test_tool_reach_effects.py": 1.3, + "tests/test_strata_inventory.py": 5.2, + "tests/test_subject_rollup.py": 6.1, + "tests/test_surface_exclusions.py": 15.4, + "tests/test_three_command_flow.py": 1.6, + "tests/test_tool_identity.py": 0.0, + "tests/test_tool_reach.py": 18.0, + "tests/test_tool_reach_effects.py": 2.1, "tests/test_tool_surface_diff.py": 0.0, - "tests/test_toolkit_bounds_check.py": 1.1, - "tests/test_trigger_command.py": 1.5, - "tests/test_trust_root.py": 1.3, - "tests/test_ts_tool_descriptions.py": 0.1, - "tests/test_unchanged_host_limits.py": 21.3, - "tests/test_unread_changed_inputs.py": 157.7, + "tests/test_toolkit_bounds_check.py": 1.2, + "tests/test_trigger_command.py": 0.2, + "tests/test_trust_root.py": 21.1, + "tests/test_ts_tool_descriptions.py": 0.2, + "tests/test_type_checking_constructor_ownership.py": 168.1, + "tests/test_unchanged_host_limits.py": 19.5, + "tests/test_unread_changed_inputs.py": 154.5, "tests/test_unresolved_descriptions.py": 0.1, - "tests/test_v07_metadata_roundtrip.py": 1.7, - "tests/test_validation_evidence.py": 1.3, + "tests/test_v07_metadata_roundtrip.py": 1.9, + "tests/test_validation_evidence.py": 1.4, "tests/test_verdict_contract.py": 0.0, - "tests/test_verification_git_snapshot.py": 5.5, - "tests/test_verifier_blocks.py": 0.6, - "tests/test_verifier_control_contract.py": 1.2, - "tests/test_verifier_scenarios.py": 29.8, - "tests/test_verify.py": 115.1, - "tests/test_verify_auto_base.py": 21.7, - "tests/test_verify_capability_scope.py": 0.4, - "tests/test_verify_config_binding.py": 0.7, - "tests/test_verify_orchestrator.py": 41.2, + "tests/test_verification_git_snapshot.py": 30.1, + "tests/test_verifier_blocks.py": 1.2, + "tests/test_verifier_control_contract.py": 1.0, + "tests/test_verifier_scenarios.py": 26.9, + "tests/test_verify.py": 101.8, + "tests/test_verify_auto_base.py": 18.5, + "tests/test_verify_capability_scope.py": 0.7, + "tests/test_verify_config_binding.py": 1.1, + "tests/test_verify_orchestrator.py": 39.3, "tests/test_verify_run.py": 0.0, - "tests/test_verify_weakening.py": 21.7, + "tests/test_verify_weakening.py": 19.7, "tests/test_vscode_mcp_jsonc.py": 0.0, "tests/test_vscode_mcp_support.py": 0.0, - "tests/test_wheel_candidate_build.py": 1.9, - "tests/test_workflow_agent_launches.py": 83.8, - "tests/test_workflow_capability_diff.py": 2.4, - "tests/test_workflow_evidence.py": 0.0, - "tests/test_workflow_label_redaction.py": 57.0, - "tests/test_workflow_step_action_references.py": 42.9, + "tests/test_wheel_candidate_build.py": 1.6, + "tests/test_workflow_agent_launches.py": 67.8, + "tests/test_workflow_capability_diff.py": 1.7, + "tests/test_workflow_evidence.py": 0.1, + "tests/test_workflow_label_redaction.py": 46.3, + "tests/test_workflow_step_action_references.py": 38.9, "tests/test_workspace_input_guard.py": 0.3, - "tests/test_zero_install_detector.py": 39.2 + "tests/test_zero_install_detector.py": 34.3 }, - "measured_at": "0a1cfb677de2" + "measured_at": "cbff1ef0b101", + "measurement": { + "case_count": 21581, + "file_count": 426, + "report_sha256": "a523a72643fd5daf9a09b7ca0efc6c36a5218b9890a53f7f428f655b04dcb0ab", + "source": "junit", + "time_basis": "JUnit testcase time; phase accounting is selected by the runner" + } } diff --git a/tests/test_adk_constructor_value_ownership.py b/tests/test_adk_constructor_value_ownership.py new file mode 100644 index 000000000..92356b7b7 --- /dev/null +++ b/tests/test_adk_constructor_value_ownership.py @@ -0,0 +1,185 @@ +"""ADK static value roles preserve existing sinks without granting object APIs.""" + +import pytest + +from agents_shipgate.core.artifact_models import GoogleAdkArtifacts +from agents_shipgate.inputs.google_adk import _load_python_path + +HEADER = '''from google.adk.agents import LlmAgent +from google.adk.tools.mcp_tool import McpToolset +from google.adk.tools.openapi_tool.openapi_spec_parser.openapi_toolset import OpenAPIToolset +from google.adk.tools import ToolContext +from pathlib import Path +raise RuntimeError("the application must never be imported") +def lookup(q: str) -> str: + """Look up the stored record.""" + return q +''' +MCP = 'McpToolset(inventory_path="inventory.json", tool_filter=["remote"])' +OPENAPI = 'OpenAPIToolset(spec_str=Path("spec.yaml").read_text(), spec_str_type="yaml")' + + +def load(tmp_path, body, extra=None): + project = tmp_path / 'project' + project.mkdir() + (project / 'agent.py').write_text(HEADER + body) + (project / 'inventory.json').write_text('{"tools":[{"name":"remote","description":"Read a remote record","inputSchema":{"type":"object","properties":{"q":{"type":"string"}}}}]}') + (project / 'spec.yaml').write_text('openapi: 3.1.0\ninfo:\n title: fixture\n version: "1"\npaths:\n /record:\n get:\n operationId: api.read\n responses:\n "200":\n description: ok\n') + for path, content in (extra or {}).items(): + target = project / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + artifacts = GoogleAdkArtifacts() + sources = _load_python_path(project / 'agent.py', project, 'adk', 'agent.py', artifacts) + return [tool for source in sources for tool in source.tools], artifacts + + +def established(tools, artifacts): + assert tools and all(tool.extraction_confidence == 'high' for tool in tools) + assert not any('constructor identity is not established' in warning for warning in artifacts.warnings) + + +def limited(tools, artifacts): + assert any('constructor identity is not established' in warning for warning in artifacts.warnings), artifacts.warnings + assert all(tool.extraction_confidence != 'high' for tool in tools) + + +@pytest.mark.parametrize('value', [MCP, OPENAPI]) +@pytest.mark.parametrize('cache', [False, True]) +def test_resolved_toolset_keeps_the_existing_literal_agent_tools_sink(tmp_path, value, cache): + body = ('held = ' + value + '\n' if cache else '') + body += 'root_agent = LlmAgent(name="x", tools=[lookup, ' + ('held' if cache else value) + '])\n' + tools, artifacts = load(tmp_path, body) + established(tools, artifacts) + assert {tool.name for tool in tools} == {'lookup', 'remote' if value == MCP else 'api.read'} + + +@pytest.mark.parametrize('value', [MCP, OPENAPI]) +@pytest.mark.parametrize('use', [ + 'opaque(held)', 'saved = held', 'held.copy()', 'held.count(None)', 'held.index(None)', + 'held["tools"]', 'held.metadata', 'held.__init__', + 'root_agent = LlmAgent(name="y", tools=[], before_tool_callback=held)', +]) +def test_toolset_result_does_not_borrow_container_or_callback_permissions(tmp_path, value, use): + tools, artifacts = load(tmp_path, 'held = ' + value + '\n' + use + '\nroot_agent = LlmAgent(name="x", tools=[lookup, held])\n') + limited(tools, artifacts) + + +@pytest.mark.parametrize('argument', [ + 'tool_filter=lookup', 'tool_filter=[lookup]', 'header_provider=lookup', + 'progress_callback=lookup', 'sampling_callback=lookup', 'require_confirmation=lookup', + 'connection_params=lookup', 'unknown_option=None', '**options', '*options', +]) +def test_mcp_callback_and_unknown_configuration_remain_unread(tmp_path, argument): + tools, artifacts = load(tmp_path, 'options = {}\nheld = McpToolset(inventory_path="inventory.json", ' + argument + ')\nroot_agent = LlmAgent(name="x", tools=[lookup, held])\n') + limited(tools, artifacts) + + +@pytest.mark.parametrize('argument', [ + 'header_provider=lookup', 'httpx_client_factory=lookup', 'auth_scheme=lookup', + 'auth_credential=lookup', 'tool_filter=lookup', 'unknown_option=None', + 'spec_str=read_spec()', 'spec_str=Path("spec.yaml").other()', +]) +def test_openapi_only_reads_literal_or_exact_local_spec_data(tmp_path, argument): + tools, artifacts = load(tmp_path, 'held = OpenAPIToolset(spec_path="spec.yaml", ' + argument + ')\nroot_agent = LlmAgent(name="x", tools=[lookup, held])\n') + limited(tools, artifacts) + + +@pytest.mark.parametrize('provider', ['mcp.py', 'mcp/__init__.py', 'pathlib.py', 'json.py', 'yaml.py', 'yaml/__init__.py']) +def test_toolset_dependency_and_path_providers_are_proved(tmp_path, provider): + value = OPENAPI if provider.startswith(('pathlib', 'json', 'yaml')) else MCP + tools, artifacts = load(tmp_path, 'root_agent = LlmAgent(name="x", tools=[lookup, ' + value + '])\n', {provider: 'from abc import ABCMeta\ndef replacement(*args, **kwargs):\n return None\nABCMeta.__call__ = replacement\n'}) + limited(tools, artifacts) + + +def test_toolset_instance_identity_comparison_keeps_its_data_role(tmp_path): + tools, artifacts = load(tmp_path, 'held = ' + MCP + '\nheld is None\nroot_agent = LlmAgent(name="x", tools=[lookup, held])\n') + established(tools, artifacts) + + +@pytest.mark.parametrize('path', ['google.adk.tools', 'google.adk.tools.tool_context']) +def test_exact_own_family_context_annotation_keeps_injection(tmp_path, path): + tools, artifacts = load(tmp_path, 'from ' + path + ' import ToolContext as ContextType\ndef contextual(ctx: ContextType, q: str) -> str:\n """Read a record in its injected context."""\n return q\nroot_agent = LlmAgent(name="x", tools=[lookup, contextual])\n') + established(tools, artifacts) + contextual = next(tool for tool in tools if tool.name == 'contextual') + assert set(contextual.input_schema['properties']) == {'q'} + + +@pytest.mark.parametrize('use', [ + 'opaque(ToolContext)', 'saved = ToolContext', 'ToolContext()', 'saved = ToolContext.metadata', + 'values = [ToolContext]', 'ToolContext.__init__ = None', +]) +def test_context_annotation_role_does_not_grant_other_class_uses(tmp_path, use): + tools, artifacts = load(tmp_path, use + '\nroot_agent = LlmAgent(name="x", tools=[lookup])\n') + limited(tools, artifacts) + + +def test_context_annotation_cannot_borrow_a_foreign_sdk_owner(tmp_path): + tools, artifacts = load(tmp_path, 'from agents import Agent, function_tool\n@function_tool\ndef foreign(ctx: ToolContext, q: str) -> str:\n return q\nforeign_agent = Agent(name="foreign", tools=[foreign])\nroot_agent = LlmAgent(name="x", tools=[lookup])\n') + limited(tools, artifacts) + + +CONNECTIONS = ( + "import os\n" + "from google.adk.tools.mcp_tool import StdioConnectionParams, StreamableHTTPConnectionParams\n" + "from mcp import StdioServerParameters\n" +) +HTTP = 'StreamableHTTPConnectionParams(url="https://example.test/mcp", headers={"Authorization": os.environ["KEY"]})' +STDIO = ('StdioConnectionParams(server_params=StdioServerParameters(command="npx", args=["server"], ' + 'env={"API_KEY": os.environ["KEY"]}))') + + +def _bound(connection, wrap='{}'): + toolset = f'McpToolset(inventory_path="inventory.json", connection_params={wrap.format(connection)})' + return CONNECTIONS + f'held = {toolset}\nroot_agent = LlmAgent(name="x", tools=[lookup, held])\n' + + +@pytest.mark.parametrize('connection', [HTTP, STDIO], ids=['headers', 'nested_stdio_env']) +def test_an_environment_read_in_connection_data_reaching_the_toolset_is_a_string(tmp_path, connection): + tools, artifacts = load(tmp_path, _bound(connection)) + established(tools, artifacts) + + +@pytest.mark.parametrize('connection', [HTTP, STDIO], ids=['headers', 'nested_stdio_env']) +@pytest.mark.parametrize('route', [ + 'opaque({})', # Handed to an unrelated call before it reaches the toolset. + '[{}][0]', # Held in a container. + '(lambda: {})()', # Built where the framework could not own it. +]) +def test_an_environment_read_in_connection_data_is_unread_off_the_toolset_route(tmp_path, connection, route): + tools, artifacts = load(tmp_path, _bound(connection, route)) + limited(tools, artifacts) + + +def test_connection_data_is_unread_when_it_is_saved_before_the_toolset(tmp_path): + body = CONNECTIONS + f'params = {HTTP}\nheld = McpToolset(inventory_path="inventory.json", connection_params=params)\n' \ + 'root_agent = LlmAgent(name="x", tools=[lookup, held])\n' + tools, artifacts = load(tmp_path, body) + limited(tools, artifacts) + + +def test_nested_connection_data_follows_only_connection_classes(tmp_path): + connection = 'StdioConnectionParams(server_params=wrap(StdioServerParameters(command="npx", env={"API_KEY": os.environ["KEY"]})))' + tools, artifacts = load(tmp_path, _bound(connection)) + limited(tools, artifacts) + + +@pytest.mark.parametrize('keyword', ['header_provider', 'tool_filter', 'errlog']) +def test_connection_data_reaches_only_the_toolsets_connection_params(tmp_path, keyword): + body = CONNECTIONS + f'held = McpToolset(inventory_path="inventory.json", {keyword}={STDIO})\n' \ + 'root_agent = LlmAgent(name="x", tools=[lookup, held])\n' + tools, artifacts = load(tmp_path, body) + limited(tools, artifacts) + + +def test_an_mcp_connection_class_the_repository_shadows_is_not_the_packages(tmp_path): + tools, artifacts = load(tmp_path, _bound(STDIO), extra={'mcp.py': 'class StdioServerParameters:\n def __init__(self, **options):\n self.options = options\n'}) + limited(tools, artifacts) + + +@pytest.mark.parametrize('argument', ['tool_name_prefix=os.environ["KEY"]', 'credential_key=os.environ["KEY"]']) +def test_an_environment_read_as_a_toolset_argument_stays_unread(tmp_path, argument): + body = CONNECTIONS + f'held = McpToolset(inventory_path="inventory.json", {argument})\n' \ + 'root_agent = LlmAgent(name="x", tools=[lookup, held])\n' + tools, artifacts = load(tmp_path, body) + limited(tools, artifacts) diff --git a/tests/test_adk_tool_factories.py b/tests/test_adk_tool_factories.py index fa11e462d..a20636bed 100644 --- a/tests/test_adk_tool_factories.py +++ b/tests/test_adk_tool_factories.py @@ -257,11 +257,13 @@ def test_application_diff_shows_the_memory_tools_a_factory_adds(tmp_path): head = _commit(tmp_path, _visulate("read_memory_tool, save_memory_tool")) result = _compare(tmp_path, base, head, "--scope", "ai-agent") - assert result["comparison_status"] == "compared" - assert [(row["agent"], row["tool"], row["change"]) for row in result["rows"]] == [ + assert result["comparison_status"] == "partial" + assert all(row["change"] == "not_established" for row in result["rows"]) + assert [(row["agent"], row["tool"], row["candidate_change"]) for row in result["rows"] if row["candidate_change"] == "added"] == [ ("visulate_root_agent", "save_memory_record", "added"), ] - after = result["rows"][0]["after"] + assert any("constructor identity is not established" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + after = next(row["after"] for row in result["rows"] if row["tool"] == "save_memory_record") assert after["definition"]["source"] == "ai-agent/common/tools.py" assert after["definition"]["line"] == 5 @@ -289,8 +291,12 @@ def test_a_factory_s_function_changing_is_an_implementation_change(tmp_path): base = _commit(tmp_path, _visulate()) head = _commit(tmp_path, {"ai-agent/common/tools.py": MEMORY.replace("return content", "return content.upper()")}) result = _compare(tmp_path, base, head, "--scope", "ai-agent") - rows = [(row["agent"], row["tool"], row["change"]) for row in result["rows"]] - assert ("visulate_root_agent", "save_memory_record", "changed") in rows + assert result["comparison_status"] == "partial" + assert all(row["change"] == "not_established" for row in result["rows"]) + row = next(row for row in result["rows"] if row["tool"] == "save_memory_record") + assert row["candidate_change"] == "changed" + assert row["before"]["definition"]["implementation_sha256"] != row["after"]["definition"]["implementation_sha256"] + assert any("constructor identity is not established" in gap["reason"] for gap in result["head"]["coverage_gaps"]) # -- a tools list built in the agent's function --------------------------------- @@ -328,7 +334,12 @@ def test_a_tools_list_built_in_the_agent_s_function_is_read(tmp_path, body, boun _write(tmp_path, _list_agent(body)) loaded, artifacts = _adk(tmp_path) assert [tool for _, tool, _ in _edges(loaded, artifacts)] == bound - assert artifacts.warnings == [] + observations = [item for source in loaded for item in source.binding_observations] + if "tools +=" not in body: + assert not artifacts.warnings and all(item.tools_complete for item in observations) + else: + assert any("constructor identity is not established" in warning for warning in artifacts.warnings) + assert observations and all(not item.tools_complete for item in observations) def test_a_tool_added_under_a_condition_is_named_beside_the_list(tmp_path): @@ -344,6 +355,7 @@ def test_a_tool_added_under_a_condition_is_named_beside_the_list(tmp_path): assert message in artifacts.warnings (observation,) = [item for source in loaded for item in source.binding_observations] assert observation.tools_complete is False + assert message in observation.issues assert observation.issues == [message] @@ -491,7 +503,49 @@ def test_a_factory_docstring_is_not_a_change(tmp_path): {"ai-agent/common/tools.py": MEMORY.replace("return content", '"""Save a record."""\n return content')}, ) result = _compare(tmp_path, before, after, "--scope", "ai-agent") - assert [row for row in result["rows"] if row["tool"] == "save_memory_record"] == [] + (row,) = [row for row in result["rows"] if row["tool"] == "save_memory_record"] + assert row["change"] == "not_established" + assert row["before"]["definition"]["implementation_sha256"] == row["after"]["definition"]["implementation_sha256"] + assert any("constructor identity is not established" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + + +@pytest.mark.parametrize("change", ["added", "implementation", "docstring"]) +def test_annotated_factories_with_deferred_headers_keep_clean_comparison_controls(tmp_path, change): + # This is a bounded synthetic control. The real Visulate fixture above + # keeps its eager annotations and its explicit constructor uncertainty. + source = '''from __future__ import annotations +from google.adk.agents import Agent +from google.adk.tools import FunctionTool + +def make_read() -> FunctionTool: + def read(query: str) -> str: + return query + return FunctionTool(read) + +def make_write() -> FunctionTool: + def write(query: str) -> str: + return query + return FunctionTool(write) + +def build() -> Agent: + return Agent(name='root', tools=[make_read()]) +a = build() +''' + changed = { + "added": source.replace("tools=[make_read()]", "tools=[make_read(), make_write()]"), + "implementation": source.replace("return query", "return query.upper()", 1), + "docstring": source.replace(" return query", ' """Read a query."""\n return query', 1), + }[change] + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, {"agent.py": source}), _commit(tmp_path, {"agent.py": changed}) + result = _compare(tmp_path, base, head) + assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] + if change == "docstring": + assert result["rows"] == [] + else: + assert [(row["tool"], row["change"]) for row in result["rows"]] == [ + ("write", "added") if change == "added" else ("read", "changed") + ] @pytest.mark.parametrize( @@ -815,7 +869,14 @@ def test_a_value_the_read_can_follow_is_data(tmp_path, factory, call): after = _commit(tmp_path, {"README.md": "b\n"}) # The whole tree: a README-only change derives no scope of its own (#875). result = _compare(tmp_path, before, after, "--scope", ".") - assert result["rows"] == [] and result["comparison_status"] == "compared", result["head"]["coverage_gaps"] + if call == "make(upper)": + assert result["comparison_status"] == "partial" + assert result["rows"] and all(row["change"] == "not_established" for row in result["rows"]) + assert all(row["before"]["definition"]["implementation_sha256"] == row["after"]["definition"]["implementation_sha256"] + for row in result["rows"]) + assert any("constructor identity is not established" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + else: + assert result["rows"] == [] and result["comparison_status"] == "compared", result["head"]["coverage_gaps"] @pytest.mark.parametrize( diff --git a/tests/test_agent_bindings.py b/tests/test_agent_bindings.py index df956a5a8..fe855aab2 100644 --- a/tests/test_agent_bindings.py +++ b/tests/test_agent_bindings.py @@ -1,5 +1,7 @@ from __future__ import annotations +import pytest + from agents_shipgate.core.agent_bindings import resolve_agent_binding_graph from agents_shipgate.core.artifacts import ArtifactBag from agents_shipgate.core.domain import ( @@ -224,6 +226,122 @@ def test_agent_level_observation_preserves_toolless_router_handoff() -> None: assert len(graph.handoff_edges) == 1 +def test_constructor_causes_keep_each_site_and_cannot_be_closed_by_wiring() -> None: + from agents_shipgate.ci.release_decision import _binding_declaration_template, _semantic_gap + from agents_shipgate.core.agent_bindings import FRAMEWORK_CONSTRUCTOR_OWNERSHIP + + tool = _tool("lookup") + cause = "An opaque imported carrier remains unread." + unrelated = "constructor identity is not established is merely wording here" + observation = AgentBindingObservation( + agent="root_agent", source_id="catalog", source="agent.py", + source_pointer="agent.py:4", tool_names=["lookup"], tools_complete=False, + issues=[cause, unrelated], + constructor_issues={"agent.py:4": cause, "agent.py:8": cause}, + ) + assert "constructor_issues" not in observation.model_dump(mode="json") + loaded = LoadedToolSource( + source_id="catalog", source_type="openai_agents_sdk", tools=[tool], + binding_observations=[observation], + ) + graph, _ = resolve_agent_binding_graph( + _manifest(sdk_object="root_agent", bindings={"declarations": [{ + "agent": "root_agent", "complete": True, "reason": "reviewed fixture wiring", + "tools": [{"tool": "lookup", "source_id": "catalog"}], + }]}), [tool], ArtifactBag(), [loaded], + ) + assert graph.reachable_tool_ids == [tool.id] + assert not graph.possible_tool_ids + assert not graph.pass_eligible + ownership = [issue for issue in graph.issues if issue.source == FRAMEWORK_CONSTRUCTOR_OWNERSHIP] + assert [issue.source_pointer for issue in ownership] == ["agent.py:4", "agent.py:8"] + assert all(issue.agent_id == graph.root_agent_id for issue in ownership) + assert [issue.message for issue in graph.issues if issue.source == "framework_extraction"] == [unrelated] + assert tool.binding_assessment is not None and not tool.binding_assessment.pass_eligible + for issue in ownership: + assert _binding_declaration_template(graph, issue, [tool]) is None + gap = _semantic_gap(tool, kind=issue.kind, why=issue.message, + issue_source=issue.source, source_ref=issue.source_pointer) + assert gap.next_action.kind == "provide_source" + assert gap.next_action.path == issue.source_pointer + assert not gap.next_action.accepted_values + assert gap.next_action.declaration_template is None + + +@pytest.mark.parametrize("declared", [True, False]) +@pytest.mark.parametrize("reachable", [True, False]) +@pytest.mark.parametrize("unsafe_agent", [None, "root_agent", "worker"]) +def test_shared_tool_keeps_constructor_causes_only_from_reachable_owners( + declared: bool, reachable: bool, unsafe_agent: str | None, +) -> None: + from agents_shipgate.ci.release_decision import _semantic_gap + from agents_shipgate.core.agent_bindings import FRAMEWORK_CONSTRUCTOR_OWNERSHIP + + tool = _tool("lookup") + cause = "The actual constructor carrier remains unread." + observations = [] + declarations = [] + for name in ("root_agent", "worker"): + unsafe = name == unsafe_agent + pointer = f"{name}.py:4" + handoffs = ["worker"] if name == "root_agent" and reachable else [] + observations.append(AgentBindingObservation( + agent=name, source_id="catalog", source=f"{name}.py", + source_pointer=pointer, tool_names=["lookup"], handoff_names=handoffs, + tools_complete=not unsafe, issues=[cause] if unsafe else [], + constructor_issues={pointer: cause} if unsafe else {}, + )) + declarations.append({ + "agent": name, "complete": True, "reason": "reviewed fixture wiring", + "tools": [{"tool": "lookup", "source_id": "catalog"}], + "handoffs": handoffs, + }) + loaded = LoadedToolSource( + source_id="catalog", source_type="openai_agents_sdk", tools=[tool], + binding_observations=observations, + ) + graph, _ = resolve_agent_binding_graph( + _manifest(sdk_object="root_agent", bindings={"declarations": declarations} if declared else None), + [tool], ArtifactBag(), [loaded], + ) + complete_owner_reachable = declared or reachable or unsafe_agent != "root_agent" + assert graph.reachable_tool_ids == ([tool.id] if complete_owner_reachable else []) + assert graph.possible_tool_ids == ([] if complete_owner_reachable else [tool.id]) + graph_causes = [issue for issue in graph.issues if issue.source == FRAMEWORK_CONSTRUCTOR_OWNERSHIP] + assert len(graph_causes) == (unsafe_agent is not None) + assert graph.pass_eligible is (unsafe_agent is None) + binding = tool.binding_assessment + assert binding is not None + assert binding.pass_eligible is (unsafe_agent is None) + if not declared and unsafe_agent is not None: + unsafe_id = next(agent.agent_id for agent in graph.agents if agent.name == unsafe_agent) + assert all(claim.value != f"{unsafe_id}->{tool.id}" for claim in binding.claims) + inherited = [issue for issue in binding.issues if issue.source == FRAMEWORK_CONSTRUCTOR_OWNERSHIP] + expected = unsafe_agent is not None and (unsafe_agent == "root_agent" or reachable) + assert len(inherited) == expected + for issue in inherited: + assert issue.source_pointer == f"{unsafe_agent}.py:4" + gap = _semantic_gap(tool, kind=issue.kind, why=issue.message, + issue_source=issue.source, source_ref=issue.source_pointer) + assert gap.next_action.kind == "provide_source" + assert gap.next_action.path == issue.source_pointer + assert not gap.next_action.accepted_values + assert gap.next_action.declaration_template is None + + +def test_catalog_annotation_cannot_claim_constructor_cause_provenance() -> None: + from agents_shipgate.core.agent_bindings import FRAMEWORK_CONSTRUCTOR_OWNERSHIP + + tool = _tool("lookup", agent="root_agent") + annotation = tool.annotations["agent_bindings"][0] + annotation["complete"] = False + annotation["source"] = FRAMEWORK_CONSTRUCTOR_OWNERSHIP + graph, _ = resolve_agent_binding_graph(_manifest(sdk_object="root_agent"), [tool], ArtifactBag()) + assert not graph.pass_eligible + assert graph.possible_tool_ids == [tool.id] + assert all(issue.source != FRAMEWORK_CONSTRUCTOR_OWNERSHIP for issue in graph.issues) + + def test_incomplete_handoff_from_reachable_agent_fails_closed() -> None: root_tool = _tool("route", source_id="sdk", agent="root_agent") root_tool.annotations["agent_handoffs"] = [ @@ -245,3 +363,103 @@ def test_incomplete_handoff_from_reachable_agent_fails_closed() -> None: assert graph.reachable_tool_ids == [root_tool.id] assert graph.pass_eligible is False assert "incomplete_handoff_graph" in {issue.kind for issue in graph.issues} + + +@pytest.mark.parametrize("cycle", [False, True]) +def test_candidate_handoff_paths_preserve_downstream_tools_without_proving_them(cycle): + route = _tool("route", source_id="sdk", agent="root_agent") + child = _tool("child", source_id="sdk", agent="child") + grandchild = _tool("grandchild", source_id="sdk", agent="grandchild") + disconnected = _tool("disconnected", source_id="sdk", agent="other") + route.annotations["agent_handoffs"] = [dict( + source_agent="root_agent", target_agent="child", source_id="sdk", complete=False, + )] + child.annotations["agent_handoffs"] = [dict( + source_agent="child", target_agent="grandchild", source_id="sdk", complete=True, + )] + if cycle: + grandchild.annotations["agent_handoffs"] = [dict( + source_agent="grandchild", target_agent="child", source_id="sdk", complete=True, + )] + graph, tools = resolve_agent_binding_graph( + _manifest(sdk_object="root_agent"), [route, child, grandchild, disconnected], ArtifactBag(), + ) + assert graph.reachable_tool_ids == [route.id] + assert graph.possible_tool_ids == sorted([child.id, grandchild.id]) + assert graph.unbound_tool_ids == [disconnected.id] + assert not graph.pass_eligible + assert {issue.tool_id for issue in graph.issues if issue.tool_id} >= {child.id, grandchild.id} + assert all(not tool.binding_assessment.pass_eligible for tool in tools if tool.id in graph.possible_tool_ids) + + +def test_a_complete_path_wins_over_an_unread_owner_of_the_same_tool(): + shared = _tool("shared", source_id="sdk", agent="root_agent") + shared.annotations["agent_bindings"].append(dict( + agent="child", source_id="sdk", edge_type="direct_tool", source="agent.py", + source_pointer="/child/tools", complete=False, + )) + shared.annotations["agent_handoffs"] = [dict( + source_agent="root_agent", target_agent="child", source_id="sdk", complete=False, + )] + graph, tools = resolve_agent_binding_graph( + _manifest(sdk_object="root_agent"), [shared], ArtifactBag(), + ) + assert graph.reachable_tool_ids == [shared.id] and graph.possible_tool_ids == [] + assert len(tools[0].binding_assessment.claims) == 1 + assert tools[0].binding_assessment.claims[0].evidence["complete"] is True + assert not tools[0].binding_assessment.pass_eligible + assert not graph.pass_eligible # The unread handoff remains visible globally. + + +def _adk_graph(*toolsets): + from agents_shipgate.core.artifact_models import GoogleAdkArtifacts + + bag = ArtifactBag() + bag.set("google_adk", GoogleAdkArtifacts( + agents=[{"name": "root_agent", "source_id": "adk", "source_ref": "agent.py"}], + toolsets=list(toolsets), + )) + graph, _ = resolve_agent_binding_graph(_manifest(sdk_object="root_agent"), [], bag, []) + return graph + + +def _toolset(kind="mcp", *, connection="read", limitations=()): + from agents_shipgate.core.artifact_models import GoogleAdkToolset, GoogleAdkToolsetConnection + + return GoogleAdkToolset( + kind=kind, source_id="adk", source_ref="agent.py:5", agent_name="root_agent", + name="McpToolset" if kind == "mcp" else None, dynamic=True, + connection=GoogleAdkToolsetConnection(limitations=list(limitations)) if connection == "read" else None, + ) + + +def test_a_remote_toolset_whose_binding_was_read_has_its_own_issue_identity() -> None: + from agents_shipgate.core.agent_bindings import FRAMEWORK_TOOLSET_INVENTORY + + graph = _adk_graph(_toolset()) + # Still a partial surface, as for any unenumerated one; but the issue says + # why, so a capability comparison does not read it as an unread binding. + assert graph.status == "partial" and not graph.pass_eligible + (issue,) = graph.issues + assert issue.kind == "partial_binding_evidence" + assert issue.source == FRAMEWORK_TOOLSET_INVENTORY + assert issue.message == "Google ADK toolset 'McpToolset' is not statically enumerable." + + +@pytest.mark.parametrize("toolset", [ + _toolset("dynamic", connection=None), + _toolset("mcp", limitations=["shadowed_toolset_constructor"]), + _toolset("openapi", connection=None), +], ids=["unread_toolset", "unproven_constructor", "openapi_without_spec"]) +def test_a_toolset_that_was_not_read_stays_an_ordinary_partial(toolset) -> None: + graph = _adk_graph(toolset) + (issue,) = graph.issues + assert issue.kind == "partial_binding_evidence" + assert issue.source == "framework_extraction" + + +def test_the_unproven_constructor_marker_matches_the_reader_constant() -> None: + from agents_shipgate.core.agent_bindings import _UNPROVEN_TOOLSET_CONSTRUCTOR + from agents_shipgate.inputs.google_adk import LIMIT_SHADOWED_TOOLSET_CONSTRUCTOR + + assert _UNPROVEN_TOOLSET_CONSTRUCTOR == LIMIT_SHADOWED_TOOLSET_CONSTRUCTOR diff --git a/tests/test_application_diff_identity.py b/tests/test_application_diff_identity.py index 152a3f027..83e69e4bc 100644 --- a/tests/test_application_diff_identity.py +++ b/tests/test_application_diff_identity.py @@ -135,21 +135,21 @@ def {name}_tool(query: str) -> str: @pytest.mark.parametrize( - "source", + ("source", "limit"), [ # PR #873 review: an import in a sibling function decided identity. - FACTORY.format(name="sdk", module="agents") + (FACTORY.format(name="sdk", module="agents") + "\n\n" - + FACTORY.format(name="voice", module="livekit.agents"), + + FACTORY.format(name="voice", module="livekit.agents"), "'sdk' (main.py:1) is named in a string"), # A function's own import shadows the module's SDK import. - "from agents import Agent as Builder, function_tool\n\n\n" + ("from agents import Agent as Builder, function_tool\n\n\n" + FACTORY.format(name="voice", module="livekit.agents") + "\n\n@function_tool\ndef sdk_tool(query: str) -> str:\n return query\n" - + 'sdk_agent = Builder(name="sdk", tools=[sdk_tool])\n', + + 'sdk_agent = Builder(name="sdk", tools=[sdk_tool])\n', "an unread decorator receives a function carrying the constructor namespace"), ], ids=["sibling-function-import", "inner-import-shadows-module"], ) -def test_import_provenance_is_resolved_in_the_enclosing_scope(tmp_path, source): +def test_import_provenance_is_resolved_in_the_enclosing_scope(tmp_path, source, limit): (tmp_path / "main.py").write_text(source) loaded = load_openai_sdk_static_tools( ToolSourceConfig(id="sdk", type="openai_agents_sdk", path="main.py"), None, tmp_path @@ -158,7 +158,10 @@ def test_import_provenance_is_resolved_in_the_enclosing_scope(tmp_path, source): assert [(o.agent, o.tool_names) for o in loaded.binding_observations] == [ ("sdk_agent", ["sdk_tool"]) ] - assert loaded.warnings == [] + assert len(loaded.warnings) == 1 + assert "constructor identity is not established" in loaded.warnings[0] + assert limit in loaded.warnings[0] + assert loaded.binding_observations[0].tools_complete is False @pytest.mark.parametrize("spelling", ["agent", "exported as agent"]) @@ -185,10 +188,12 @@ def test_import_bound_agent_name_is_not_a_removal(repo, spelling): (r["agent"], r["tool"], r["change"], r["candidate_change"]) for r in result["rows"] ] == [ ("agent", "lookup", "not_established", "removed"), - ("assistant", "lookup", "added", None), + ("assistant", "lookup", "not_established", "added"), ] assert list(result["rows"][0]["uncertainty"]) == ["head"] assert result["rows"][1]["after"]["agent_source"] == "agent_factory.py" + assert any("constructor identity is not established" in gap["reason"] + and gap["source"] == "agent_factory.py" for gap in result["head"]["coverage_gaps"]) @pytest.mark.parametrize("construction", sorted(UNREAD_CONSTRUCTIONS)) @@ -247,8 +252,14 @@ def test_handoff_reference_is_not_an_observed_construction(repo): result = run(repo, base, head) assert result["comparison_status"] == "partial" assert [(r["agent"], r["tool"], r["change"]) for r in result["rows"]] == [ - ("worker", "execute", "not_established") + ("agent", "lookup", "not_established"), + ("triage", "handoff:agent.py:worker", "not_established"), + ("worker", "execute", "not_established"), ] + assert result["rows"][-1]["candidate_change"] == "removed" + assert any(gap["agent"] == "worker" and "subclass 'Worker'" in gap["reason"] + for gap in result["head"]["coverage_gaps"]) + assert any("unread class construction hook" in gap["reason"] for gap in result["head"]["coverage_gaps"]) def test_unread_base_construction_is_not_an_addition(repo): diff --git a/tests/test_application_diff_reach.py b/tests/test_application_diff_reach.py index 31d461c8b..2c472e4e2 100644 --- a/tests/test_application_diff_reach.py +++ b/tests/test_application_diff_reach.py @@ -38,6 +38,13 @@ def gitlink(repo, path, commit_id): git(repo, "update-index", "--add", "--cacheinfo", f"160000,{commit_id},{path}") +def _assert_unread_caller_link(result, path): + assert result["comparison_status"] == "partial" + assert result["rows"] and all(row["change"] == "not_established" for row in result["rows"]) + for side in ("base", "head"): + assert any(f"{path} is a link; a caller behind it is not read" in limit for limit in result[side]["limits"]) + + @pytest.mark.parametrize("shape", sorted(LINKS)) def test_root_scope_reads_past_a_link_python_discovery_never_follows(repo, shape): path, target = LINKS[shape] @@ -46,6 +53,9 @@ def test_root_scope_reads_past_a_link_python_discovery_never_follows(repo, shape base = commit(repo, {"agent.py": SDK.replace("TOOLS", "[lookup]")}) head = commit(repo, {"agent.py": SDK.replace("TOOLS", "[lookup, execute]")}) result = run(repo, base, head) + if shape not in {"absolute", "in_tree_directory"}: + _assert_unread_caller_link(result, path) + return assert result["comparison_status"] == "compared", result["head"]["limits"] assert [(r["agent"], r["tool"], r["change"]) for r in result["rows"]] == [ ("agent", "execute", "added") @@ -62,9 +72,9 @@ def test_python_link_to_an_input_already_read_is_not_read_twice(repo): base = commit(repo, {}) head = commit(repo, {"real/helper.py": source.replace("TOOLS", "[lookup, execute]")}) result = run(repo, base, head, "--scope", ".") - assert result["comparison_status"] == "compared", result["head"]["limits"] + _assert_unread_caller_link(result, "helper.py") assert [(r["agent_source"], r["tool"], r["change"]) for r in result["rows"]] == [ - ("real/helper.py", "execute", "added") + ("real/helper.py", "execute", "not_established"), ("real/helper.py", "lookup", "not_established"), ] assert result["head"]["sources"] == [{"type": "openai_agents_sdk", "path": "real/helper.py"}] @@ -85,13 +95,11 @@ def test_python_link_to_anything_else_is_a_gap_over_its_path(repo, target): head = commit(repo, {"app/agent.py": SDK.replace("TOOLS", "[lookup, execute]")}) result = run(repo, base, head, "--scope", "app") assert result["comparison_status"] == "partial" - assert [(r["agent_source"], r["tool"], r["change"]) for r in result["rows"]] == [ - ("agent.py", "execute", "added") - ] + _assert_unread_caller_link(result, "tools.py") assert result["head"]["sources"] == [{"type": "openai_agents_sdk", "path": "agent.py"}] for side in ("base", "head"): - assert [(g["source"], g["reason"]) for g in result[side]["coverage_gaps"]] == [ - ("tools.py", "Linked Python input: tools.py") + assert ("tools.py", "Linked Python input: tools.py") in [ + (g["source"], g["reason"]) for g in result[side]["coverage_gaps"] ] @@ -153,8 +161,7 @@ def test_unchanged_unresolved_link_beside_the_application_is_not_a_gap(repo): base = commit(repo, {"app/agent.py": SDK.replace("TOOLS", "[lookup]")}) head = commit(repo, {"app/agent.py": SDK.replace("TOOLS", "[lookup, execute]")}) result = run(repo, base, head, "--scope", "app") - assert result["comparison_status"] == "compared" - assert result["base"]["limits"] == result["head"]["limits"] == [] + _assert_unread_caller_link(result, "VERSION") def test_directory_link_leaving_the_scope_with_python_is_a_gap(repo): @@ -164,9 +171,9 @@ def test_directory_link_leaving_the_scope_with_python_is_a_gap(repo): head = commit(repo, {"app/agent.py": SDK.replace("TOOLS", "[lookup, execute]")}) result = run(repo, base, head, "--scope", "app") assert result["comparison_status"] == "partial" - assert [(r["tool"], r["change"]) for r in result["rows"]] == [("execute", "added")] - assert [(g["source"], g["reason"]) for g in result["head"]["coverage_gaps"]] == [ - ("lib", "Linked directory holds Python outside the scope: lib") + _assert_unread_caller_link(result, "lib") + assert ("lib", "Linked directory holds Python outside the scope: lib") in [ + (g["source"], g["reason"]) for g in result["head"]["coverage_gaps"] ] @@ -201,9 +208,7 @@ def test_submodule_behind_a_link_out_of_scope_is_not_the_scopes(repo): base = commit(repo, {"app/agent.py": SDK.replace("TOOLS", "[lookup]")}) head = commit(repo, {"app/agent.py": SDK.replace("TOOLS", "[lookup, execute]")}) result = run(repo, base, head, "--scope", "app") - assert result["comparison_status"] == "compared" - assert [(r["tool"], r["change"]) for r in result["rows"]] == [("execute", "added")] - assert result["base"]["limits"] == result["head"]["limits"] == [] + _assert_unread_caller_link(result, "lib") @pytest.mark.parametrize("direction", ["submodule_to_directory", "directory_to_submodule"]) diff --git a/tests/test_application_diff_tool_reach.py b/tests/test_application_diff_tool_reach.py index 6fed90323..b237a172b 100644 --- a/tests/test_application_diff_tool_reach.py +++ b/tests/test_application_diff_tool_reach.py @@ -28,12 +28,18 @@ def lookup(query: str) -> str: ''' -def _added(repo, body: str, *, extra: dict[str, str] | None = None): +def _added(repo, body: str, *, extra: dict[str, str] | None = None, established: bool = True): base = commit(repo, {"agent.py": AGENT.replace("BODY", "").replace(", act", "")}) head = commit(repo, {"agent.py": AGENT.replace("BODY", body), **(extra or {})}) result = run(repo, base, head) [row] = [row for row in result["rows"] if row["tool"] == "act"] - assert row["change"] == "added" + if established: + assert row["change"] == "added" + else: + assert result["comparison_status"] == "partial" + assert row["change"] == "not_established" + assert row["candidate_change"] == "added" + assert any("constructor" in gap["reason"] for gap in result["head"]["coverage_gaps"]) return row["after"], result, (base, head) @@ -82,8 +88,11 @@ def test_the_graphql_operation_not_the_transport_decides_the_effect(repo, doc, o assert after["effect_evidence"]["conservative_effect"] == effect -def test_a_graphql_document_that_is_not_a_literal_is_named(repo): - after, _, _ = _added(repo, GRAPHQL.replace("DOC", "os.environ['DOC']")) +@pytest.mark.parametrize("document,established", [("os.environ['DOC']", False), ('os.environ.get("DOC")', True)]) +def test_a_graphql_document_that_is_not_a_literal_is_named(repo, document, established): + # Both carry the endpoint evidence; retaining an imported getter as a + # subscript receiver additionally withholds constructor ownership. + after, _, _ = _added(repo, GRAPHQL.replace("DOC", document), established=established) [call] = after["reach"]["calls"] assert call["graphql"] == "unknown" and call["effect"] is None assert any("GraphQL document is not a literal" in item["why"] for item in after["reach"]["limits"]) @@ -223,24 +232,30 @@ def act(number: int) -> dict: def _chain(hops: int) -> str: + return ('from support import hop1\n\n@function_tool\ndef act(number: int) -> dict:\n' + ' return hop1(f"https://api.example.com/issues/{number}")\n') + + +def _chain_source(hops: int) -> str: helpers = "\n".join( f"def hop{index}(url):\n return {'hop' + str(index + 1) + '(url)' if index < hops else 'requests.get(url).json()'}\n" for index in range(1, hops + 1) ) - return ( - f"\n{helpers}\n\n@function_tool\ndef act(number: int) -> dict:\n" - ' return hop1(f"https://api.example.com/issues/{number}")\n' - ) + return "import requests\n\n" + helpers + + +def _helper_line(hops: int, needle: str) -> str: + return f"support.py:{next(i for i, line in enumerate(_chain_source(hops).splitlines(), 1) if needle in line)}" def test_a_read_through_helpers_within_the_bound_is_established(repo): body = _chain(3) - after, _, _ = _added(repo, body) + after, _, _ = _added(repo, body, extra={"support.py": _chain_source(3)}) [call] = after["reach"]["calls"] assert call["via"] == [ f"{_line(body, 'return hop1(')} hop1", - f"{_line(body, 'return hop2(')} hop2", - f"{_line(body, 'return hop3(')} hop3", + f"{_helper_line(3, 'return hop2(')} hop2", + f"{_helper_line(3, 'return hop3(')} hop3", ] assert after["reach"]["effect_claims"] == [ {"effect": "read", "at": _line(body, "def act("), "calls": 1} @@ -250,11 +265,11 @@ def test_a_read_through_helpers_within_the_bound_is_established(repo): def test_a_helper_beyond_the_bound_is_a_named_limit_not_a_read(repo): body = _chain(4) - after, _, _ = _added(repo, body) + after, _, _ = _added(repo, body, extra={"support.py": _chain_source(4)}) assert after["reach"]["calls"] == [] assert after["reach"]["limits"] == [ { - "at": _line(body, "return hop4("), + "at": _helper_line(4, "return hop4("), "why": "calls hop4, more than 3 helper calls from the tool; not read", } ] @@ -279,7 +294,8 @@ def act(number: int) -> dict: assert after["reach"]["limits"][0]["why"] == "calls storage.remember, which is not read" -def test_a_helper_in_another_module_is_followed_and_located(repo): +@pytest.mark.parametrize("external_call", [False, True], ids=["saved-client-unread", "direct-external-call"]) +def test_a_helper_in_another_module_is_followed_and_located(repo, external_call): body = ''' from support import client @@ -298,10 +314,15 @@ def act(number: int) -> dict: def close_issue(number): return session.delete(f"{BASE}/issues/{number}", headers={"X-Api-Key": os.environ["API_KEY"]}) ''' + if external_call: + support = support.replace('os.environ["API_BASE"]', 'os.environ.get("API_BASE")') + support = support.replace('os.environ["API_KEY"]', 'os.environ.get("API_KEY")') + support = support.replace("return session.delete", "return requests.delete") after, _, (base, head) = _added( repo, body, extra={"support/__init__.py": "", "support/client.py": support}, + established=external_call, ) [call] = after["reach"]["calls"] assert call["method"] == "DELETE" and call["effect"] == "destructive" @@ -371,7 +392,7 @@ def test_two_same_named_adk_agents_are_located_at_their_own_constructions(repo): def test_reach_is_deterministic(repo): - after, _, (base, head) = _added(repo, _chain(2)) + after, _, (base, head) = _added(repo, _chain(2), extra={"support.py": _chain_source(2)}) again = run(repo, base, head) [row] = [row for row in again["rows"] if row["tool"] == "act"] assert row["after"]["reach"] == after["reach"] @@ -395,7 +416,8 @@ def test_construction_sites_are_in_line_order(repo): assert row["after"]["binding_location"] == "agent.py:9" -def test_a_credential_only_some_call_sites_send_says_so(repo): +@pytest.mark.parametrize("credential,established", [('os.environ["TOKEN"]', False), ('os.environ.get("TOKEN")', True)]) +def test_a_credential_only_some_call_sites_send_says_so(repo, credential, established): body = ''' @function_tool def act(number: int) -> dict: @@ -405,7 +427,8 @@ def act(number: int) -> dict: response = requests.get(url) return response.json() ''' - _, _, (base, head) = _added(repo, body) + body = body.replace('os.environ["TOKEN"]', credential) + _, _, (base, head) = _added(repo, body, established=established) text = _text(repo, base, head) assert "credential: env TOKEN → header Authorization (at some call sites)" in text diff --git a/tests/test_application_diff_unobserved.py b/tests/test_application_diff_unobserved.py index 0f857aefe..565698743 100644 --- a/tests/test_application_diff_unobserved.py +++ b/tests/test_application_diff_unobserved.py @@ -29,6 +29,17 @@ def _pairs(result): return [(r["agent"], r["tool"], r["change"]) for r in result["rows"]] +def _assert_constructor_namespace_limit(result): + assert result["comparison_status"] == "partial" + assert any("constructor identity is not established" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + assert result["rows"] and all(row["change"] == "not_established" for row in result["rows"]) + + +def _assert_added_candidate(result, agent, tool): + assert any(row["agent"] == agent and row["tool"] == tool + and row["candidate_change"] == "added" for row in result["rows"]) + + @pytest.mark.parametrize( ("body", "agent"), [ @@ -50,6 +61,12 @@ def test_every_agent_construction_is_observed(repo, body, agent): base = commit(repo, {"agent.py": _agents(body.replace("TOOLS", "[quote]"))}) head = commit(repo, {"agent.py": _agents(body.replace("TOOLS", "[quote, send_image]"))}) result = run(repo, base, head) + if agent == "Held": + assert result["comparison_status"] == "partial" + assert any(item["name"] == agent for item in result["head"]["agents"]) + assert _pairs(result) and all(row[2] == "not_established" for row in _pairs(result)) + assert any("constructor identity is not established" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + return assert result["comparison_status"] == "compared" assert _pairs(result) == [(agent, "send_image", "added")] @@ -98,11 +115,11 @@ def test_a_test_double_does_not_establish_the_application(repo): base = commit( repo, { - "bots/agents.py": _agents(real.replace("TOOLS", "[quote]")), + "bots/agents_def.py": _agents(real.replace("TOOLS", "[quote]")), "bots/tests/test_turn.py": double, }, ) - head = commit(repo, {"bots/agents.py": _agents(real.replace("TOOLS", "[quote, send_image]"))}) + head = commit(repo, {"bots/agents_def.py": _agents(real.replace("TOOLS", "[quote, send_image]"))}) result = run(repo, base, head, "--scope", ".") assert _pairs(result) == [("Wycena", "send_image", "added")] for side in ("base", "head"): @@ -374,7 +391,7 @@ def test_dataclasses_replace_with_tools_is_a_named_limit(repo): assert any("agent copy at agent.py" in limit for limit in result["head"]["limits"]) -def test_a_copy_without_its_own_capabilities_is_not_a_limit(repo): +def test_an_unused_literal_clone_preserves_the_original_membership_comparison(repo): """The SDK docs' `robot_agent = pirate_agent.clone(name=..., instructions=...)`.""" body = ( @@ -384,11 +401,14 @@ def test_a_copy_without_its_own_capabilities_is_not_a_limit(repo): result = _compare( repo, body.replace("TOOLS", "[quote]"), body.replace("TOOLS", "[quote, send_image]") ) + # #876 treats a name/instructions-only copy with an unused result as no + # explicit capability override. Its direct receiver still needs complete + # constructor/method/result ownership; other copy/escape tests pin refusal. assert result["comparison_status"] == "compared" assert _pairs(result) == [("pirate_agent", "send_image", "added")] -def test_an_unused_subclass_does_not_downgrade_other_agents(repo): +def test_unused_sdk_subclass_hooks_limit_constructor_identity(repo): body = ( "class LoggingAgent(Agent):\n pass\n" 'triage = Agent(name="triage", tools=TOOLS)\n' @@ -396,11 +416,12 @@ def test_an_unused_subclass_does_not_downgrade_other_agents(repo): result = _compare( repo, body.replace("TOOLS", "[quote]"), body.replace("TOOLS", "[quote, send_image]") ) - assert result["comparison_status"] == "compared" - assert _pairs(result) == [("triage", "send_image", "added")] + _assert_constructor_namespace_limit(result) + _assert_added_candidate(result, "triage", "send_image") + assert any("unread class construction hook" in gap["reason"] for gap in result["head"]["coverage_gaps"]) -def test_an_instantiated_subclass_limits_only_its_own_agent(repo): +def test_instantiated_sdk_subclass_hooks_also_limit_peer_constructor_identity(repo): body = ( "class LoggingAgent(Agent):\n pass\n" 'logged = LoggingAgent(name="logged", tools=[quote])\n' @@ -410,8 +431,9 @@ def test_an_instantiated_subclass_limits_only_its_own_agent(repo): repo, body.replace("TOOLS", "[quote]"), body.replace("TOOLS", "[quote, send_image]") ) assert result["comparison_status"] == "partial" - assert _pairs(result) == [("triage", "send_image", "added")] - assert {g["agent"] for g in result["head"]["coverage_gaps"]} == {"logged"} + _assert_constructor_namespace_limit(result) + _assert_added_candidate(result, "triage", "send_image") + assert {"logged", "triage"} <= {g["agent"] for g in result["head"]["coverage_gaps"]} def test_text_output_names_the_test_files_it_did_not_read(repo): @@ -641,12 +663,12 @@ def test_a_subclass_used_from_another_module_is_a_named_limit(repo): ], ids=["config-replace", "non-agent-clone", "same-named-local"], ) -def test_copies_and_changes_on_values_that_are_not_agents_are_not_limits(repo, noise): +def test_namespace_classes_and_reflection_keep_a_named_constructor_limit(repo, noise): before = 'triage = Agent(name="Triage", tools=[quote])\n' after = 'triage = Agent(name="Triage", tools=[quote, send_image])\n' + noise result = _compare(repo, before, after) - assert result["comparison_status"] == "compared" - assert _pairs(result) == [("triage", "send_image", "added")] + _assert_constructor_namespace_limit(result) + assert any(row["agent"] == "triage" and row["tool"] == "send_image" for row in result["rows"]) def test_two_builders_local_agent_variables_are_two_agents(repo): @@ -712,10 +734,13 @@ def test_identical_constructions_of_one_identity_are_one_agent(repo): ], ids=["handle-only-read", "getattr-read"], ) -def test_reading_another_objects_tools_is_not_a_change(repo, helper): +def test_nonreflective_tools_reads_are_bounded_and_reflection_stays_unread(repo, helper): before = 'main_agent = Agent(name="Main", tools=[quote])\n' + helper after = 'main_agent = Agent(name="Main", tools=[quote, send_image])\n' + helper result = _compare(repo, before, after) + if "getattr" in helper: + _assert_constructor_namespace_limit(result) + return assert result["comparison_status"] == "compared" assert _pairs(result) == [("main_agent", "send_image", "added")] @@ -907,7 +932,7 @@ def test_a_same_stem_vendor_class_is_not_the_scopes_subclass(repo): assert not any("agent subclass" in limit for limit in result["head"]["limits"]) -def test_two_class_bodies_with_the_same_attribute_are_two_agents(repo): +def test_class_body_agents_keep_distinct_candidate_identities(repo): body = ( 'class Billing:\n agent = Agent(name="Billing", tools=[quote])\n' 'class Support:\n agent = Agent(name="Support", tools=SUPPORT)\n' @@ -915,8 +940,9 @@ def test_two_class_bodies_with_the_same_attribute_are_two_agents(repo): result = _compare( repo, body.replace("SUPPORT", "[quote]"), body.replace("SUPPORT", "[quote, send_image]") ) - assert result["comparison_status"] == "compared" - assert _pairs(result) == [("Support", "send_image", "added")] + _assert_constructor_namespace_limit(result) + _assert_added_candidate(result, "Support", "send_image") + assert {item["name"] for item in result["head"]["agents"]} == {"Billing", "Support"} def test_a_list_changed_through_one_agent_limits_every_agent_sharing_it(repo): @@ -1047,18 +1073,19 @@ def _scan_decision(tmp_path, source: str) -> str: ) -def test_an_adk_subclass_nothing_uses_is_not_a_limit(repo, tmp_path): +def test_unused_adk_subclass_hooks_limit_constructor_identity(repo, tmp_path): # The unused class made the module `partial`, hid the root agent's true # row, and dropped `scan` to insufficient_evidence (#876 review). base = commit(repo, {"agent.py": _ADK + _UNUSED_HELPER.replace("TOOLS", "[lookup]")}) head = commit(repo, {"agent.py": _ADK + _UNUSED_HELPER.replace("TOOLS", "[lookup, search]")}) result = run(repo, base, head) - assert result["comparison_status"] == "compared", result["head"]["limits"] - assert _pairs(result) == [("root", "search", "added")] + _assert_constructor_namespace_limit(result) + _assert_added_candidate(result, "root", "search") + assert any("unread class construction hook" in gap["reason"] for gap in result["head"]["coverage_gaps"]) scan_dir = tmp_path / "scan" scan_dir.mkdir() source = _ADK + _UNUSED_HELPER.replace("TOOLS", "[lookup, search]") - assert _scan_decision(scan_dir, source) == "passed" + assert _scan_decision(scan_dir, source) == "insufficient_evidence" @pytest.mark.parametrize( @@ -1097,15 +1124,15 @@ def test_an_adk_subclass_the_module_uses_is_a_limit(repo, use, named): ] -def test_an_unused_chain_of_adk_subclasses_is_not_a_limit(repo): +def test_unused_adk_subclass_chains_keep_a_named_constructor_limit(repo): chain = "class Deeper(Helper):\n pass\n" base = commit(repo, {"agent.py": _ADK + _UNUSED_HELPER.replace("TOOLS", "[lookup]") + chain}) head = commit( repo, {"agent.py": _ADK + _UNUSED_HELPER.replace("TOOLS", "[lookup, search]") + chain} ) result = run(repo, base, head) - assert result["comparison_status"] == "compared", result["head"]["limits"] - assert _pairs(result) == [("root", "search", "added")] + _assert_constructor_namespace_limit(result) + _assert_added_candidate(result, "root", "search") @pytest.mark.parametrize( @@ -1137,7 +1164,7 @@ def test_an_adk_subclass_another_module_uses_is_a_limit_there(repo, wiring, name for g in result["head"]["coverage_gaps"] ), result["head"]["coverage_gaps"] # The defining module builds no instance of either class itself. - assert not any(g["source"] == "agent.py" for g in result["head"]["coverage_gaps"]) + assert any(g["source"] == "agent.py" and "unread class construction hook" in g["reason"] for g in result["head"]["coverage_gaps"]) _TWICE = ( diff --git a/tests/test_application_scope.py b/tests/test_application_scope.py index b8dcd8269..71e1c8e0b 100644 --- a/tests/test_application_scope.py +++ b/tests/test_application_scope.py @@ -53,7 +53,13 @@ def test_a_tool_module_change_is_compared_in_the_package_that_holds_its_agent(re assert selection["scopes"] == ["backend/app"] assert result["head"]["scope"] == "backend/app" assert "backend/app/services/gemini_tools.py" in selection["reason"] - assert _rows(result) == [("support", "lookup", "changed")] + # Scope and candidate identity survive; the runtime Runner receives an + # agent handle whose constructor retention is not yet read. + assert _rows(result) == [("support", "lookup", "not_established")] + assert result["rows"][0]["candidate_change"] == "changed" + assert result["comparison_status"] == "partial" + assert any("constructor identity is not established" in gap["reason"] + and gap["source"] == "agents/support.py" for gap in result["head"]["coverage_gaps"]) def test_the_scope_is_reported_in_the_text_output(repo): diff --git a/tests/test_base_cache_engine_identity.py b/tests/test_base_cache_engine_identity.py index 160f20098..8823ab3ae 100644 --- a/tests/test_base_cache_engine_identity.py +++ b/tests/test_base_cache_engine_identity.py @@ -210,7 +210,7 @@ def test_directory_at_cache_report_is_preserved_and_routes_to_repair(tmp_path, m verifier, report, _ = _run_verify(repo) assert len(scans) == 2 assert verifier.base_status == 'scan_failed' - assert report.release_decision.decision == 'review_required' + assert report.release_decision.decision == 'insufficient_evidence' assert not verifier.control.permissions.merge assert not verifier.control.permissions.report_complete assert 'Could not store the regenerated base report' in verifier.model_dump_json() diff --git a/tests/test_builder_bindings.py b/tests/test_builder_bindings.py index 389823d89..efd1cfdb3 100644 --- a/tests/test_builder_bindings.py +++ b/tests/test_builder_bindings.py @@ -6,6 +6,97 @@ from tests.test_imported_tool_bindings import _adk, _sdk, _write +#: Parametrizations that expect a clean read of a write the ownership proof does +#: not yet attribute to an unrelated target (an unrelated attribute, another +#: module, a fresh or foreign dictionary). The proof refuses them, fail-closed; +#: the precision is tracked in #1005. Each is a strict xfail so that closing the +#: gap is noticed, and no refusing parametrization ("retains", "cannot hide", +#: "cannot prove") is listed. +_PRECISION_GAPS: dict[str, frozenset[str]] = { + "test_an_explicit_mutation_receiver_keeps_ownership_when_later_arguments_are_unpacked": frozenset({ + 'dict_update-False-sdk', 'operator_ior-False-sdk', 'setter-False-adk', 'setter-False-sdk', + }), + "test_builtin_stability_follows_the_owner_of_dictionary_writes": frozenset({ + 'dynamic_key-foreign-dict-adk', 'dynamic_key-foreign-dict-sdk', 'dynamic_key-foreign-vars-adk', + 'dynamic_key-foreign-vars-sdk', 'dynamic_key-fresh-dict-adk', 'dynamic_key-fresh-dict-sdk', + 'dynamic_key-fresh-vars-adk', 'dynamic_key-fresh-vars-sdk', 'ior-foreign-dict-adk', + 'ior-foreign-dict-sdk', 'ior-foreign-vars-adk', 'ior-foreign-vars-sdk', 'ior-fresh-dict-adk', + 'ior-fresh-dict-sdk', 'ior-fresh-vars-adk', 'ior-fresh-vars-sdk', 'method_ior-foreign-dict-adk', + 'method_ior-foreign-dict-sdk', 'method_ior-foreign-vars-adk', 'method_ior-foreign-vars-sdk', + 'method_ior-fresh-dict-adk', 'method_ior-fresh-dict-sdk', 'method_ior-fresh-vars-adk', + 'method_ior-fresh-vars-sdk', 'operator_ior-foreign-dict-adk', 'operator_ior-foreign-dict-sdk', + 'operator_ior-foreign-vars-adk', 'operator_ior-foreign-vars-sdk', 'operator_ior-fresh-dict-adk', + 'operator_ior-fresh-dict-sdk', 'operator_ior-fresh-vars-adk', 'operator_ior-fresh-vars-sdk', + 'pop_setdefault-foreign-dict-adk', 'pop_setdefault-foreign-dict-sdk', + 'pop_setdefault-foreign-vars-adk', 'pop_setdefault-foreign-vars-sdk', + 'pop_setdefault-fresh-dict-adk', 'pop_setdefault-fresh-dict-sdk', + 'pop_setdefault-fresh-vars-adk', 'pop_setdefault-fresh-vars-sdk', + 'saved_mapping-foreign-dict-adk', 'saved_mapping-foreign-dict-sdk', + 'saved_mapping-foreign-vars-adk', 'saved_mapping-foreign-vars-sdk', + 'saved_mapping-fresh-dict-adk', 'saved_mapping-fresh-dict-sdk', 'saved_mapping-fresh-vars-adk', + 'saved_mapping-fresh-vars-sdk', + }), + "test_dictionary_initialization_retains_its_namespace_write_owner": frozenset({ + 'unrelated-method-framework-adk', 'unrelated-method-framework-sdk', + 'unrelated-qualified-framework-adk', 'unrelated-qualified-framework-sdk', + 'unrelated-unbound-framework-adk', 'unrelated-unbound-framework-sdk', + }), + "test_exported_namespace_identity_cannot_be_proved_by_its_import_path": frozenset({ + 'unrelated-attribute-adk', 'unrelated-attribute-sdk', 'unrelated-export-adk', + 'unrelated-export-sdk', + }), + "test_fresh_dictionary_key_writes_do_not_replace_namespace_slots": frozenset({ + 'ior-copy-adk', 'ior-copy-sdk', 'store-copy-adk', 'store-copy-sdk', 'update-copy-adk', + 'update-copy-sdk', + }), + "test_imported_producer_symbol_cannot_prove_result_namespace_ownership": frozenset({ + 'unrelated-container-canonical-adk', 'unrelated-container-canonical-sdk', + 'unrelated-container-foreign-sdk', 'unrelated-inline-canonical-adk', + 'unrelated-inline-canonical-sdk', 'unrelated-inline-foreign-sdk', + 'unrelated-saved-canonical-adk', 'unrelated-saved-canonical-sdk', 'unrelated-saved-foreign-sdk', + }), + "test_inline_namespace_holder_store_cannot_leave_the_constructor_read_only": frozenset({ + 'attribute-False-sdk', 'dict-False-sdk', 'dynamic_field-False-adk', 'dynamic_field-False-sdk', + 'setattr-False-adk', 'setattr-False-sdk', 'unrelated-False-sdk', 'unrelated-True-adk', + 'unrelated-True-sdk', 'vars-False-adk', 'vars-False-sdk', + }), + "test_object_setter_retains_the_actual_module_namespace": frozenset({ + 'bare-False-sdk', 'qualified-False-adk', 'qualified-False-sdk', 'saved_alias-False-sdk', + }), + "test_read_only_constructor_import_is_resolved_where_a_header_is_evaluated": frozenset({ + 'class_base-True-adk', 'class_base-True-sdk', 'decorator-True-adk', 'decorator-True-sdk', + }), + "test_replaced_framework_constructor_is_not_a_read_only_list_borrower": frozenset({ + 'unrelated-False-adk', 'unrelated-False-sdk', 'unrelated-True-adk', 'unrelated-True-sdk', + }), + "test_retained_namespace_cannot_hide_constructor_replacement": frozenset({ + 'dict-False-sdk', 'list-False-sdk', 'tuple-False-sdk', + }), + "test_saved_namespace_alias_retains_constructor_patch_ownership": frozenset({ + 'unrelated-False-sdk', 'unrelated-True-adk', 'unrelated-True-sdk', + }), + "test_saved_namespace_dictionary_retains_its_write_owner": frozenset({ + 'unrelated-attribute-True-adk', 'unrelated-attribute-True-sdk', 'unrelated-getattr-True-adk', + 'unrelated-getattr-True-sdk', 'unrelated-vars-True-adk', 'unrelated-vars-True-sdk', + }), + "test_unread_namespace_producer_cannot_prove_a_constructor_unchanged": frozenset({ + 'unrelated-adk', 'unrelated-sdk', + }), + "test_unrelated_primitive_field_names_do_not_poison_builtin_copy_proof": frozenset({ + 'foreign_attribute-adk', 'foreign_attribute-sdk', 'fresh_store-adk', 'fresh_store-sdk', + 'fresh_update-adk', 'fresh_update-sdk', + }), +} + + +@pytest.fixture(autouse=True) +def _known_precision_gaps(request): + gaps = _PRECISION_GAPS.get(getattr(request.node, "originalname", None) or "", frozenset()) + callspec = getattr(request.node, "callspec", None) + if callspec is not None and callspec.id in gaps: + request.applymarker(pytest.mark.xfail(strict=True, reason="precision gap, see #1005")) + + def _read(root, framework): if framework == "sdk": @@ -126,6 +217,28 @@ def test_read_only_constructor_import_is_resolved_where_a_header_is_evaluated( assert all(not item.tools_complete and item.issues for item in built) +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("keyword_only", [False, True]) +@pytest.mark.parametrize("use", ["unused", "called", "metadata", "opaque_consumer", "reflection", "mutating_parameter"]) +def test_constructor_default_retains_function_and_parameter_use_checks(tmp_path, framework, keyword_only, use): + declaration = "*, value" if keyword_only else "value" + body = " value.tools.append(write)\n" if use == "mutating_parameter" else " return None\n" + suffix = { + "unused": "", + "called": "unused()\n", + "metadata": "saved = unused.__kwdefaults__\n" if keyword_only else "saved = unused.__defaults__\n", + "opaque_consumer": "consumer(unused)\n", + "reflection": "globals()\n", + "mutating_parameter": "unused()\n", + }[use] + built, warnings = _namespace_mutation_observations( + tmp_path, framework, "from tools import write\n" + + f"def unused({declaration}=framework.Agent(name='Default', tools=SHARED)):\n" + + body + suffix, + ) + _assert_namespace_mutation_result(built, warnings, use not in {"unused", "called"}) + + @pytest.mark.parametrize("framework", ["sdk", "adk"]) @pytest.mark.parametrize("aliased", [False, True]) @pytest.mark.parametrize("patch", ["clean", "attribute", "setattr", "unrelated"]) @@ -534,6 +647,39 @@ def test_fresh_dictionary_key_writes_do_not_replace_namespace_slots( _assert_namespace_mutation_result(built, warnings, False) +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("use,extra", [ + ("", None), + ("saved = slots['Agent']\n", None), + ("slots['Agent'](name='Used', tools=SHARED)\n", None), + ("def capture():\n return slots\n", None), + ("__all__ = ['slots']\n", None), + ("", {"consumer.py": "from helper import slots\n"}), + ("framework.Agent.__init__ = fake.Agent\n", None), + ("framework.Agent = fake.Agent\n", None), +]) +def test_literal_constructor_dictionary_grants_only_confined_data(tmp_path, framework, use, extra): + built, warnings = _namespace_mutation_observations( + tmp_path, framework, "slots = {'Agent': framework.Agent}\nslots['Agent'] = fake.Agent\n" + use, extra, + ) + _assert_namespace_mutation_result(built, warnings, bool(use or extra)) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("use", ["", "fake.Agent(name='Used', tools=SHARED)\n", "saved = fake.Agent\n"]) +def test_literal_function_dictionary_keeps_the_original_callable_census(tmp_path, framework, use): + package = "agents" if framework == "sdk" else "google.adk.agents" + # Both fixtures carry an actual constructor namespace. Plain ADK tools + # do not acquire one merely by being imported from tools.py. + extra = {"fake.py": f"import {package} as canonical\nfrom tools import write\n" + "def Agent(name, tools):\n tools.append(write)\n"} + built, warnings = _namespace_mutation_observations( + tmp_path, framework, "slots = {'Agent': fake.Agent}\nslots['Agent'] = fake.Agent\n" + use, + extra, + ) + _assert_namespace_mutation_result(built, warnings, bool(use)) + + @pytest.mark.parametrize("framework", ["sdk", "adk"]) @pytest.mark.parametrize("spelling", ["export", "attribute"]) @pytest.mark.parametrize("field", ["Agent", "unrelated"]) @@ -586,6 +732,68 @@ def test_fresh_dictionary_provenance_stops_at_its_proof_bound(tmp_path, framewor _assert_namespace_mutation_result(built, warnings, exceeds_bound) +@pytest.mark.parametrize("use,extra", [ + ("slots['Agent'](name='Used', tools=SHARED)\n", None), + ("saved = slots['Agent']\n", None), + ("consumer(slots)\n", None), + ("def capture():\n return slots\n", None), + ("", {"consumer.py": "from helper import slots\n"}), + ("fake.Agent(name='Used', tools=SHARED)\n", None), + ("fake.Agent.__globals__['changed'] = framework\n", None), +]) +def test_fresh_dictionary_function_sink_keeps_other_namespace_uses_partial(tmp_path, use, extra): + built, warnings = _namespace_mutation_observations( + tmp_path, "sdk", "slots = {}\nslots['Agent'] = fake.Agent\n" + use, extra, + ) + _assert_namespace_mutation_result(built, warnings, True) + + +@pytest.mark.parametrize("write", ["slots.update(Agent=fake.Agent)", "slots.update({'Agent': fake.Agent})", + "slots |= {'Agent': fake.Agent}"]) +@pytest.mark.parametrize("use", ["", "saved = slots['Agent']\n", "fake.Agent(name='Used', tools=SHARED)\n"]) +def test_fresh_dictionary_update_sink_keeps_function_use_ownership(tmp_path, write, use): + built, warnings = _namespace_mutation_observations( + tmp_path, "sdk", "original = {}\nslots = original\n" + write + "\n" + use, + ) + _assert_namespace_mutation_result(built, warnings, bool(use)) + + +@pytest.mark.parametrize("write", [ + "saved = slots.update(Agent=fake.Agent)", + "saved = slots.update\nsaved(Agent=fake.Agent)", + "slots.update(**{'Agent': fake.Agent})", + "slots.update(*[{'Agent': fake.Agent}])", + "slots.update({key(): fake.Agent})", + "slots |= {key(): fake.Agent}", + "slots.update(Agent=fake)", + "slots.update(Agent=fake.Replacement)", + "slots |= {'Agent': fake.Replacement}", + "slots.update(Agent=lambda: fake.Agent)", +]) +def test_fresh_dictionary_update_sink_does_not_grant_protocol_or_nonfunction_values(tmp_path, write): + built, warnings = _namespace_mutation_observations( + tmp_path, "sdk", "slots = {}\n" + write + "\n", + {"fake.py": "from tools import write\ndef Agent(name, tools):\n tools.append(write)\nclass Replacement:\n pass\n"}, + ) + _assert_namespace_mutation_result(built, warnings, True) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("use,extra", [ + ("", None), + ("consumer(other)\n", None), + ("saved = other.Agent\n", None), + ("def capture():\n return other\n", None), + ("del other\n", None), + ("", {"consumer.py": "from helper import other\n"}), +]) +def test_unused_namespace_copy_has_no_read_capture_or_export_authority(tmp_path, framework, use, extra): + built, warnings = _namespace_mutation_observations( + tmp_path, framework, "other = framework\n" + use, extra, + ) + _assert_namespace_mutation_result(built, warnings, bool(use or extra)) + + @pytest.mark.parametrize("framework", ["sdk", "adk"]) @pytest.mark.parametrize("primitive", ["vars", "dict"]) @pytest.mark.parametrize("owner", ["builtin", "foreign", "fresh"]) @@ -689,7 +897,10 @@ def test_saved_bare_builtin_alias_keeps_its_lexical_terminal( built, warnings = _namespace_mutation_observations( tmp_path, framework, f"replace = {primitive}\n" + mutations[primitive] ) - _assert_namespace_mutation_result(built, warnings, same_namespace) + # Deletion has no same-function source-slot proof: destroying a function + # may release metadata or callbacks. A foreign receiver alone does not + # establish this operation; setters and mapping writes keep their roles. + _assert_namespace_mutation_result(built, warnings, same_namespace or primitive == "delattr") @pytest.mark.parametrize("framework", ["sdk", "adk"]) @@ -778,10 +989,13 @@ def test_operator_receiver_proof_reads_import_roots_above_the_caller_scope( tmp_path / "app", framework, "import operator\noperator.ior(fake.__dict__, {'Agent': fake.Agent})\n", ) - # The exact named lookup must establish absence on this filesystem. Both - # case-sensitive and case-insensitive filesystems exercise the same test. - provider_reachable = (tmp_path / "src" / "operator.py").exists() - _assert_namespace_mutation_result(built, warnings, import_root == "linked" or provider_reachable) + # Absence is decided from the directory listing with case folded, so a + # differently cased root or provider refuses on every filesystem, as it + # would be found on a case-insensitive one; the answer never depends on + # the host (see test_builder_calls' `case` providers). + _assert_namespace_mutation_result( + built, warnings, import_root in {"linked", "local_provider", "case_root", "case_provider"}, + ) @pytest.mark.parametrize("framework", ["sdk", "adk"]) @@ -2263,7 +2477,9 @@ def test_filter_predicate_parameters_do_not_create_builder_dependencies(tmp_path ) _write(tmp_path, files) observations, warnings = _read(tmp_path, framework) - assert warnings == [] and observations[0].tools_complete + assert any("anonymous callable or generator" in warning for warning in warnings) + assert not observations[0].tools_complete and not observations[0].handoffs_complete + assert not any("builder dependenc" in warning or "missing argument" in warning for warning in warnings) assert observations[0].tool_names == ["read"] assert observations[0].tool_conditions diff --git a/tests/test_builder_calls.py b/tests/test_builder_calls.py index 0d5c6296a..81e02a60e 100644 --- a/tests/test_builder_calls.py +++ b/tests/test_builder_calls.py @@ -175,6 +175,211 @@ def test_each_caller_retains_its_paired_arguments(tmp_path): assert contexts[0].site.module.tree is caller.tree +@pytest.mark.parametrize("refined_first", [False, True]) +@pytest.mark.parametrize("write", ["slots['Agent'] = Agent", "slots.update(Agent=Agent)", + "slots.update({'Agent': Agent})", "slots |= {'Agent': Agent}"]) +def test_confined_dictionary_data_does_not_change_the_ordinary_caller_census(tmp_path, refined_first, write): + resolver = ImportResolver(tmp_path) + module = _module(tmp_path, "builders.py", "def Agent(name, tools):\n return None\n", resolver) + _module(tmp_path, "app.py", "from builders import Agent\noriginal = {}\nslots = original\n" + write + "\ndel slots['Agent']\n", resolver) + calls = BuilderCalls(resolver) + answers = {} + for refined in [refined_first, not refined_first, refined_first]: + answers[refined] = calls.callers(module, module.tree.body[0], allow_empty=True, + confined_dictionary_data=refined) + assert not answers[True].sites and not answers[True].limits + assert not answers[False].sites + assert any("used as a value" in limit for limit in answers[False].limits) + assert any("named in a string" in limit for limit in answers[False].limits) + + +@pytest.mark.parametrize("suffix,extra", [ + ("slots['Agent']('Used', [])\n", None), + ("saved = slots['Agent']\n", None), + ("slots.get('Agent')\n", None), + ("consumer(slots)\n", None), + ("slots.values()\n", None), + ("slots.copy()\n", None), + ("def capture():\n return slots\n", None), + ("__all__ = ['slots']\n", None), + ("", "from app import slots as saved\n"), + ("", "import app as saved\n"), + ("slots = {}\n", None), + ("saved = slots.update(Agent=Agent)\n", None), + ("changes = {'Agent': Agent}\nslots |= changes\n", None), + ("saved = slots.update\nsaved(Agent=Agent)\n", None), + ("slots.update(**{'Agent': Agent})\n", None), + ("slots.update(*[{'Agent': Agent}])\n", None), + ("slots.update({key(): Agent})\n", None), + ("slots |= {key(): Agent}\n", None), + ("slots.update(Agent=produce())\n", None), + ("slots |= {'Agent': produce()}\n", None), + ("slots.__ior__({'Agent': Agent})\n", None), +]) +def test_dictionary_data_edges_require_complete_confinement(tmp_path, suffix, extra): + resolver = ImportResolver(tmp_path) + module = _module(tmp_path, "builders.py", "def Agent(name, tools):\n return None\n", resolver) + _module(tmp_path, "app.py", "from builders import Agent\noriginal = {}\nslots = original\nslots['Agent'] = Agent\n" + suffix, resolver) + if extra is not None: + _module(tmp_path, "consumer.py", extra, resolver) + census = BuilderCalls(resolver).callers(module, module.tree.body[0], allow_empty=True, + confined_dictionary_data=True) + assert census.limits + + +@pytest.mark.parametrize("refined_first", [False, True]) +def test_literal_dictionary_initializer_keeps_each_function_data_edge(tmp_path, refined_first): + resolver = ImportResolver(tmp_path) + module = _module(tmp_path, "builders.py", "def Agent(name, tools):\n return None\n", resolver) + _module(tmp_path, "app.py", "from builders import Agent\nslots = {'Agent': Agent}\nslots['Agent'] = Agent\ndel slots['Agent']\n", resolver) + calls = BuilderCalls(resolver) + answers = {} + for refined in [refined_first, not refined_first, refined_first]: + answers[refined] = calls.callers(module, module.tree.body[0], allow_empty=True, + confined_dictionary_data=refined) + assert not answers[True].sites and not answers[True].limits + assert not answers[False].sites + assert any("used as a value" in limit for limit in answers[False].limits) + assert any("named in a string" in limit for limit in answers[False].limits) + + +@pytest.mark.parametrize("initializer,suffix", [ + ("{'Agent': Agent}", "saved = slots['Agent']\n"), + ("{'Agent': Agent}", "slots['Agent']('Used', [])\n"), + ("{'Agent': Agent}", "def capture():\n return slots\n"), + ("{'Agent': Agent}", "__all__ = ['slots']\n"), + ("{key(): Agent}", ""), + ("{**opaque, 'Agent': Agent}", ""), + ("{'Agent': produce(), 'stored': Agent}", ""), +]) +def test_literal_dictionary_initializer_requires_complete_data_confinement(tmp_path, initializer, suffix): + resolver = ImportResolver(tmp_path) + module = _module(tmp_path, "builders.py", "def Agent(name, tools):\n return None\n", resolver) + _module(tmp_path, "app.py", "from builders import Agent\nslots = " + initializer + "\n" + suffix, resolver) + census = BuilderCalls(resolver).callers(module, module.tree.body[0], allow_empty=True, + confined_dictionary_data=True) + assert census.limits + + +@pytest.mark.parametrize("suffix,has_site", [ + ("Agent('Used', [])\n", True), + ("saved = Agent\n", False), + ("print('Agent')\n", False), + ("saved = Agent.__globals__\n", False), + ("globals()\n", False), +]) +def test_confined_dictionary_data_preserves_other_function_edges(tmp_path, suffix, has_site): + resolver = ImportResolver(tmp_path) + module = _module(tmp_path, "builders.py", "def Agent(name, tools):\n return None\n", resolver) + _module(tmp_path, "app.py", "from builders import Agent\nslots = {}\nslots['Agent'] = Agent\n" + suffix, resolver) + census = BuilderCalls(resolver).callers(module, module.tree.body[0], allow_empty=True, + confined_dictionary_data=True) + if has_site: + assert len(census.sites) == 1 + else: + assert census.limits + + +@pytest.mark.parametrize("failure", [None, "candidate_bound", "unread_candidate"]) +def test_dictionary_sink_census_retains_its_read_limit(tmp_path, failure): + from agents_shipgate.inputs.list_expressions import ListExpressions + from agents_shipgate.inputs.python_imports import ScopeIndex, _external_constructor_use + + resolver = ImportResolver(tmp_path) + defining = _module(tmp_path, "builders.py", "from agents import Agent as Canonical\ndef Replacement(name, tools):\n return None\n", resolver) + module = _module(tmp_path, "helper.py", "import builders\nslots = {}\nslots['Replacement'] = builders.Replacement\n", resolver) + if failure == "candidate_bound": + # Exercise the shipped 32-candidate bound, rather than a changed test engine. + for index in range(33): + (tmp_path / f"candidate_{index}.py").write_text("# helper\n") + elif failure == "unread_candidate": + (tmp_path / "unread.py").write_bytes(b"# helper\n\xff") + scopes = ScopeIndex(module.tree) + assert _external_constructor_use(resolver, module, "agents", {defining.path}) is None + assert resolver._constructor_dictionary_sinks + calls = BuilderCalls(resolver) + lists = ListExpressions(ref=module.ref, tree=module.tree, scopes=scopes, + bindings=module.bindings, module=module, resolver=resolver, + agent_reads=lambda call, keyword: False, builder_calls=calls) + assert lists.constructor_namespace_changed() is (failure is not None) + if failure == "candidate_bound": + assert "exceeds 32 candidate modules" in lists.constructor_namespace_issue + elif failure == "unread_candidate": + assert "unread.py could not be read" in lists.constructor_namespace_issue + else: + assert lists.constructor_namespace_issue is None + + +@pytest.mark.parametrize("refined_first", [False, True]) +def test_unused_namespace_data_preserves_ordinary_carrier_limits(tmp_path, refined_first): + resolver = ImportResolver(tmp_path) + module = _module(tmp_path, "builders.py", "def Agent():\n return None\n", resolver) + _module(tmp_path, "app.py", "import builders\nunused = builders\n", resolver) + calls = BuilderCalls(resolver) + answers = {} + for refined in [refined_first, not refined_first, refined_first]: + answers[refined] = calls.callers(module, module.tree.body[0], allow_empty=True, + unused_namespace_data=refined) + assert not answers[True].sites and not answers[True].limits + assert not answers[False].sites and answers[False].limits + assert any("module is used" in limit for limit in answers[False].limits) + assert any("retained namespace is used" in limit for limit in answers[False].limits) + + +@pytest.mark.parametrize("suffix,extra", [ + ("consumer(unused)\n", None), + ("saved = unused.Agent\n", None), + ("alias = unused\n", None), + ("def capture():\n return unused\n", None), + ("__all__ = ['unused']\n", None), + ("unused = {}\n", None), + ("del unused\n", None), + ("globals()['unused']\n", None), + ("", "from app import unused\n"), + ("", "import app\n"), +]) +def test_unused_namespace_data_requires_a_closed_destination(tmp_path, suffix, extra): + resolver = ImportResolver(tmp_path) + module = _module(tmp_path, "builders.py", "def Agent():\n return None\n", resolver) + _module(tmp_path, "app.py", "import builders\nunused = builders\n" + suffix, resolver) + if extra is not None: + _module(tmp_path, "consumer.py", extra, resolver) + census = BuilderCalls(resolver).callers(module, module.tree.body[0], allow_empty=True, + unused_namespace_data=True) + assert census.limits + + +@pytest.mark.parametrize("source", [ + "from builders import Agent\nunused = Agent\n", + "import builders\nunused = builders.Agent\n", +]) +def test_unused_namespace_data_is_not_a_symbol_or_attribute_proof(tmp_path, source): + resolver = ImportResolver(tmp_path) + module = _module(tmp_path, "builders.py", "def Agent():\n return None\n", resolver) + _module(tmp_path, "app.py", source, resolver) + census = BuilderCalls(resolver).callers(module, module.tree.body[0], allow_empty=True, + unused_namespace_data=True) + assert census.limits + + +@pytest.mark.parametrize("suffix,has_site", [ + ("builders.Agent()\n", True), + ("print('Agent')\n", False), + ("saved = builders.Agent\n", False), + ("saved = builders.Agent.__globals__\n", False), +]) +def test_unused_namespace_data_preserves_other_function_uses(tmp_path, suffix, has_site): + resolver = ImportResolver(tmp_path) + module = _module(tmp_path, "builders.py", "def Agent():\n return None\n", resolver) + _module(tmp_path, "app.py", "import builders\nunused = builders\n" + suffix, resolver) + census = BuilderCalls(resolver).callers(module, module.tree.body[0], allow_empty=True, + unused_namespace_data=True) + if has_site: + assert len(census.sites) == 1 + else: + assert census.limits + + def test_unrelated_text_matches_do_not_expand_the_caller_census(tmp_path, monkeypatch): from agents_shipgate.inputs import builder_calls @@ -431,6 +636,43 @@ def test_census_binds_nonmatching_files_and_exclusion_marker_absence(tmp_path): reset_static_input_snapshot(token) +def test_namespace_directory_absence_is_read_under_any_letter_case(namespace_workspace, monkeypatch): + """A directory missing by exact name beside a differently cased one is not proven absent.""" + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace) + (namespace_workspace / "Vendor").mkdir() + real_exists = Path.exists + + def calls_with_empty_namespace_state(): + calls = BuilderCalls(resolver) + calls._namespace_entry_work = 0 + calls._namespace_directories = {} + calls._namespace_directory_names = {} + return calls + + # What a case-sensitive host answers for the spelling asked about. + monkeypatch.setattr(Path, "exists", lambda self, **kw: False if self.name == "vendor" else real_exists(self, **kw)) + with pytest.raises(CallLimit, match="unread directory evidence"): + calls_with_empty_namespace_state()._namespace_directory_entries(namespace_workspace / "vendor") + # No listing entry matches this spelling under any case: a proven absence. + monkeypatch.setattr(Path, "exists", lambda self, **kw: False if self.name == "other" else real_exists(self, **kw)) + assert calls_with_empty_namespace_state()._namespace_directory_entries(namespace_workspace / "other") is None + + +def test_a_differently_cased_virtualenv_marker_does_not_hide_a_directory_on_any_host(tmp_path): + """Presence of ``pyvenv.cfg`` selects the code read, so it comes from the listing.""" + resolver = ImportResolver(tmp_path) + _module(tmp_path, "builders.py", "def build(tools):\n return None\n", resolver) + (tmp_path / "environment").mkdir() + (tmp_path / "environment/caller.py").write_text("from builders import build\nbuild([])\n") + (tmp_path / "environment/PyVenv.cfg").write_text("home = elsewhere\n") + (tmp_path / "real").mkdir() + (tmp_path / "real/caller.py").write_text("from builders import build\nbuild([])\n") + (tmp_path / "real/pyvenv.cfg").write_text("home = elsewhere\n") + inventory = {path.relative_to(tmp_path).as_posix() for path in BuilderCalls(resolver)._inventory()} + assert "environment/caller.py" in inventory # Not a virtual environment: it is read. + assert "real/caller.py" not in inventory # The exact spelling is one: it is skipped. + + @pytest.mark.parametrize( "returned", ["return [read]", "def nested():\n return None\n return {'tools': [nested]}"], @@ -558,3 +800,6374 @@ def test_coroutine_or_generator_builders_are_named_limits(tmp_path, kind): for context in contexts for limit in context.limits ) + + +@pytest.mark.parametrize('namespace_first', [False, True]) +def test_function_namespace_census_does_not_replace_shared_list_census(tmp_path, namespace_first): + resolver = ImportResolver(tmp_path) + module = _module(tmp_path, 'tools.py', 'def read():\n return None\n', resolver) + _module(tmp_path, 'registry.py', 'from tools import read\ndef anchor():\n return None\n', resolver) + _module(tmp_path, 'bridge.py', 'from registry import anchor\ndef carrier():\n return None\n', resolver) + _module(tmp_path, 'sibling.py', 'from bridge import carrier\nconsume(carrier)\n', resolver) + calls = BuilderCalls(resolver) + results = {} + for namespace in [namespace_first, not namespace_first]: + results[namespace] = calls.retaining_modules(module, 'read', namespace_carriers=namespace) + assert tmp_path / 'sibling.py' in results[True] + assert tmp_path / 'sibling.py' not in results[False] + assert calls.retaining_modules(module, 'read') == results[False] + + +@pytest.fixture +def namespace_workspace(tmp_path): + from tests.test_constructor_dependency_ownership import _git + + _git(tmp_path, "init", "-q") + return tmp_path + + +@pytest.mark.parametrize("family,package", [("agents", "agents"), ("agents", "openai_agents"), + ("google.adk", "google.adk.agents")]) +def test_namespace_source_context_reads_quiet_and_imported_excluded_code(namespace_workspace, family, package): + (namespace_workspace / "entry.py").write_text(f"import {package} as framework\nfrom tests import hooks\n") + (namespace_workspace / "quiet.py").write_text("VALUE = None\n") + (namespace_workspace / "tests").mkdir() + (namespace_workspace / "tests/__init__.py").write_text("VALUE = None\n") + (namespace_workspace / "tests/hooks.py").write_text("VALUE = None\n") + calls = BuilderCalls(ImportResolver(namespace_workspace)) + context = calls.namespace_source_context(family) + assert {module.ref for module in context.modules} == { + "entry.py", "quiet.py", "tests/__init__.py", "tests/hooks.py", + } + assert context.external_imports == (f"entry.py:1: {package}",) + assert calls._read_bytes == sum(len(module.text.encode("utf-8")) for module in context.modules) + assert not calls._censuses + + +@pytest.mark.parametrize("expanded", [False, True]) +@pytest.mark.parametrize("count", [32, 33]) +def test_namespace_source_context_bounds_the_complete_graph(namespace_workspace, expanded, count): + if expanded: + (namespace_workspace / "tests").mkdir() + (namespace_workspace / "tests/__init__.py").write_text("") + (namespace_workspace / "entry.py").write_text("from tests import chain\n") + for index in range(count - 2): + name = "chain" if index == 0 else f"step{index}" + tail = f"from . import step{index + 1}\n" if index < count - 3 else "VALUE = None\n" + (namespace_workspace / "tests" / f"{name}.py").write_text(tail) + else: + for index in range(count): + (namespace_workspace / f"quiet{index}.py").write_text("VALUE = None\n") + calls = BuilderCalls(ImportResolver(namespace_workspace)) + if count == 33: + with pytest.raises(CallLimit, match="exceeds 32 total modules"): + calls.namespace_source_context("agents") + else: + assert len(calls.namespace_source_context("agents").modules) == count + + +@pytest.mark.parametrize("problem,reason", [ + ("unknown", "module 'unknown'"), + ("relative", "missing"), + ("namespace", "no readable module"), + ("wildcard", "wildcard"), + ("utf8", "could not be read"), + ("link", "symbolic link"), + ("syntax", "could not be read or parsed"), +]) +def test_namespace_source_context_names_unread_inputs(namespace_workspace, problem, reason): + (namespace_workspace / "entry.py").write_text("VALUE = None\n") + (namespace_workspace / "tests").mkdir() + (namespace_workspace / "tests/__init__.py").write_text("") + if problem == "unknown": + (namespace_workspace / "entry.py").write_text("import unknown\n") + elif problem == "relative": + (namespace_workspace / "entry.py").write_text("from tests import hooks\n") + (namespace_workspace / "tests/hooks.py").write_text("from .missing import value\n") + elif problem == "namespace": + (namespace_workspace / "portion").mkdir() + (namespace_workspace / "entry.py").write_text("import portion\n") + elif problem == "wildcard": + (namespace_workspace / "entry.py").write_text("from tests import *\n") + else: + (namespace_workspace / "entry.py").write_text("from tests import hooks\n") + if problem == "utf8": + (namespace_workspace / "tests/hooks.py").write_bytes(b"\xff") + elif problem == "link": + (namespace_workspace / "tests/hooks.py").symlink_to("../entry.py") + else: + (namespace_workspace / "tests/hooks.py").write_text("def broken(:\n") + with pytest.raises(CallLimit, match=reason): + BuilderCalls(ImportResolver(namespace_workspace)).namespace_source_context("agents") + + +def test_namespace_source_context_keeps_an_aggregate_syntax_bound(namespace_workspace): + (namespace_workspace / "quiet.py").write_text("VALUE = None\n" * 6000) + with pytest.raises(CallLimit, match="exceeds 20000 syntax nodes"): + BuilderCalls(ImportResolver(namespace_workspace)).namespace_source_context("agents") + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("extra", [{}, {"quiet.py": "import unavailable\n"}]) +def test_namespace_source_context_never_grants_copy_or_function_authority(namespace_workspace, framework, extra): + from tests.test_builder_bindings import _namespace_mutation_observations + + built, warnings = _namespace_mutation_observations( + namespace_workspace, framework, "slots = dict(vars(framework))\nslots['Agent'] = fake.Agent\n", extra, + ) + assert warnings + assert all(not item.tools_complete and item.issues for item in built) + + +@pytest.mark.parametrize("family,package", [("agents", "agents"), ("google.adk", "google.adk.agents")]) +@pytest.mark.parametrize("unread", [False, True]) +def test_namespace_source_context_integration_keeps_reflection_refusal(namespace_workspace, family, package, unread): + from agents_shipgate.inputs.python_imports import ( + _external_constructor_use, + _namespace_copy_source_limit, + ) + + source = f"import {package} as framework\nslots = dict(vars(framework))\n" + path = namespace_workspace / "entry.py" + path.write_text(source) + if unread: + (namespace_workspace / "quiet.py").write_text("import unavailable\n") + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(source), source) + limit = _namespace_copy_source_limit(resolver, module, family) + answer = _external_constructor_use(resolver, module, family, {path}) + if unread: + assert limit and "quiet.py" in limit and "unavailable" in limit + assert answer == limit + else: + assert limit is None + assert answer and "reflective" in answer + assert not resolver._constructor_namespace_owners + assert not resolver._constructor_dictionary_sinks + assert not resolver._constructor_dictionary_class_sinks + + +def test_namespace_source_context_keeps_an_aggregate_byte_bound(namespace_workspace): + for index in range(9): + (namespace_workspace / f"quiet{index}.py").write_text("#" + "x" * (4 * 1024 * 1024) + "\n") + with pytest.raises(CallLimit, match="exceeds 33554432 source bytes"): + BuilderCalls(ImportResolver(namespace_workspace)).namespace_source_context("agents") + + +def test_namespace_source_context_names_its_selected_scope_limit(namespace_workspace): + (namespace_workspace / "__init__.py").write_text("VALUE = None\n") + scoped = namespace_workspace / "selected" + scoped.mkdir() + (scoped / "entry.py").write_text("VALUE = None\n") + with pytest.raises(CallLimit, match="selected.*above the read scope"): + BuilderCalls(ImportResolver(scoped)).namespace_source_context("agents") + + +def test_namespace_source_context_does_not_pair_cached_syntax_with_new_bytes(namespace_workspace): + path = namespace_workspace / "entry.py" + old = "VALUE = None\n" + path.write_text(old) + resolver = ImportResolver(namespace_workspace) + resolver.entry(path, ast.parse(old), old) + path.write_text("import unavailable\n") + with pytest.raises(CallLimit, match="entry.py changed after its parsed namespace source was read"): + BuilderCalls(resolver).namespace_source_context("agents") + + +@pytest.mark.parametrize("source", [ + "__import__('unread_external')\n", + "saved = __import__\n", + "import importlib as loader\nloader.import_module('unread_external')\n", + "from builtins import __import__ as loader\nloader('unread_external')\n", +]) +def test_namespace_source_context_refuses_dynamic_importers(namespace_workspace, source): + (namespace_workspace / "quiet.py").write_text(source) + with pytest.raises(CallLimit, match="quiet.py:1.*dynamic import machinery"): + BuilderCalls(ImportResolver(namespace_workspace)).namespace_source_context("agents") + + +@pytest.mark.parametrize("order", ["portion, local", "local, portion"]) +def test_namespace_source_context_checks_each_grouped_import_alias(namespace_workspace, order): + (namespace_workspace / "portion").mkdir() + (namespace_workspace / "local.py").write_text("VALUE = None\n") + (namespace_workspace / "entry.py").write_text(f"import {order}\n") + with pytest.raises(CallLimit, match="imports 'portion'.*no readable module"): + BuilderCalls(ImportResolver(namespace_workspace)).namespace_source_context("agents") + + +@pytest.mark.parametrize("family,package", [("agents", "agents"), ("agents", "openai_agents"), + ("google.adk", "google.adk.agents")]) +@pytest.mark.parametrize("order", ["canonical_first", "local_first"]) +def test_namespace_source_context_attributes_grouped_external_boundaries(namespace_workspace, family, package, order): + aliases = [f"{package} as framework", "local"] + if order == "local_first": + aliases.reverse() + (namespace_workspace / "entry.py").write_text("import " + ", ".join(aliases) + "\n") + (namespace_workspace / "local.py").write_text("VALUE = None\n") + context = BuilderCalls(ImportResolver(namespace_workspace)).namespace_source_context(family) + assert {module.ref for module in context.modules} == {"entry.py", "local.py"} + assert context.external_imports == (f"entry.py:1: {package}",) + + +def test_namespace_source_context_reads_a_scope_with_proven_absent_initializers(namespace_workspace): + selected = namespace_workspace / "selected" + selected.mkdir() + (selected / "entry.py").write_text("VALUE = None\n") + context = BuilderCalls(ImportResolver(selected)).namespace_source_context("agents") + assert [module.ref for module in context.modules] == ["entry.py"] + assert not context.external_imports + + +@pytest.mark.parametrize("kind", ["module", "namespace", "src_module", "src_namespace", "case", "linked"]) +def test_namespace_source_context_retains_above_scope_import_candidates(namespace_workspace, kind): + selected = namespace_workspace / "selected" + selected.mkdir() + (selected / "entry.py").write_text("import common\n") + root = namespace_workspace + if kind.startswith("src_"): + root = namespace_workspace / "src" + root.mkdir() + if kind.endswith("namespace"): + (root / "common").mkdir() + elif kind == "case": + (root / "Common.py").write_text("VALUE = None\n") + elif kind == "linked": + (selected / "target.py").write_text("VALUE = None\n") + (root / "common.py").symlink_to("selected/target.py") + else: + (root / "common.py").write_text("VALUE = None\n") + with pytest.raises(CallLimit, match="common.*above the read scope"): + BuilderCalls(ImportResolver(selected)).namespace_source_context("agents") + + +@pytest.mark.parametrize("kind", ["plain", "case", "linked"]) +def test_namespace_source_context_refuses_ancestor_initializer_shapes(namespace_workspace, kind): + selected = namespace_workspace / "selected" + selected.mkdir() + (selected / "entry.py").write_text("VALUE = None\n") + name = "__INIT__.PY" if kind == "case" else "__init__.py" + if kind == "linked": + (namespace_workspace / name).symlink_to("selected/entry.py") + else: + (namespace_workspace / name).write_text("VALUE = None\n") + with pytest.raises(CallLimit, match="initializer.*above.*read scope"): + BuilderCalls(ImportResolver(selected)).namespace_source_context("agents") + + +def test_namespace_source_context_does_not_use_an_unbound_layout_as_disk_evidence(namespace_workspace): + from agents_shipgate.inputs.python_imports import RepositoryLayout, repository_layout + + selected = namespace_workspace / "selected" + selected.mkdir() + (selected / "entry.py").write_text("VALUE = None\n") + with repository_layout(RepositoryLayout("selected", lambda path: frozenset())): + resolver = ImportResolver(selected) + with pytest.raises(CallLimit, match="no bound disk ancestry"): + BuilderCalls(resolver).namespace_source_context("agents") + + +def test_namespace_source_context_refuses_ancestry_outside_active_snapshot(namespace_workspace): + selected = namespace_workspace / "selected" + selected.mkdir() + (selected / "entry.py").write_text("VALUE = None\n") + snapshot = StaticInputSnapshot(selected) + token = activate_static_input_snapshot(snapshot) + try: + with pytest.raises(CallLimit, match="outside the bound input snapshot"): + BuilderCalls(ImportResolver(selected)).namespace_source_context("agents") + finally: + reset_static_input_snapshot(token) + + +def test_namespace_source_context_binds_ancestor_absence_to_the_repository_snapshot(namespace_workspace): + from agents_shipgate.core.input_directory_identity import validate_directory_inputs + from agents_shipgate.core.verification_identity import ( + build_verification_plan, + validate_dependency_inputs, + ) + from tests.test_constructor_dependency_ownership import _commit + + selected = namespace_workspace / "selected" + selected.mkdir() + (selected / "entry.py").write_text("VALUE = None\n") + (namespace_workspace / "shipgate.yaml").write_text("agent:\n name: namespace-fixture\n") + _commit(namespace_workspace, {}) + snapshot = StaticInputSnapshot(namespace_workspace) + token = activate_static_input_snapshot(snapshot) + try: + context = BuilderCalls(ImportResolver(selected)).namespace_source_context("agents") + assert [module.ref for module in context.modules] == ["entry.py"] + plan = build_verification_plan( + git_root=namespace_workspace, input_root=namespace_workspace, + config_path=namespace_workspace / "shipgate.yaml", config_logical_path="shipgate.yaml", + base_ref=None, head_ref="HEAD", archived_head=False, + repository_id="https://example.test/namespace-fixture.git", + base_commit_sha=None, base_tree_sha=None, head_commit_sha=None, + head_tree_sha=None, merge_base_sha=None, changed_files=[], diff_text="", + baseline_path=None, diff_from_path=None, policy_pack_paths=[], + evaluation_date="2026-10-05", options={}, plugins_enabled=False, + captured_input_paths=snapshot.paths(), + ) + snapshot.finish() + finally: + reset_static_input_snapshot(token) + validate_directory_inputs(plan, snapshot=StaticInputSnapshot(namespace_workspace)) + validate_dependency_inputs(plan, root=namespace_workspace) + (namespace_workspace / "__init__.py").write_text("VALUE = None\n") + with pytest.raises(ValueError): + validate_directory_inputs(plan, snapshot=StaticInputSnapshot(namespace_workspace)) + with pytest.raises(ValueError): + validate_dependency_inputs(plan, root=namespace_workspace) + + +def _source_slot_fixture(root, extra="", definition="def Agent(tools):\n return tools\n"): + (root / "fake.py").write_text(definition) + source = "import fake\nimport fake as other\nother.Agent = fake.Agent\n" + extra + (root / "entry.py").write_text(source) + resolver = ImportResolver(root) + module = resolver.entry(root / "entry.py", ast.parse(source), source) + statement = module.tree.body[2] + return resolver, module, statement + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_source_slot_proof_keeps_ordinary_caller_cache_separate(namespace_workspace, family): + resolver, module, statement = _source_slot_fixture(namespace_workspace) + calls = BuilderCalls(resolver) + home, function, _ = calls._source_slot_candidate(module, statement) + before = calls.callers(home, function, allow_empty=True) + assert before.limits + assert calls.idempotent_source_slot(module, statement, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert before.limits and not before.sites + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", [ + "fake.Agent([])\n", + "saved = fake.Agent\n", + "fake.Agent = replacement\n", + "del fake.Agent\n", + "unknown.Agent = replacement\n", + "setattr(fake, 'Agent', replacement)\n", + "saved = vars(fake)\n", + "saved = fake.Agent.__globals__\n", + "import sys\nsys.modules['fake'] = replacement\n", + "import builtins\nbuiltins.dict = replacement\n", + "def __getattr__(name):\n return replacement\n", +]) +def test_source_slot_proof_refuses_other_calls_escapes_and_mutations(namespace_workspace, extra): + resolver, module, statement = _source_slot_fixture(namespace_workspace, extra) + calls = BuilderCalls(resolver) + home, function, _ = calls._source_slot_candidate(module, statement) + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not calls.uncalled_source_slot(home, function) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("definition", [ + "@decorator\ndef Agent(tools):\n return tools\n", + "async def Agent(tools):\n return tools\n", + "class Agent:\n pass\n", +]) +def test_source_slot_proof_requires_the_exact_plain_source_function(namespace_workspace, definition): + resolver, module, statement = _source_slot_fixture(namespace_workspace, definition=definition) + assert not BuilderCalls(resolver).idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +def test_source_slot_proof_checks_sibling_primitive_changes(namespace_workspace): + resolver, module, statement = _source_slot_fixture(namespace_workspace) + (namespace_workspace / "quiet.py").write_text("import builtins\nbuiltins.dict = replacement\n") + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +def test_source_slot_proof_rechecks_source_after_a_previous_success(namespace_workspace): + resolver, module, statement = _source_slot_fixture(namespace_workspace) + calls = BuilderCalls(resolver) + assert calls.idempotent_source_slot(module, statement, "agents") + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +def test_source_slot_proof_does_not_clear_an_active_reentry_guard(namespace_workspace): + resolver, module, statement = _source_slot_fixture(namespace_workspace) + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + BuilderCalls(resolver).idempotent_source_slot(module, statement, "agents") + assert resolver._checking_source_module_slot + + +def test_namespace_directory_capture_counts_unique_entries_and_rechecks_currency(namespace_workspace): + selected = namespace_workspace / "selected" + selected.mkdir() + (selected / "entry.py").write_text("import agents\n" * 24) + for number in range(4200): + (namespace_workspace / f"data_{number}.txt").touch() + calls = BuilderCalls(ImportResolver(selected)) + assert len(calls.namespace_source_context("agents").modules) == 1 + assert calls._namespace_entry_work < 10000 + (namespace_workspace / "agents.py").write_text("VALUE = None\n") + with pytest.raises(CallLimit, match="changed after"): + calls._namespace_directory_currency() + + +def _source_slot_alias_fixture(root, declaration="other = fake", *, local=False, extra="", prefix=""): + (root / "fake.py").write_text("def Agent(tools):\n return tools\n") + body = "import fake\n" + declaration + "\nother.Agent = fake.Agent\n" + extra + source = prefix + ("def touch():\n" + "".join(" " + line + "\n" for line in body.splitlines()) + if local else body) + (root / "entry.py").write_text(source) + resolver = ImportResolver(root) + module = resolver.entry(root / "entry.py", ast.parse(source), source) + statements = module.tree.body[-1].body if local else module.tree.body[len(ast.parse(prefix).body):] + return resolver, module, statements[1].value, statements[2] + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("local", [False, True]) +@pytest.mark.parametrize("declaration", ["other = fake", "other: object = fake"]) +def test_source_slot_alias_proof_binds_initializer_and_slot_without_cache_grants( + namespace_workspace, family, local, declaration, +): + resolver, module, initializer, terminal = _source_slot_alias_fixture( + namespace_workspace, declaration, local=local, + ) + calls = BuilderCalls(resolver) + home, function, _ = calls._source_slot_candidate(module, terminal) + before = calls.callers(home, function, allow_empty=True) + assert before.limits + assert calls.idempotent_source_slot(module, initializer, family) + assert calls.idempotent_source_slot(module, terminal, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert before.limits and not before.sites + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", [ + "other.Agent([])\n", "fake.Agent([])\n", "saved = other.Agent\n", "saved = fake.Agent\n", + "saved = other\n", "other.unrelated = None\n", "other = fake\n", "del other\n", + "def retain():\n return other\n", "def retain(value=other):\n pass\n", + "def retain(value: other):\n pass\n", "__all__ = ['other']\n", + "other['Agent'] = replacement\n", "setattr(other, 'Agent', replacement)\n", + "fake.Agent = replacement\n", "unknown.Agent = replacement\n", +]) +def test_source_slot_alias_proof_refuses_calls_captures_rebinding_and_other_writes(namespace_workspace, extra): + resolver, module, initializer, terminal = _source_slot_alias_fixture(namespace_workspace, extra=extra) + calls = BuilderCalls(resolver) + for node in (initializer, terminal): + try: + assert not calls.idempotent_source_slot(module, node, "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("annotation", ["object()", "type", "object[int]", "builtins.object", "'object'", "Custom"]) +def test_source_slot_alias_annotation_is_only_the_exact_builtin_name(namespace_workspace, annotation): + resolver, module, initializer, terminal = _source_slot_alias_fixture( + namespace_workspace, f"other: {annotation} = fake", + ) + calls = BuilderCalls(resolver) + assert not calls.idempotent_source_slot(module, initializer, "agents") + assert not calls.idempotent_source_slot(module, terminal, "agents") + + +@pytest.mark.parametrize("local", [False, True]) +def test_source_slot_alias_annotation_cannot_borrow_a_shadowed_object(namespace_workspace, local): + resolver, module, initializer, terminal = _source_slot_alias_fixture( + namespace_workspace, "other: object = fake", local=local, prefix="object = replacement\n", + ) + assert not BuilderCalls(resolver).idempotent_source_slot(module, initializer, "agents") + assert not BuilderCalls(resolver).idempotent_source_slot(module, terminal, "agents") + + +def test_source_slot_alias_annotation_rechecks_sibling_builtin_writes(namespace_workspace): + resolver, module, initializer, terminal = _source_slot_alias_fixture( + namespace_workspace, "other: object = fake", + ) + (namespace_workspace / "quiet.py").write_text("import builtins\nbuiltins.object = replacement\n") + for node in (initializer, terminal): + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, node, "agents") + + +def test_source_slot_alias_proof_does_not_allow_importing_the_namespace_owner(namespace_workspace): + resolver, module, initializer, terminal = _source_slot_alias_fixture(namespace_workspace) + (namespace_workspace / "consumer.py").write_text("from entry import other\n") + for node in (initializer, terminal): + with pytest.raises(CallLimit, match="imported source-slot namespace alias"): + BuilderCalls(resolver).idempotent_source_slot(module, node, "agents") + + +def test_source_slot_alias_proof_rechecks_new_alias_uses_after_success(namespace_workspace): + resolver, module, initializer, terminal = _source_slot_alias_fixture(namespace_workspace) + calls = BuilderCalls(resolver) + assert calls.idempotent_source_slot(module, initializer, "agents") + path = namespace_workspace / "entry.py" + path.write_text(path.read_text() + "saved = other\n") + for node in (initializer, terminal): + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, node, "agents") + + +@pytest.mark.parametrize("shape", ["distinct", "rhs_only", "shared"]) +def test_source_slot_alias_route_requires_one_shared_or_target_initializer(namespace_workspace, shape): + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return tools\n") + bodies = { + "distinct": "left = fake\nright = fake\nleft.Agent = right.Agent\n", + "rhs_only": "right = fake\nfake.Agent = right.Agent\n", + "shared": "left = fake\nleft.Agent = left.Agent\n", + } + source = "import fake\n" + bodies[shape] + path = namespace_workspace / "entry.py" + path.write_text(source) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(source), source) + calls = BuilderCalls(resolver) + terminal = module.tree.body[-1] + initializers = [statement.value for statement in module.tree.body[1:-1]] + for node in [terminal, *initializers]: + assert calls.idempotent_source_slot(module, node, "agents") is (shape == "shared") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("package", [False, True]) +@pytest.mark.parametrize("where", ["home", "quiet"]) +@pytest.mark.parametrize("alias", [False, True]) +def test_source_slot_proof_refuses_raw_path_markers_in_every_source_module( + namespace_workspace, package, where, alias, +): + if package: + (namespace_workspace / "fake").mkdir() + home = namespace_workspace / ("fake/__init__.py" if package else "fake.py") + home.write_text("def Agent(tools):\n return tools\n") + changed = home if where == "home" else namespace_workspace / "quiet.py" + changed.write_text(changed.read_text() + "__path__ = []\n" if changed.exists() else "__path__ = []\n") + source = "import fake\n" + ("other = fake\nother.Agent = fake.Agent\n" if alias + else "import fake as other\nother.Agent = fake.Agent\n") + path = namespace_workspace / "entry.py" + path.write_text(source) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(source), source) + calls = BuilderCalls(resolver) + nodes = [module.tree.body[-1]] + if alias: + nodes.append(module.tree.body[1].value) + for node in nodes: + with pytest.raises(CallLimit, match="raw package-path or module-namespace mutation markers"): + calls.idempotent_source_slot(module, node, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "slots.__init__(Agent=fake.Agent)", + "slots.__init__({'Agent': fake.Agent})", +]) +@pytest.mark.parametrize("alias", [False, True]) +def test_bound_dictionary_initialization_keeps_exact_data_and_ordinary_census( + namespace_workspace, write, alias, +): + resolver = ImportResolver(namespace_workspace) + home = _module(namespace_workspace, "fake.py", "def Agent(tools):\n return tools\n", resolver) + declaration = "original = {}\nslots = original\n" if alias else "slots = {}\n" + module = _module(namespace_workspace, "entry.py", "import fake\n" + declaration + write + "\n", resolver) + calls = BuilderCalls(resolver) + payload = next(node for node in ast.walk(module.tree) + if isinstance(node, ast.Attribute) and node.attr == "Agent") + before = calls.callers(home, home.tree.body[0], allow_empty=True) + assert before.limits + assert payload in calls._confined_dictionary_data(module).values + refined = calls.callers(home, home.tree.body[0], allow_empty=True, confined_dictionary_data=True) + assert not refined.sites and not refined.limits + assert calls.callers(home, home.tree.body[0], allow_empty=True) is before + assert before.limits + + +@pytest.mark.parametrize("write,suffix,consumer", [ + ("slots.__init__(Agent=fake.Agent)", "saved = slots['Agent']\n", None), + ("slots.__init__(Agent=fake.Agent)", "slots['Agent']([])\n", None), + ("slots.__init__(Agent=fake.Agent)", "saved = slots\nconsumer(saved)\n", None), + ("slots.__init__(Agent=fake.Agent)", "def capture():\n return slots\n", None), + ("slots.__init__(Agent=fake.Agent)", "__all__ = ['slots']\n", None), + ("slots.__init__(Agent=fake.Agent)", "slots = {}\n", None), + ("slots.__init__(Agent=fake.Agent)", "del slots\n", None), + ("slots.__init__(Agent=fake.Agent)", "", "from entry import slots\n"), + ("slots.__init__(Agent=fake.Agent)", "", "import entry\n"), + ("saved = slots.__init__(Agent=fake.Agent)", "", None), + ("saved = slots.__init__\nsaved(Agent=fake.Agent)", "", None), + ("slots.__init__(opaque, Agent=fake.Agent)", "", None), + ("slots.__init__(*opaque, Agent=fake.Agent)", "", None), + ("slots.__init__(**{'Agent': fake.Agent})", "", None), + ("slots.__init__({'Agent': fake.Agent}, opaque)", "", None), + ("slots.__init__({'Agent': fake.Agent}, other=fake.Agent)", "", None), + ("slots.__init__({key(): fake.Agent})", "", None), + ("slots.__init__({**opaque, 'Agent': fake.Agent})", "", None), + ("slots.__init__(__class__=fake.Agent)", "", None), + ("slots.__init__({'__class__': fake.Agent})", "", None), + ("dict.__init__(slots, Agent=fake.Agent)", "", None), + ("builtins.dict.__init__(slots, Agent=fake.Agent)", "", None), +]) +def test_bound_dictionary_initialization_refuses_reads_escapes_and_opaque_protocols( + namespace_workspace, write, suffix, consumer, +): + resolver = ImportResolver(namespace_workspace) + home = _module(namespace_workspace, "fake.py", "def Agent(tools):\n return tools\n", resolver) + module = _module(namespace_workspace, "entry.py", "import fake\nslots = {}\n" + write + "\n" + suffix, resolver) + if consumer is not None: + _module(namespace_workspace, "consumer.py", consumer, resolver) + calls = BuilderCalls(resolver) + payloads = [node for node in ast.walk(module.tree) + if isinstance(node, ast.Attribute) and node.attr == "Agent"] + assert payloads and all(node not in calls._confined_dictionary_data(module).values for node in payloads) + census = calls.callers(home, home.tree.body[0], allow_empty=True, confined_dictionary_data=True) + assert census.limits + + +@pytest.mark.parametrize("allocation", ["vars(fake)", "fake.__dict__", "dict(vars(fake))", "unknown()", "imported"]) +def test_bound_dictionary_initialization_cannot_borrow_a_namespace_or_unknown_receiver(namespace_workspace, allocation): + resolver = ImportResolver(namespace_workspace) + _module(namespace_workspace, "fake.py", "def Agent(tools):\n return tools\n", resolver) + module = _module(namespace_workspace, "entry.py", "import fake\nslots = " + allocation + "\nslots.__init__(Agent=fake.Agent)\n", resolver) + assert not BuilderCalls(resolver)._confined_dictionary_data(module).values + + +@pytest.mark.parametrize("suffix,has_site", [("fake.Agent([])\n", True), ("saved = fake.Agent\n", False)]) +def test_bound_dictionary_initialization_preserves_neighboring_function_uses(namespace_workspace, suffix, has_site): + resolver = ImportResolver(namespace_workspace) + home = _module(namespace_workspace, "fake.py", "def Agent(tools):\n return tools\n", resolver) + _module(namespace_workspace, "entry.py", "import fake\nslots = {}\nslots.__init__(Agent=fake.Agent)\n" + suffix, resolver) + census = BuilderCalls(resolver).callers(home, home.tree.body[0], allow_empty=True, confined_dictionary_data=True) + assert bool(census.sites) if has_site else bool(census.limits) + + +def _source_dictionary_slot_fixture(root, *, write="fake.__dict__['Agent'] = fake.Agent", extra="", home="def Agent(tools):\n return tools\n"): + (root / "fake.py").write_text(home) + source = "import fake\n" + write + "\n" + extra + path = root / "entry.py" + path.write_text(source) + resolver = ImportResolver(root) + module = resolver.entry(path, ast.parse(source), source) + return resolver, module, module.tree.body[1] + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_source_dictionary_slot_keeps_projection_key_and_function_roles_distinct(namespace_workspace, family): + resolver, module, statement = _source_dictionary_slot_fixture(namespace_workspace) + calls = BuilderCalls(resolver) + home, function, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + parts = calls._source_slot_parts(module, statement) + assert parts[0] is statement.targets[0].value.value + assert parts[3] is statement.targets[0].value + assert parts[4] is statement.targets[0].slice + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + for node in (statement, statement.value, parts[0], parts[3]): + assert calls.idempotent_source_slot(module, node, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert before.limits and not before.sites + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", [ + "fake.Agent([])\n", "saved = fake.Agent\n", "saved = fake.__dict__\n", + "fake.__dict__['Agent']([])\n", "saved = fake.__dict__['Agent']\n", + "def capture():\n return fake.__dict__\n", "consumer(fake)\n", "__all__ = ['fake']\n", + "fake.Agent = replacement\n", "unknown.Agent = replacement\n", + "fake.__dict__['Agent'] = replacement\n", "del fake.__dict__['Agent']\n", + "fake.__dict__[key] = replacement\n", "fake.__class__ = replacement\n", +]) +def test_source_dictionary_slot_keeps_calls_escapes_and_other_writes_unread(namespace_workspace, extra): + resolver, module, statement = _source_dictionary_slot_fixture(namespace_workspace, extra=extra) + calls = BuilderCalls(resolver) + try: + assert not calls.idempotent_source_slot(module, statement, "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "fake.__dict__[key] = fake.Agent", + "fake.__dict__['other'] = fake.Agent", + "fake.__dict__['__class__'] = fake.Agent", + "vars(fake, opaque)['Agent'] = fake.Agent", + "getattr(fake, '__dict__')['Agent'] = fake.Agent", + "first = fake\nother = first\nother.__dict__['Agent'] = fake.Agent", + "slots = fake.__dict__\nother = slots\nother['Agent'] = fake.Agent", + "def touch():\n fake.__dict__['Agent'] = fake.Agent", + "fake().__dict__['Agent'] = fake.Agent", +]) +def test_source_dictionary_slot_does_not_grant_other_projection_or_alias_roles(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.Assign): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + + +@pytest.mark.parametrize("home", [ + "class Agent:\n pass\n", "@decorate\ndef Agent(tools):\n return tools\n", + "async def Agent(tools):\n return tools\n", "Agent = unknown\n", +]) +def test_source_dictionary_slot_requires_the_actual_original_plain_function(namespace_workspace, home): + resolver, module, statement = _source_dictionary_slot_fixture(namespace_workspace, home=home) + assert BuilderCalls(resolver)._source_slot_candidate(module, statement) is None + assert not BuilderCalls(resolver).idempotent_source_slot(module, statement, "agents") + + +def test_source_dictionary_slot_does_not_pair_distinct_module_homes(namespace_workspace): + resolver, module, statement = _source_dictionary_slot_fixture( + namespace_workspace, write="import other\nother.__dict__['Agent'] = fake.Agent", + ) + (namespace_workspace / "other.py").write_text("def Agent(tools):\n return tools\n") + statement = module.tree.body[-1] + assert BuilderCalls(resolver)._source_slot_candidate(module, statement) is None + + +@pytest.mark.parametrize("mutation", ["__path__ = []\n", "__getattr__ = replacement\n", "import sys\nsys.modules.clear()\n"]) +def test_source_dictionary_slot_retains_raw_hook_path_and_table_checks(namespace_workspace, mutation): + resolver, module, statement = _source_dictionary_slot_fixture(namespace_workspace) + (namespace_workspace / "quiet.py").write_text(mutation) + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, statement, "agents") + + +def test_source_dictionary_slot_raw_proof_cannot_borrow_receiving_callbacks(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, statement = _source_dictionary_slot_fixture(namespace_workspace) + def forbidden(*args, **kwargs): + raise AssertionError("a raw projection proof borrowed a receiving-owner callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, statement, "agents") + + +def test_source_dictionary_slot_rechecks_the_actual_source_after_success(namespace_workspace): + resolver, module, statement = _source_dictionary_slot_fixture(namespace_workspace) + calls = BuilderCalls(resolver) + assert calls.idempotent_source_slot(module, statement, "agents") + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + + +@pytest.mark.parametrize("alias,consumer", [ + (False, "from entry import fake\n"), + (True, "from entry import other\n"), + (True, "from entry import fake\n"), + (False, "import entry as exported\n"), +]) +def test_source_dictionary_slot_refuses_importing_either_actual_namespace_receiver(namespace_workspace, alias, consumer): + write = "import fake as other\nother.__dict__['Agent'] = fake.Agent" if alias else "fake.__dict__['Agent'] = fake.Agent" + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + statement = module.tree.body[-1] + (namespace_workspace / "consumer.py").write_text(consumer) + calls = BuilderCalls(resolver) + for node in (statement, statement.value, statement.targets[0].value): + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + calls.idempotent_source_slot(module, node, "agents") + assert not resolver._checking_source_module_slot + + +def test_source_dictionary_slot_import_guard_preserves_the_source_function_export_contract(namespace_workspace): + resolver, module, statement = _source_dictionary_slot_fixture( + namespace_workspace, home="__all__ = ['Agent']\ndef Agent(tools):\n return tools\n", + ) + assert BuilderCalls(resolver).idempotent_source_slot(module, statement, "agents") + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_direct_vars_slot_keeps_getter_projection_key_namespace_and_function_distinct(namespace_workspace, family): + resolver, module, statement = _source_dictionary_slot_fixture( + namespace_workspace, write="vars(fake)['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + home, function, _ = calls._source_slot_candidate(module, statement) + projection = statement.targets[0].value + assert isinstance(projection, ast.Call) and isinstance(projection.func, ast.Name) + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + for node in (statement, projection, projection.func, projection.args[0], statement.value): + assert calls.idempotent_source_slot(module, node, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert before.limits and not before.sites + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "vars()['Agent'] = fake.Agent", "vars(fake, opaque)['Agent'] = fake.Agent", + "vars(fake, extra=opaque)['Agent'] = fake.Agent", "vars(*[fake])['Agent'] = fake.Agent", + "vars(owner=fake)['Agent'] = fake.Agent", "builtins.vars(fake)['Agent'] = fake.Agent", + "from builtins import vars\nvars(fake)['Agent'] = fake.Agent", + "replace = vars\nreplace(fake)['Agent'] = fake.Agent", + "vars = replacement\nvars(fake)['Agent'] = fake.Agent", + "vars(fake())['Agent'] = fake.Agent", "vars(fake)[key] = fake.Agent", + "vars(fake)['other'] = fake.Agent", "vars(fake)['__class__'] = fake.Agent", + "first = fake\nother = first\nvars(other)['Agent'] = fake.Agent", + "slots = vars(fake)\nother = slots\nother['Agent'] = fake.Agent", + "def touch():\n vars(fake)['Agent'] = fake.Agent", +]) +def test_direct_vars_slot_refuses_noncanonical_getters_receivers_and_other_roles(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.Assign): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + + +@pytest.mark.parametrize("extra", [ + "fake.Agent([])\n", "saved = fake.Agent\n", "saved = vars(fake)\n", + "vars(fake)['Agent']([])\n", "consumer(fake)\n", "__all__ = ['fake']\n", + "fake.Agent = replacement\n", "del fake.Agent\n", +]) +def test_direct_vars_slot_preserves_other_uses_and_competing_writes(namespace_workspace, extra): + resolver, module, statement = _source_dictionary_slot_fixture( + namespace_workspace, write="vars(fake)['Agent'] = fake.Agent", extra=extra, + ) + calls = BuilderCalls(resolver) + try: + assert not calls.idempotent_source_slot(module, statement, "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("consumer", ["from entry import fake\n", "import entry as exported\n"]) +def test_direct_vars_slot_requires_the_completed_namespace_import_guard(namespace_workspace, consumer): + resolver, module, statement = _source_dictionary_slot_fixture( + namespace_workspace, write="vars(fake)['Agent'] = fake.Agent", + ) + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + BuilderCalls(resolver).idempotent_source_slot(module, statement.targets[0].value.func, "agents") + + +def test_direct_vars_slot_rechecks_foreign_builtin_replacement(namespace_workspace): + resolver, module, statement = _source_dictionary_slot_fixture( + namespace_workspace, write="vars(fake)['Agent'] = fake.Agent", + ) + (namespace_workspace / "quiet.py").write_text("import builtins\nbuiltins.vars = replacement\n") + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, statement.targets[0].value.func, "agents") + + +def test_direct_vars_slot_raw_proof_cannot_borrow_receiving_callbacks(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, statement = _source_dictionary_slot_fixture( + namespace_workspace, write="vars(fake)['Agent'] = fake.Agent", + ) + def forbidden(*args, **kwargs): + raise AssertionError("a raw vars proof borrowed a receiving-owner callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, statement.targets[0].value.func, "agents") + + +def test_direct_vars_slot_does_not_clear_an_active_reentry_guard(namespace_workspace): + resolver, module, statement = _source_dictionary_slot_fixture( + namespace_workspace, write="vars(fake)['Agent'] = fake.Agent", + ) + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + BuilderCalls(resolver).idempotent_source_slot(module, statement.targets[0].value.func, "agents") + assert resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["other.__dict__", "vars(other)"]) +@pytest.mark.parametrize("declaration", ["other = fake", "other: object = fake"]) +@pytest.mark.parametrize("rhs", ["fake", "other"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_source_mapping_module_alias_preserves_actual_initializer_and_uncached_function_roles( + namespace_workspace, projection, declaration, rhs, family, +): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"{declaration}\n{projection}['Agent'] = {rhs}.Agent", + ) + initializer = module.tree.body[1].value + statement = module.tree.body[-1] + calls = BuilderCalls(resolver) + home, function, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + parts = calls._source_slot_parts(module, statement) + receiver = calls._source_slot_receiver(module, parts[0], statement) + assert receiver[0] is initializer and receiver[1] is initializer + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + assert not calls.idempotent_source_slot(module, parts[1], family) + nodes = [initializer, statement, parts[0], parts[3], statement.value] + if isinstance(parts[3], ast.Call): + nodes.append(parts[3].func) + for node in nodes: + assert calls.idempotent_source_slot(module, node, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert before.limits and not before.sites + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["other.__dict__", "vars(other)"]) +@pytest.mark.parametrize("extra", [ + "consumer(other)\n", "saved = other\n", "other.Agent([])\n", + "saved = other.Agent\n", "saved = other.__dict__\n", + "def capture():\n return other\n", "def capture(value=other):\n pass\n", + "other = fake\n", "del other\n", "__all__ = ['other']\n", + "other.Agent = replacement\n", "other.__dict__[key] = replacement\n", +]) +def test_source_mapping_module_alias_refuses_other_alias_uses(namespace_workspace, projection, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"other = fake\n{projection}['Agent'] = fake.Agent", extra=extra, + ) + statement = module.tree.body[2] + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, statement, "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "other = fake\nfake.__dict__['Agent'] = other.Agent", + "other = fake\nvars(fake)['Agent'] = other.Agent", + "other = fake\nright = fake\nother.__dict__['Agent'] = right.Agent", + "other = fake\nright = fake\nvars(other)['Agent'] = right.Agent", + "other: 'object' = fake\nother.__dict__['Agent'] = fake.Agent", + "other: custom = fake\nvars(other)['Agent'] = fake.Agent", + "object = replacement\nother: object = fake\nvars(other)['Agent'] = fake.Agent", + "other = fake\nslots = vars(other)\nslots['Agent'] = fake.Agent", +]) +def test_source_mapping_module_alias_refuses_other_initializer_and_annotation_routes(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + + +@pytest.mark.parametrize("projection", ["other.__dict__", "vars(other)"]) +@pytest.mark.parametrize("rhs", ["fake", "other"]) +@pytest.mark.parametrize("consumer", [ + "from entry import other\n", "from entry import fake\n", "import entry as exported\n", +]) +def test_source_mapping_module_alias_refuses_importing_actual_receiver_or_initializer( + namespace_workspace, projection, rhs, consumer, +): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"other = fake\n{projection}['Agent'] = {rhs}.Agent", + ) + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[1].value, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["other.__dict__", "vars(other)"]) +def test_source_mapping_module_alias_rechecks_initializer_source_currency(namespace_workspace, projection): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"other = fake\n{projection}['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + initializer = module.tree.body[1].value + assert calls.idempotent_source_slot(module, initializer, "agents") + module.path.write_text(f"import fake\nother = replacement\n{projection}['Agent'] = fake.Agent\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, initializer, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_saved_source_mapping_store_keeps_mapping_and_function_roles_distinct(namespace_workspace, projection, family): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = {projection}\nslots['Agent'] = fake.Agent", + ) + declaration, statement = module.tree.body[1:] + calls = BuilderCalls(resolver) + parts = calls._source_slot_parts(module, statement) + mapping = parts[5] + assert mapping.declaration is declaration and mapping.target is declaration.targets[0] + assert mapping.receiver is statement.targets[0].value + assert mapping.projection is declaration.value and mapping.namespace is parts[0] + home, function, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + assert not calls.idempotent_source_slot(module, declaration, family) + nodes = [statement, mapping.receiver, declaration.value, parts[0], statement.value] + if isinstance(declaration.value, ast.Call): + nodes.append(declaration.value.func) + for node in nodes: + assert calls.idempotent_source_slot(module, node, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert not calls._confined_dictionary_data(module).values + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)"]) +@pytest.mark.parametrize("extra", [ + "saved = slots\n", "saved = slots['Agent']\n", "slots['Agent']([])\n", + "consumer(slots)\n", "def capture():\n return slots\n", + "def capture(value=slots):\n pass\n", "slots = {}\n", "del slots\n", + "slots.update(Agent=fake.Agent)\n", "slots |= {'Agent': fake.Agent}\n", + "__all__ = ['slots']\n", "__all__ = ['fake']\n", "fake.Agent([])\n", + "fake.Agent = replacement\n", "unknown.__dict__['Agent'] = replacement\n", +]) +def test_saved_source_mapping_store_refuses_reads_captures_and_competing_uses(namespace_workspace, projection, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = {projection}\nslots['Agent'] = fake.Agent", extra=extra, + ) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "slots: object = vars(fake)\nslots['Agent'] = fake.Agent", + "other = fake\nslots = vars(other)\nslots['Agent'] = fake.Agent", + "other = fake\nslots = fake.__dict__\nslots['Agent'] = other.Agent", + "slots = fake.__dict__\nother = slots\nother['Agent'] = fake.Agent", + "slots = vars(fake)\nslots[key] = fake.Agent", + "slots = fake.__dict__\nslots['other'] = fake.Agent", + "slots = getattr(fake, '__dict__', {})\nslots['Agent'] = fake.Agent", + "slots = dict(vars(fake))\nslots['Agent'] = fake.Agent", + "slots = builtins.vars(fake)\nslots['Agent'] = fake.Agent", + "replace = vars\nother = replace\nslots = other(fake)\nslots['Agent'] = fake.Agent", + "vars = replacement\nslots = vars(fake)\nslots['Agent'] = fake.Agent", + "slots = other = fake.__dict__\nslots['Agent'] = fake.Agent", + "def touch():\n slots = vars(fake)\n slots['Agent'] = fake.Agent", +]) +def test_saved_source_mapping_store_refuses_other_projection_and_declaration_shapes(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.Assign): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)"]) +@pytest.mark.parametrize("consumer", [ + "from entry import slots\n", "from entry import fake\n", + "from entry import origin\n", "import entry\n", +]) +def test_saved_source_mapping_store_checks_dictionary_and_both_imported_namespace_names(namespace_workspace, projection, consumer): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"import fake as origin\nslots = {projection}\nslots['Agent'] = origin.Agent", + ) + statement = module.tree.body[-1] + calls = BuilderCalls(resolver) + assert calls.idempotent_source_slot(module, statement, "agents") + (namespace_workspace / "consumer.py").write_text(consumer) + source = module.path.read_text() + current_resolver = ImportResolver(namespace_workspace) + current_module = current_resolver.entry(module.path, ast.parse(source), source) + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + BuilderCalls(current_resolver).idempotent_source_slot(current_module, current_module.tree.body[-1], "agents") + assert not current_resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)"]) +def test_saved_source_mapping_store_preserves_function_home_exports(namespace_workspace, projection): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = {projection}\nslots['Agent'] = fake.Agent", + home="__all__ = ['Agent']\ndef Agent(tools):\n return tools\n", + ) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)"]) +def test_saved_source_mapping_store_rechecks_declaration_and_reentry(namespace_workspace, projection): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = {projection}\nslots['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + assert calls.idempotent_source_slot(module, statement, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.idempotent_source_slot(module, statement, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + module.path.write_text("import fake\nslots = replacement\nslots['Agent'] = fake.Agent\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)"]) +@pytest.mark.parametrize("write", ["slots.update(Agent=fake.Agent)", "slots.update({'Agent': fake.Agent})"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_saved_source_mapping_update_separates_function_value_from_mutation_syntax(namespace_workspace, projection, write, family): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = {projection}\n{write}") + declaration, statement = module.tree.body[1:] + calls = BuilderCalls(resolver) + edge = calls._source_slot_edge(module, statement) + assert edge.statement is statement and edge.call is statement.value + assert edge.callee is statement.value.func + assert edge.parts[5].receiver is edge.callee.value + assert edge.parts[5].projection is declaration.value + if isinstance(edge.payload, ast.keyword): + assert edge.rhs is edge.payload.value + assert edge.parts[4] is None + else: + assert edge.rhs is edge.payload.values[0] + assert edge.parts[4] is edge.payload.keys[0] + home, function, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + assert not calls.idempotent_source_slot(module, edge.payload, family) + assert not calls.idempotent_source_slot(module, declaration, family) + nodes = [statement, edge.call, edge.callee, edge.parts[5].receiver, edge.rhs, declaration.value, edge.parts[0]] + if isinstance(declaration.value, ast.Call): + nodes.append(declaration.value.func) + for node in nodes: + assert calls.idempotent_source_slot(module, node, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)"]) +@pytest.mark.parametrize("write", [ + "slots.update(**{'Agent': fake.Agent})", "slots.update(*[{'Agent': fake.Agent}])", + "slots.update(Agent=fake.Agent, other=fake.Agent)", + "slots.update({'Agent': fake.Agent}, {'Agent': fake.Agent})", + "slots.update({'Agent': fake.Agent}, Agent=fake.Agent)", + "slots.update({'Agent': fake.Agent, 'Agent': fake.Agent})", + "slots.update({key: fake.Agent})", "slots.update({**opaque, 'Agent': fake.Agent})", + "slots.update(mapping)", "slots.update(other=fake.Agent)", + "slots.update({'other': fake.Agent})", "slots.update(Agent=other.Agent)", + "saved = slots.update(Agent=fake.Agent)", "consume(slots.update(Agent=fake.Agent))", + "replace = slots.update\nreplace(Agent=fake.Agent)", + "dict.update(slots, Agent=fake.Agent)", "builtins.dict.update(slots, Agent=fake.Agent)", + "if condition:\n slots.update(Agent=fake.Agent)", + "slots.update(Agent=fake.Agent)\nsaved = slots", + "slots.update(Agent=fake.Agent)\nslots.update(Agent=fake.Agent)", +]) +def test_saved_source_mapping_update_refuses_other_payloads_methods_and_uses(namespace_workspace, projection, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = {projection}\n{write}") + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.Assign | ast.Expr): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["slots.update(Agent=fake.Agent)", "slots.update({'Agent': fake.Agent})"]) +@pytest.mark.parametrize("consumer", ["from entry import slots\n", "from entry import fake\n", "import entry\n"]) +def test_saved_source_mapping_update_checks_completed_mapping_and_source_imports(namespace_workspace, write, consumer): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}") + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["slots.update(Agent=fake.Agent)", "slots.update({'Agent': fake.Agent})"]) +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "saved = fake.Agent\n", "__all__ = ['slots']\n"]) +def test_saved_source_mapping_update_preserves_calls_values_and_exports(namespace_workspace, write, extra): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}", extra=extra) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["slots.update(Agent=fake.Agent)", "slots.update({'Agent': fake.Agent})"]) +def test_saved_source_mapping_update_rechecks_source_and_preserves_reentry(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}") + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + assert calls.idempotent_source_slot(module, statement, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.idempotent_source_slot(module, statement, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["slots.update(Agent=fake.Agent)", "slots.update({'Agent': fake.Agent})"]) +def test_saved_source_mapping_update_cannot_borrow_receiving_owner_callbacks(namespace_workspace, monkeypatch, write): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}") + def forbidden(*args, **kwargs): + raise AssertionError("a raw saved mapping update borrowed a receiving-owner callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + + +@pytest.mark.parametrize("write", ["slots.update(update=fake.update)", "slots.update({'update': fake.update})"]) +@pytest.mark.parametrize("called", [False, True]) +def test_saved_source_mapping_update_keeps_a_same_named_function_distinct_from_the_dictionary_method(namespace_workspace, write, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = vars(fake)\n{write}", + extra="fake.update([])\n" if called else "", home="def update(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[2] + home, function, _ = calls._source_slot_candidate(module, statement) + before = calls.callers(home, function, allow_empty=True) + assert before.limits + assert calls.idempotent_source_slot(module, statement, "agents") is (not called) + assert calls.uncalled_source_slot(home, function) is (not called) + assert calls.callers(home, function, allow_empty=True) is before + assert bool(before.sites) is called + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_saved_source_mapping_ior_proves_actual_augmented_binding_and_separate_function_role(namespace_workspace, projection, family): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = {projection}\nslots |= {'{'}'Agent': fake.Agent{'}'}", + ) + declaration, statement = module.tree.body[1:] + calls = BuilderCalls(resolver) + edge = calls._source_slot_edge(module, statement) + mapping = edge.parts[5] + assert isinstance(statement.op, ast.BitOr) and isinstance(statement.target.ctx, ast.Store) + assert edge.target is statement.target and mapping.receiver is statement.target + assert edge.payload is statement.value and edge.rhs is statement.value.values[0] + assert edge.parts[4] is statement.value.keys[0] + assert edge.call is None and edge.callee is None + assert mapping.declaration is declaration and mapping.target is declaration.targets[0] + assert mapping.projection is declaration.value + rows = module.bindings['slots'] + assert len(rows) == 2 + assert next(row for row in rows if row.statement is declaration).top_level + assert not next(row for row in rows if row.statement is statement).top_level + assert calls.scopes(module).parents[statement] is module.tree + home, function, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + assert not calls.idempotent_source_slot(module, declaration, family) + assert not calls.idempotent_source_slot(module, edge.payload, family) + nodes = [statement, edge.target, edge.rhs, declaration.value, mapping.namespace] + if isinstance(declaration.value, ast.Call): + nodes.append(declaration.value.func) + for node in nodes: + assert calls.idempotent_source_slot(module, node, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert not calls._confined_dictionary_data(module).values + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)"]) +@pytest.mark.parametrize("write", [ + "slots += {'Agent': fake.Agent}", "slots &= {'Agent': fake.Agent}", + "slots |= {'Agent': fake.Agent, 'Agent': fake.Agent}", + "slots |= {'Agent': fake.Agent, 'other': fake.Agent}", + "slots |= {key: fake.Agent}", "slots |= {**opaque, 'Agent': fake.Agent}", + "slots |= {'other': fake.Agent}", "slots |= {'Agent': other.Agent}", + "slots |= mapping", "slots |= {'Agent': fake}", + "slots |= {'Agent': fake.Agent}\nslots |= {'Agent': fake.Agent}", + "slots |= {'Agent': fake.Agent}\nslots.update(Agent=fake.Agent)", + "slots |= {'Agent': fake.Agent}\nslots['Agent'] = fake.Agent", + "slots |= {'Agent': fake.Agent}\nsaved = slots", + "slots |= {'Agent': fake.Agent}\ndel slots", + "slots |= {'Agent': fake.Agent}\nslots = {}", + "slots |= {'Agent': fake.Agent}\ndef capture():\n return slots", + "slots |= {'Agent': fake.Agent}\ndef capture(value=slots):\n pass", + "slots |= {'Agent': fake.Agent}\n__all__ = ['slots']", + "if condition:\n slots |= {'Agent': fake.Agent}", + "def touch():\n global slots\n slots |= {'Agent': fake.Agent}", +]) +def test_saved_source_mapping_ior_refuses_competing_uses_payloads_and_nested_targets(namespace_workspace, projection, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = {projection}\n{write}") + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.AugAssign): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "slots: object = vars(fake)\nslots |= {'Agent': fake.Agent}", + "other = fake\nslots = vars(other)\nslots |= {'Agent': fake.Agent}", + "slots = vars(fake)\nother = slots\nother |= {'Agent': fake.Agent}", + "slots = getattr(fake, '__dict__', {})\nslots |= {'Agent': fake.Agent}", + "slots = dict(vars(fake))\nslots |= {'Agent': fake.Agent}", + "slots = builtins.vars(fake)\nslots |= {'Agent': fake.Agent}", + "vars = replacement\nslots = vars(fake)\nslots |= {'Agent': fake.Agent}", + "slots = other = vars(fake)\nslots |= {'Agent': fake.Agent}", + "def touch():\n slots = vars(fake)\n slots |= {'Agent': fake.Agent}", +]) +def test_saved_source_mapping_ior_refuses_other_declarations_and_projection_routes(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.AugAssign): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)"]) +@pytest.mark.parametrize("consumer", ["from entry import slots\n", "from entry import fake\n", "import entry\n"]) +def test_saved_source_mapping_ior_checks_completed_mapping_and_namespace_imports(namespace_workspace, projection, consumer): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = {projection}\nslots |= {'{'}'Agent': fake.Agent{'}'}") + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("called", [False, True]) +def test_saved_source_mapping_ior_keeps_a_function_named_update_subject_to_real_calls(namespace_workspace, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\nslots |= {'update': fake.update}", + extra="fake.update([])\n" if called else "", home="def update(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[2] + home, function, _ = calls._source_slot_candidate(module, statement) + before = calls.callers(home, function, allow_empty=True) + assert calls.idempotent_source_slot(module, statement, "agents") is (not called) + assert calls.uncalled_source_slot(home, function) is (not called) + assert calls.callers(home, function, allow_empty=True) is before + assert bool(before.sites) is called + + +def test_saved_source_mapping_ior_rejects_a_counterfeit_load_target(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write="slots = vars(fake)\nslots |= {'Agent': fake.Agent}") + statement = module.tree.body[-1] + statement.target.ctx = ast.Load() + assert BuilderCalls(resolver)._source_slot_augmented_mapping(module, statement) is None + + +def test_saved_source_mapping_ior_rechecks_source_and_preserves_reentry(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write="slots = vars(fake)\nslots |= {'Agent': fake.Agent}") + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + assert calls.idempotent_source_slot(module, statement, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.idempotent_source_slot(module, statement, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + module.path.write_text("import fake\nslots = replacement\nslots |= {'Agent': fake.Agent}\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +_SAVED_SOURCE_GETATTR_WRITES = ( + "slots['Agent'] = fake.Agent", + "slots.update(Agent=fake.Agent)", + "slots.update({'Agent': fake.Agent})", + "slots |= {'Agent': fake.Agent}", +) + + +@pytest.mark.parametrize("write", _SAVED_SOURCE_GETATTR_WRITES) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_saved_source_getattr_mapping_keeps_metadata_distinct_from_slot_key_and_function(namespace_workspace, write, family): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = getattr(fake, '__dict__')\n{write}", + ) + declaration, statement = module.tree.body[1:] + calls = BuilderCalls(resolver) + edge = calls._source_slot_edge(module, statement) + projection = edge.parts[3] + metadata = projection.args[1] + assert calls._source_slot_projection_metadata(projection) is metadata + assert metadata is not edge.parts[4] and metadata is not edge.rhs + assert edge.parts[5].declaration is declaration + assert calls._source_slot_projection_metadata(ast.Constant('__dict__')) is None + assert calls._source_slot_candidate(module, ast.Constant('__dict__')) is None + home, function, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + assert not calls.idempotent_source_slot(module, declaration, family) + for node in (statement, projection, projection.func, projection.args[0], metadata, edge.rhs, edge.parts[5].receiver): + assert calls.idempotent_source_slot(module, node, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", _SAVED_SOURCE_GETATTR_WRITES) +@pytest.mark.parametrize("getter", [ + "getattr(fake, '__dict__', {})", "getattr(fake, name='__dict__')", + "getattr(*[fake, '__dict__'])", "getattr(fake, field)", "getattr(fake, 'other')", + "builtins.getattr(fake, '__dict__')", "replace(fake, '__dict__')", + "getattr(fake, '__dict__').copy()", "getattr(fake.Agent, '__dict__')", +]) +def test_saved_source_getattr_mapping_refuses_other_getter_shapes(namespace_workspace, write, getter): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = {getter}\n{write}") + calls = BuilderCalls(resolver) + assert calls._source_slot_candidate(module, module.tree.body[-1]) is None + assert not calls.idempotent_source_slot(module, module.tree.body[-1], "agents") + + +@pytest.mark.parametrize("write", _SAVED_SOURCE_GETATTR_WRITES) +@pytest.mark.parametrize("extra", [ + "getattr = replacement\n", "saved = slots\n", "slots = {}\n", "del slots\n", + "def capture():\n return slots\n", "def capture(value=slots):\n pass\n", + "__all__ = ['slots']\n", "slots['Agent'] = fake.Agent\n", "fake.Agent([])\n", +]) +def test_saved_source_getattr_mapping_preserves_shadowing_capture_export_and_actual_call_checks(namespace_workspace, write, extra): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = getattr(fake, '__dict__')\n{write}", extra=extra) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", _SAVED_SOURCE_GETATTR_WRITES) +@pytest.mark.parametrize("consumer", ["from entry import slots\n", "from entry import fake\n", "import entry\n"]) +def test_saved_source_getattr_mapping_checks_complete_context_imports(namespace_workspace, write, consumer): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = getattr(fake, '__dict__')\n{write}") + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", _SAVED_SOURCE_GETATTR_WRITES) +@pytest.mark.parametrize("mutation", [ + "import builtins\nbuiltins.getattr = replacement\n", + "import builtins as b\nb.getattr = replacement\n", + "import builtins\nsetattr(builtins, 'getattr', replacement)\n", + "import sys\nsys.modules.clear()\n", "__getattr__ = replacement\n", +]) +def test_saved_source_getattr_mapping_retains_raw_getter_hook_and_table_stability(namespace_workspace, write, mutation): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = getattr(fake, '__dict__')\n{write}") + (namespace_workspace / "quiet.py").write_text(mutation) + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", _SAVED_SOURCE_GETATTR_WRITES) +def test_saved_source_getattr_mapping_rechecks_source_and_preserves_reentry(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = getattr(fake, '__dict__')\n{write}") + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + assert calls.idempotent_source_slot(module, statement, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.idempotent_source_slot(module, statement, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + module.path.write_text(f"import fake\nslots = replacement\n{write}\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", _SAVED_SOURCE_GETATTR_WRITES) +@pytest.mark.parametrize("called", [False, True]) +def test_saved_source_getattr_mapping_keeps_function_update_distinct_from_method_and_metadata(namespace_workspace, write, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = getattr(fake, '__dict__')\n" + write.replace('Agent', 'update'), + extra="fake.update([])\n" if called else "", home="def update(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[2] + home, function, _ = calls._source_slot_candidate(module, statement) + before = calls.callers(home, function, allow_empty=True) + assert calls.idempotent_source_slot(module, statement, "agents") is (not called) + assert calls.uncalled_source_slot(home, function) is (not called) + assert calls.callers(home, function, allow_empty=True) is before + assert bool(before.sites) is called + + +@pytest.mark.parametrize("write", _SAVED_SOURCE_GETATTR_WRITES) +def test_saved_source_getattr_mapping_cannot_borrow_receiving_owner_callbacks(namespace_workspace, monkeypatch, write): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = getattr(fake, '__dict__')\n{write}") + def forbidden(*args, **kwargs): + raise AssertionError("a raw getattr mapping borrowed a receiving-owner callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + + +@pytest.mark.parametrize("field", ["vars", "dict", "getattr", "setattr"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_operator_metadata_write_requires_exact_completed_target_and_uncached_source_proof(namespace_workspace, field, family): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"import operator\noperator.{field} = None\nslots = vars(fake)\nslots['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + metadata = calls._operator_metadata_write(module, module.tree.body[2]) + assert metadata.target is metadata.statement.targets[0] + assert metadata.imported is module.tree.body[1].names[0] + assert metadata.marker == f'operator.{field}' + home, function, _ = calls._source_slot_candidate(module, module.tree.body[-1]) + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + with pytest.raises(CallLimit): + BuilderCalls(resolver).namespace_source_context(family) + assert calls.operator_metadata_write(module, metadata.target, family) + assert calls.idempotent_source_slot(module, module.tree.body[-1], family, + required_operator_metadata=(module, metadata.target)) + assert not calls.idempotent_source_slot(module, module.tree.body[-1], family, + required_operator_metadata=(home, metadata.target)) + clone = ast.parse(f'operator.{field} = None').body[0].targets[0] + assert not calls.operator_metadata_write(module, clone, family) + assert not calls.idempotent_source_slot(module, module.tree.body[-1], family, + required_operator_metadata=(module, clone)) + assert not calls.operator_metadata_write(module, metadata.statement, family) + assert not calls.operator_metadata_write(module, metadata.receiver, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "import operator\noperator.vars = None\noperator.vars = None", + "import operator\noperator.vars = None\nimport operator as op\nop.vars = None", + "import operator\noperator.vars = None\nother = operator", + "import operator as op\nop.vars = None", "from operator import vars\nvars = None", + "import operator\noperator.vars = replacement", "import operator\noperator.vars = fake.Agent", + "import operator\noperator.vars += None", "import operator\ndel operator.vars", + "import operator\nif condition:\n operator.vars = None", + "import operator\ndef touch():\n operator.vars = None", + "import operator\noperator.vars = None\nconsumer(operator)", + "import operator\noperator.vars = None\ndef capture():\n return operator", + "import operator\noperator.vars = None\ndef capture(value=operator):\n pass", + "import operator\noperator.vars = None\n__all__ = ['operator']", + "import operator\noperator = fake\noperator.vars = None", + "import operator\noperator.vars = None\nsetattr(operator, 'vars', None)", + "import operator\noperator.vars = None\noperator.__dict__['vars'] = None", +]) +def test_operator_metadata_write_refuses_other_shapes_and_namespace_uses(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=write + "\nslots = vars(fake)\nslots['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + for node in ast.walk(module.tree): + if isinstance(node, ast.Attribute) and node.attr == 'vars': + try: + assert not calls.operator_metadata_write(module, node, 'agents') + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("provider", ["module", "package", "case", "linked"]) +def test_operator_metadata_write_requires_absence_of_repository_providers(namespace_workspace, provider): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import operator\noperator.vars = None\nslots = vars(fake)\nslots['Agent'] = fake.Agent", + ) + if provider == 'package': + (namespace_workspace / 'operator').mkdir() + (namespace_workspace / 'operator/__init__.py').write_text('VALUE = None\n') + elif provider == 'linked': + (namespace_workspace / 'operator.py').symlink_to('fake.py') + else: + (namespace_workspace / ('Operator.py' if provider == 'case' else 'operator.py')).write_text('VALUE = None\n') + source = module.path.read_text() + current = ImportResolver(namespace_workspace) + actual = current.entry(module.path, ast.parse(source), source) + try: + assert not BuilderCalls(current).operator_metadata_write(actual, actual.tree.body[2].targets[0], 'agents') + except CallLimit: + pass + + +def test_operator_metadata_write_case_variant_provider_blocks_on_a_case_sensitive_host(namespace_workspace, monkeypatch): + """The absence proof reads the exact listing, not what this host's filesystem finds. + + ``Operator.py`` is found as ``operator.py`` by a case-insensitive filesystem + and not by a case-sensitive one. The probe below makes any host answer as + the second does; the verdict must be the same as on the first. + """ + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import operator\noperator.vars = None\nslots = vars(fake)\nslots['Agent'] = fake.Agent", + ) + (namespace_workspace / 'Operator.py').write_text('VALUE = None\n') + real_lstat = Path.lstat + + def case_sensitive_lstat(self, *args, **kwargs): + if self.name in {'operator', 'operator.py'}: + raise FileNotFoundError(self) + return real_lstat(self, *args, **kwargs) + + monkeypatch.setattr(Path, 'lstat', case_sensitive_lstat) + source = module.path.read_text() + current = ImportResolver(namespace_workspace) + actual = current.entry(module.path, ast.parse(source), source) + try: + assert not BuilderCalls(current).operator_metadata_write(actual, actual.tree.body[2].targets[0], 'agents') + except CallLimit: + pass + + +@pytest.mark.parametrize("field", ["vars", "dict", "getattr", "setattr"]) +@pytest.mark.parametrize("consumer", ["from entry import operator\n", "import entry\n"]) +def test_operator_metadata_write_checks_even_unused_importers(namespace_workspace, field, consumer): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"import operator\noperator.{field} = None\nfake.Agent = fake.Agent", + ) + (namespace_workspace / 'consumer.py').write_text(consumer) + with pytest.raises(CallLimit, match='imported operator metadata namespace'): + BuilderCalls(resolver).operator_metadata_write(module, module.tree.body[2].targets[0], 'agents') + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("field", ["vars", "dict", "getattr", "setattr"]) +@pytest.mark.parametrize("injected", ['unknown', 'copied', 'missing']) +def test_operator_metadata_write_refuses_incomplete_or_nonunique_raw_producers(namespace_workspace, monkeypatch, field, injected): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"import operator\noperator.{field} = None\nslots = vars(fake)\nslots['Agent'] = fake.Agent", + ) + target = module.tree.body[2].targets[0] + original = list_expressions._qualified_attribute_patches + def traced(view, **kwargs): + patches = original(view, **kwargs) + producers = kwargs.get('patch_producers') + if view.module is module and producers is not None: + marker = f'operator.{field}' + if injected == 'missing': + producers.pop(marker, None) + else: + extra = None if injected == 'unknown' else ast.parse(f'operator.{field} = None').body[0].targets[0] + producers.setdefault(marker, set()).add(extra) + return patches + monkeypatch.setattr(list_expressions, '_qualified_attribute_patches', traced) + try: + assert not BuilderCalls(resolver).operator_metadata_write(module, target, 'agents') + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("field", ["vars", "dict", "getattr", "setattr"]) +def test_operator_metadata_write_rechecks_currency_and_reentry(namespace_workspace, field): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"import operator\noperator.{field} = None\nslots = vars(fake)\nslots['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + target = module.tree.body[2].targets[0] + assert calls.operator_metadata_write(module, target, 'agents') + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match='reentered'): + calls.operator_metadata_write(module, target, 'agents') + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + (namespace_workspace / 'operator.py').write_text('VALUE = None\n') + with pytest.raises(CallLimit): + calls.operator_metadata_write(module, target, 'agents') + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "import operator\noperator.vars = None", "import operator\ndel operator.vars", + "import operator\noperator.__dict__['vars'] = None", + "import operator\nslots = vars(operator)\nslots |= {'vars': None}", + "import operator\nslots = vars(operator)\nslots.update(vars=None)", + "import operator\nslots = vars(operator)\nsaved = slots.update", + "import operator\ndict.update(vars(operator), {'vars': None})", + "import operator\nsetattr(operator, 'vars', None)", + "import operator\noperator.ior(vars(operator), {'vars': None})", + "setattr(*opaque)", "import operator\noperator.ior(*opaque)", + "import builtins\nbuiltins.vars = None\nslots = vars(fake)\nslots['vars'] = None", +]) +def test_raw_patch_producer_evidence_preserves_default_sets_and_primitive_flags(namespace_workspace, write): + from agents_shipgate.inputs.list_expressions import ( + _qualified_attribute_patches, + _View, + bindings_at, + ) + + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + def view(): + value = _View(module.ref, module.tree, BuilderCalls(resolver).scopes(module), module.bindings, module, set()) + value.lookup = bindings_at(value.scopes, value.bindings) + value.resolver = resolver + return value + ordinary, observed = view(), view() + expected = _qualified_attribute_patches(ordinary, raw_namespaces=True) + producers = {} + actual = _qualified_attribute_patches(observed, raw_namespaces=True, patch_producers=producers) + assert actual == expected + assert observed.namespace_builtins_changed == ordinary.namespace_builtins_changed + assert all(producers.get(marker) for marker in actual) + nodes = set(ast.walk(module.tree)) + assert all(node is None or node in nodes for values in producers.values() for node in values) + assert all(node is None or isinstance(node, ast.Attribute | ast.Subscript | ast.AugAssign | ast.Call) + for values in producers.values() for node in values) + + +def test_raw_patch_producer_evidence_marks_fixed_point_exhaustion_as_unknown(namespace_workspace): + from agents_shipgate.inputs.list_expressions import ( + _NAMESPACE_PRIMITIVES, + _UNREAD_NAMESPACE_ALIAS, + _qualified_attribute_patches, + _View, + bindings_at, + ) + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write='import builtins\nbuiltins.vars = None') + view = _View(module.ref, module.tree, BuilderCalls(resolver).scopes(module), module.bindings, module, set()) + view.lookup = bindings_at(view.scopes, view.bindings) + view.resolver = resolver + producers = {} + patches = _qualified_attribute_patches(view, raw_namespaces=True, + primitive_pass=len(_NAMESPACE_PRIMITIVES), patch_producers=producers) + assert _UNREAD_NAMESPACE_ALIAS in patches + assert producers[_UNREAD_NAMESPACE_ALIAS] == {None} + + +@pytest.mark.parametrize("field", ["vars", "dict", "getattr", "setattr"]) +@pytest.mark.parametrize("operation", ["metadata_target", "source_slot"]) +@pytest.mark.parametrize("tree_backed", [False, True]) +def test_operator_metadata_requires_bound_disk_currency(namespace_workspace, monkeypatch, field, operation, tree_backed): + from contextlib import nullcontext + + from agents_shipgate.inputs.python_imports import RepositoryLayout, repository_layout + + files = { + "entry.py": f"import fake\nimport operator\noperator.{field} = None\nfake.Agent = fake.Agent\n", + "fake.py": "def Agent(tools):\n return tools\n", + } + for name, source in files.items(): + (namespace_workspace / name).write_text(source) + layout = RepositoryLayout( + "", lambda ref: frozenset(files) if ref == "" else None, read=lambda ref: files.get(ref), + ) + with repository_layout(layout) if tree_backed else nullcontext(): + resolver = ImportResolver(namespace_workspace) + source = files["entry.py"] + module = resolver.entry(namespace_workspace / "entry.py", ast.parse(source), source) + calls = BuilderCalls(resolver) + target = module.tree.body[2].targets[0] + terminal = module.tree.body[3] + if tree_backed: + assert resolver._layout.disk_root is None + + def forbidden_disk_capture(*args): + pytest.fail("tree metadata proof must refuse before capturing live disk currency") + + monkeypatch.setattr(calls, "_namespace_directory_entries", forbidden_disk_capture) + with pytest.raises(CallLimit): + calls.namespace_source_context("agents") + if tree_backed: + with pytest.raises(CallLimit, match="no bound import-root currency for the operator metadata import"): + if operation == "metadata_target": + calls.operator_metadata_write(module, target, "agents") + else: + calls.idempotent_source_slot(module, terminal, "agents") + elif operation == "metadata_target": + assert calls.operator_metadata_write(module, target, "agents") + else: + assert calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)", "getattr(fake, '__dict__')"]) +@pytest.mark.parametrize("write", ["slots.__init__(Agent=fake.Agent)", "slots.__init__({'Agent': fake.Agent})"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_saved_source_mapping_init_separates_function_value_from_mutation_syntax(namespace_workspace, projection, write, family): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = {projection}\n{write}") + declaration, statement = module.tree.body[1:] + calls = BuilderCalls(resolver) + edge = calls._source_slot_edge(module, statement) + assert edge.statement is statement and edge.call is statement.value + assert edge.callee is statement.value.func + assert edge.parts[5].receiver is edge.callee.value + assert edge.parts[5].projection is declaration.value + if isinstance(edge.payload, ast.keyword): + assert edge.rhs is edge.payload.value + assert edge.parts[4] is None + else: + assert edge.rhs is edge.payload.values[0] + assert edge.parts[4] is edge.payload.keys[0] + home, function, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + assert not calls.idempotent_source_slot(module, edge.payload, family) + assert not calls.idempotent_source_slot(module, declaration, family) + nodes = [statement, edge.call, edge.callee, edge.parts[5].receiver, edge.rhs, declaration.value, edge.parts[0]] + if isinstance(declaration.value, ast.Call): + nodes.append(declaration.value.func) + for node in nodes: + assert calls.idempotent_source_slot(module, node, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)", "getattr(fake, '__dict__')"]) +@pytest.mark.parametrize("write", [ + "slots.__init__(**{'Agent': fake.Agent})", "slots.__init__(*[{'Agent': fake.Agent}])", + "slots.__init__(Agent=fake.Agent, other=fake.Agent)", + "slots.__init__({'Agent': fake.Agent}, {'Agent': fake.Agent})", + "slots.__init__({'Agent': fake.Agent}, Agent=fake.Agent)", + "slots.__init__({'Agent': fake.Agent, 'Agent': fake.Agent})", + "slots.__init__({key: fake.Agent})", "slots.__init__({**opaque, 'Agent': fake.Agent})", + "slots.__init__(mapping)", "slots.__init__(other=fake.Agent)", + "slots.__init__({'other': fake.Agent})", "slots.__init__(Agent=other.Agent)", + "saved = slots.__init__(Agent=fake.Agent)", "consume(slots.__init__(Agent=fake.Agent))", + "replace = slots.__init__\nreplace(Agent=fake.Agent)", + "dict.__init__(slots, Agent=fake.Agent, other=fake.Agent)", "builtins.dict.__init__(slots, Agent=fake.Agent)", + "if condition:\n slots.__init__(Agent=fake.Agent)", + "slots.__init__(Agent=fake.Agent)\nsaved = slots", + "slots.__init__(Agent=fake.Agent)\nslots.__init__(Agent=fake.Agent)", +]) +def test_saved_source_mapping_init_refuses_other_payloads_methods_and_uses(namespace_workspace, projection, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = {projection}\n{write}") + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.Assign | ast.Expr): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["slots.__init__(Agent=fake.Agent)", "slots.__init__({'Agent': fake.Agent})"]) +@pytest.mark.parametrize("consumer", ["from entry import slots\n", "from entry import fake\n", "import entry\n"]) +def test_saved_source_mapping_init_checks_completed_mapping_and_source_imports(namespace_workspace, write, consumer): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}") + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["slots.__init__(Agent=fake.Agent)", "slots.__init__({'Agent': fake.Agent})"]) +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "saved = fake.Agent\n", "__all__ = ['slots']\n"]) +def test_saved_source_mapping_init_preserves_calls_values_and_exports(namespace_workspace, write, extra): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}", extra=extra) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["slots.__init__(Agent=fake.Agent)", "slots.__init__({'Agent': fake.Agent})"]) +def test_saved_source_mapping_init_rechecks_source_and_preserves_reentry(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}") + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + assert calls.idempotent_source_slot(module, statement, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.idempotent_source_slot(module, statement, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["slots.__init__(Agent=fake.Agent)", "slots.__init__({'Agent': fake.Agent})"]) +def test_saved_source_mapping_init_cannot_borrow_receiving_owner_callbacks(namespace_workspace, monkeypatch, write): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}") + def forbidden(*args, **kwargs): + raise AssertionError("a raw saved mapping update borrowed a receiving-owner callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + + +@pytest.mark.parametrize("write", ["slots.__init__(update=fake.update)", "slots.__init__({'update': fake.update})"]) +@pytest.mark.parametrize("called", [False, True]) +def test_saved_source_mapping_init_keeps_a_same_named_function_distinct_from_the_dictionary_method(namespace_workspace, write, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = vars(fake)\n{write}", + extra="fake.update([])\n" if called else "", home="def update(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[2] + home, function, _ = calls._source_slot_candidate(module, statement) + before = calls.callers(home, function, allow_empty=True) + assert before.limits + assert calls.idempotent_source_slot(module, statement, "agents") is (not called) + assert calls.uncalled_source_slot(home, function) is (not called) + assert calls.callers(home, function, allow_empty=True) is before + assert bool(before.sites) is called + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)", "getattr(fake, '__dict__')"]) +@pytest.mark.parametrize("write", ["dict.__init__(slots, Agent=fake.Agent)", "dict.__init__(slots, {'Agent': fake.Agent})"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_source_unbound_mapping_init_separates_function_value_from_mutation_syntax(namespace_workspace, projection, write, family): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = {projection}\n{write}") + declaration, statement = module.tree.body[1:] + calls = BuilderCalls(resolver) + edge = calls._source_slot_edge(module, statement) + assert edge.statement is statement and edge.call is statement.value + assert edge.callee is statement.value.func + assert edge.parts[5].receiver is edge.call.args[0] + assert edge.primitive is edge.callee.value and edge.primitive.id == "dict" + assert edge.target is edge.parts[5].receiver + assert edge.parts[5].projection is declaration.value + if isinstance(edge.payload, ast.keyword): + assert edge.rhs is edge.payload.value + assert edge.parts[4] is None + else: + assert edge.rhs is edge.payload.values[0] + assert edge.parts[4] is edge.payload.keys[0] + home, function, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + assert not calls.idempotent_source_slot(module, edge.payload, family) + assert not calls.idempotent_source_slot(module, declaration, family) + nodes = [edge.primitive, statement, edge.call, edge.callee, edge.parts[5].receiver, edge.rhs, declaration.value, edge.parts[0]] + if isinstance(declaration.value, ast.Call): + nodes.append(declaration.value.func) + for node in nodes: + assert calls.idempotent_source_slot(module, node, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)", "getattr(fake, '__dict__')"]) +@pytest.mark.parametrize("write", [ + "dict.__init__(slots, **{'Agent': fake.Agent})", "dict.__init__(slots, *[{'Agent': fake.Agent}])", + "dict.__init__(slots, Agent=fake.Agent, other=fake.Agent)", + "dict.__init__(slots, {'Agent': fake.Agent}, {'Agent': fake.Agent})", + "dict.__init__(slots, {'Agent': fake.Agent}, Agent=fake.Agent)", + "dict.__init__(slots, {'Agent': fake.Agent, 'Agent': fake.Agent})", + "dict.__init__(slots, {key: fake.Agent})", "dict.__init__(slots, {**opaque, 'Agent': fake.Agent})", + "dict.__init__(slots, mapping)", "dict.__init__(slots, other=fake.Agent)", + "dict.__init__(slots, {'other': fake.Agent})", "dict.__init__(slots, Agent=other.Agent)", + "saved = dict.__init__(slots, Agent=fake.Agent)", "consume(dict.__init__(slots, Agent=fake.Agent))", + "replace = slots.__init__\nreplace(Agent=fake.Agent)", + "dict.update(slots, Agent=fake.Agent)", "builtins.dict.__init__(slots, Agent=fake.Agent)", + "if condition:\n dict.__init__(slots, Agent=fake.Agent)", + "dict.__init__(slots, Agent=fake.Agent)\nsaved = slots", + "dict.__init__(slots, Agent=fake.Agent)\ndict.__init__(slots, Agent=fake.Agent)", +]) +def test_source_unbound_mapping_init_refuses_other_payloads_methods_and_uses(namespace_workspace, projection, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = {projection}\n{write}") + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.Assign | ast.Expr): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["dict.__init__(slots, Agent=fake.Agent)", "dict.__init__(slots, {'Agent': fake.Agent})"]) +@pytest.mark.parametrize("consumer", ["from entry import slots\n", "from entry import fake\n", "import entry\n"]) +def test_source_unbound_mapping_init_checks_completed_mapping_and_source_imports(namespace_workspace, write, consumer): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}") + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["dict.__init__(slots, Agent=fake.Agent)", "dict.__init__(slots, {'Agent': fake.Agent})"]) +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "saved = fake.Agent\n", "__all__ = ['slots']\n"]) +def test_source_unbound_mapping_init_preserves_calls_values_and_exports(namespace_workspace, write, extra): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}", extra=extra) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["dict.__init__(slots, Agent=fake.Agent)", "dict.__init__(slots, {'Agent': fake.Agent})"]) +def test_source_unbound_mapping_init_rechecks_source_and_preserves_reentry(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}") + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + assert calls.idempotent_source_slot(module, statement, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.idempotent_source_slot(module, statement, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["dict.__init__(slots, Agent=fake.Agent)", "dict.__init__(slots, {'Agent': fake.Agent})"]) +def test_source_unbound_mapping_init_cannot_borrow_receiving_owner_callbacks(namespace_workspace, monkeypatch, write): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = vars(fake)\n{write}") + def forbidden(*args, **kwargs): + raise AssertionError("a raw saved mapping update borrowed a receiving-owner callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + + +@pytest.mark.parametrize("write", ["dict.__init__(slots, update=fake.update)", "dict.__init__(slots, {'update': fake.update})"]) +@pytest.mark.parametrize("called", [False, True]) +def test_source_unbound_mapping_init_keeps_a_same_named_function_distinct_from_the_dictionary_method(namespace_workspace, write, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = vars(fake)\n{write}", + extra="fake.update([])\n" if called else "", home="def update(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[2] + home, function, _ = calls._source_slot_candidate(module, statement) + before = calls.callers(home, function, allow_empty=True) + assert before.limits + assert calls.idempotent_source_slot(module, statement, "agents") is (not called) + assert calls.uncalled_source_slot(home, function) is (not called) + assert calls.callers(home, function, allow_empty=True) is before + assert bool(before.sites) is called + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("called", [False, True]) +def test_source_unbound_mapping_init_keeps_builtin_callee_distinct_from_function_dict(namespace_workspace, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\ndict.__init__(slots, dict=fake.dict)", + extra="fake.dict([])\n" if called else "", home="def dict(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + edge = calls._source_slot_edge(module, terminal) + assert edge.primitive is terminal.value.func.value + assert edge.rhs is terminal.value.keywords[0].value + assert edge.primitive is not edge.rhs.value + assert calls.idempotent_source_slot(module, terminal, "agents") is (not called) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("field", ["Agent", "dict"]) +def test_source_unbound_mapping_init_requires_raw_canonical_primitive(namespace_workspace, monkeypatch, field): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = vars(fake)\ndict.__init__(slots, {field}=fake.{field})", + home=f"def {field}(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + assert calls.idempotent_source_slot(module, terminal, "agents") + original = list_expressions._namespace_call_reference + raw_original = list_expressions._qualified_attribute_patches + raw_active = False + + def raw(view, **kwargs): + nonlocal raw_active + previous = raw_active + raw_active = True + try: + return raw_original(view, **kwargs) + finally: + raw_active = previous + + def altered(view, call): + if call is terminal.value and not raw_active: + return "foreign.dict.__init__" + return original(view, call) + + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", raw) + monkeypatch.setattr(list_expressions, "_namespace_call_reference", altered) + with pytest.raises(CallLimit, match="unread dictionary initialization primitive"): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", [ + "dict = replacement\n", "from provider import dict\n", "import builtins\nbuiltins.dict = replacement\n", + "import builtins\nbuiltins.dict.__init__ = replacement\n", "fake.__class__ = replacement\n", + "import builtins\nsetattr(builtins, 'dict', replacement)\n", +]) +def test_source_unbound_mapping_init_preserves_local_and_remote_primitive_stability(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\ndict.__init__(slots, Agent=fake.Agent)", extra=extra, + ) + calls = BuilderCalls(resolver) + try: + assert not calls.idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("operation", ["metadata_target", "source_slot"]) +@pytest.mark.parametrize("disk_root_kind", ["disjoint", "nested"]) +def test_operator_metadata_refuses_invalid_disk_root_before_currency_capture(namespace_workspace, monkeypatch, operation, disk_root_kind): + from agents_shipgate.inputs.python_imports import RepositoryLayout, repository_layout + + files = { + "entry.py": "import fake\nimport operator\noperator.dict = None\nfake.Agent = fake.Agent\n", + "fake.py": "def Agent(tools):\n return tools\n", + } + for name, source in files.items(): + (namespace_workspace / name).write_text(source) + disk_root = (namespace_workspace.parent if disk_root_kind == "disjoint" else namespace_workspace) / "elsewhere" + layout = RepositoryLayout( + "", lambda ref: frozenset(files) if ref == "" else None, + read=lambda ref: files.get(ref), disk_root=disk_root, + ) + + def forbidden_disk_capture(*args): + pytest.fail("invalid disk ancestry must be refused before acquiring directory currency") + + monkeypatch.setattr(BuilderCalls, "_namespace_directory_entries", forbidden_disk_capture) + with repository_layout(layout): + resolver = ImportResolver(namespace_workspace) + source = files["entry.py"] + module = resolver.entry(namespace_workspace / "entry.py", ast.parse(source), source) + calls = BuilderCalls(resolver) + with pytest.raises(CallLimit, match="disk ancestry that does not match the read scope"): + if operation == "metadata_target": + calls.operator_metadata_write(module, module.tree.body[2].targets[0], "agents") + else: + calls.idempotent_source_slot(module, module.tree.body[3], "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)", "getattr(fake, '__dict__')"]) +@pytest.mark.parametrize("write", ["builtins.dict.__init__(slots, Agent=fake.Agent)", "builtins.dict.__init__(slots, {'Agent': fake.Agent})"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_source_qualified_mapping_init_separates_function_value_from_mutation_syntax(namespace_workspace, projection, write, family): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = {projection}\n{write}") + builtin_import, declaration, statement = module.tree.body[1:] + calls = BuilderCalls(resolver) + edge = calls._source_slot_edge(module, statement) + assert edge.statement is statement and edge.call is statement.value + assert edge.callee is statement.value.func + assert edge.parts[5].receiver is edge.call.args[0] + assert edge.primitive is edge.callee.value and edge.primitive.attr == "dict" + assert edge.primitive_import.statement is builtin_import + assert edge.primitive_import.root is edge.primitive.value + assert edge.target is edge.parts[5].receiver + assert edge.parts[5].projection is declaration.value + if isinstance(edge.payload, ast.keyword): + assert edge.rhs is edge.payload.value + assert edge.parts[4] is None + else: + assert edge.rhs is edge.payload.values[0] + assert edge.parts[4] is edge.payload.keys[0] + home, function, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + assert not calls.idempotent_source_slot(module, edge.payload, family) + assert not calls.idempotent_source_slot(module, declaration, family) + nodes = [edge.primitive_import.root, edge.primitive, statement, edge.call, edge.callee, edge.parts[5].receiver, edge.rhs, declaration.value, edge.parts[0]] + if isinstance(declaration.value, ast.Call): + nodes.append(declaration.value.func) + for node in nodes: + assert calls.idempotent_source_slot(module, node, family) + assert calls.source_primitive_import(module, builtin_import, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)", "getattr(fake, '__dict__')"]) +@pytest.mark.parametrize("write", [ + "builtins.dict.__init__(slots, **{'Agent': fake.Agent})", "builtins.dict.__init__(slots, *[{'Agent': fake.Agent}])", + "builtins.dict.__init__(slots, Agent=fake.Agent, other=fake.Agent)", + "builtins.dict.__init__(slots, {'Agent': fake.Agent}, {'Agent': fake.Agent})", + "builtins.dict.__init__(slots, {'Agent': fake.Agent}, Agent=fake.Agent)", + "builtins.dict.__init__(slots, {'Agent': fake.Agent, 'Agent': fake.Agent})", + "builtins.dict.__init__(slots, {key: fake.Agent})", "builtins.dict.__init__(slots, {**opaque, 'Agent': fake.Agent})", + "builtins.dict.__init__(slots, mapping)", "builtins.dict.__init__(slots, other=fake.Agent)", + "builtins.dict.__init__(slots, {'other': fake.Agent})", "builtins.dict.__init__(slots, Agent=other.Agent)", + "saved = builtins.dict.__init__(slots, Agent=fake.Agent)", "consume(builtins.dict.__init__(slots, Agent=fake.Agent))", + "replace = slots.__init__\nreplace(Agent=fake.Agent)", + "dict.update(slots, Agent=fake.Agent)", "builtins.dict.update(slots, Agent=fake.Agent, other=fake.Agent)", + "if condition:\n builtins.dict.__init__(slots, Agent=fake.Agent)", + "builtins.dict.__init__(slots, Agent=fake.Agent)\nsaved = slots", + "builtins.dict.__init__(slots, Agent=fake.Agent)\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", +]) +def test_source_qualified_mapping_init_refuses_other_payloads_methods_and_uses(namespace_workspace, projection, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = {projection}\n{write}") + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.Assign | ast.Expr): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["builtins.dict.__init__(slots, Agent=fake.Agent)", "builtins.dict.__init__(slots, {'Agent': fake.Agent})"]) +@pytest.mark.parametrize("consumer", ["from entry import slots\n", "from entry import fake\n", "import entry\n"]) +def test_source_qualified_mapping_init_checks_completed_mapping_and_source_imports(namespace_workspace, write, consumer): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = vars(fake)\n{write}") + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["builtins.dict.__init__(slots, Agent=fake.Agent)", "builtins.dict.__init__(slots, {'Agent': fake.Agent})"]) +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "saved = fake.Agent\n", "__all__ = ['slots']\n"]) +def test_source_qualified_mapping_init_preserves_calls_values_and_exports(namespace_workspace, write, extra): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = vars(fake)\n{write}", extra=extra) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["builtins.dict.__init__(slots, Agent=fake.Agent)", "builtins.dict.__init__(slots, {'Agent': fake.Agent})"]) +def test_source_qualified_mapping_init_rechecks_source_and_preserves_reentry(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = vars(fake)\n{write}") + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + assert calls.idempotent_source_slot(module, statement, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.idempotent_source_slot(module, statement, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["builtins.dict.__init__(slots, Agent=fake.Agent)", "builtins.dict.__init__(slots, {'Agent': fake.Agent})"]) +def test_source_qualified_mapping_init_cannot_borrow_receiving_owner_callbacks(namespace_workspace, monkeypatch, write): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = vars(fake)\n{write}") + def forbidden(*args, **kwargs): + raise AssertionError("a raw saved mapping update borrowed a receiving-owner callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + + +@pytest.mark.parametrize("write", ["builtins.dict.__init__(slots, update=fake.update)", "builtins.dict.__init__(slots, {'update': fake.update})"]) +@pytest.mark.parametrize("called", [False, True]) +def test_source_qualified_mapping_init_keeps_a_same_named_function_distinct_from_the_dictionary_method(namespace_workspace, write, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"import builtins\nslots = vars(fake)\n{write}", + extra="fake.update([])\n" if called else "", home="def update(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[3] + home, function, _ = calls._source_slot_candidate(module, statement) + before = calls.callers(home, function, allow_empty=True) + assert before.limits + assert calls.idempotent_source_slot(module, statement, "agents") is (not called) + assert calls.uncalled_source_slot(home, function) is (not called) + assert calls.callers(home, function, allow_empty=True) is before + assert bool(before.sites) is called + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("called", [False, True]) +def test_source_qualified_mapping_init_keeps_builtin_callee_distinct_from_function_dict(namespace_workspace, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, dict=fake.dict)", + extra="fake.dict([])\n" if called else "", home="def dict(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + edge = calls._source_slot_edge(module, terminal) + assert edge.primitive is terminal.value.func.value + assert edge.rhs is terminal.value.keywords[0].value + assert edge.primitive is not edge.rhs.value + assert calls.idempotent_source_slot(module, terminal, "agents") is (not called) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("field", ["Agent", "dict"]) +def test_source_qualified_mapping_init_requires_raw_canonical_primitive(namespace_workspace, monkeypatch, field): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, {field}=fake.{field})", + home=f"def {field}(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + assert calls.idempotent_source_slot(module, terminal, "agents") + original = list_expressions._namespace_call_reference + raw_original = list_expressions._qualified_attribute_patches + raw_active = False + + def raw(view, **kwargs): + nonlocal raw_active + previous = raw_active + raw_active = True + try: + return raw_original(view, **kwargs) + finally: + raw_active = previous + + def altered(view, call): + if call is terminal.value and not raw_active: + return "foreign.dict.__init__" + return original(view, call) + + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", raw) + monkeypatch.setattr(list_expressions, "_namespace_call_reference", altered) + with pytest.raises(CallLimit, match="unread dictionary initialization primitive"): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", [ + "builtins = replacement\n", "from provider import dict\n", "import builtins\nbuiltins.dict = replacement\n", + "import builtins\nbuiltins.dict.__init__ = replacement\n", "fake.__class__ = replacement\n", + "import builtins\nsetattr(builtins, 'dict', replacement)\n", +]) +def test_source_qualified_mapping_init_preserves_local_and_remote_primitive_stability(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", extra=extra, + ) + calls = BuilderCalls(resolver) + try: + assert not calls.idempotent_source_slot(module, module.tree.body[3], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_source_qualified_mapping_init_requires_actual_import_receipt(namespace_workspace, family): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + ) + calls = BuilderCalls(resolver) + declaration, terminal = module.tree.body[1], module.tree.body[3] + clone = ast.parse("import builtins").body[0] + assert calls.source_primitive_import(module, declaration, family) + assert calls.idempotent_source_slot(module, terminal, family, required_primitive_import=(module, declaration)) + assert not calls.source_primitive_import(module, clone, family) + assert not calls.source_primitive_import(module, module.tree.body[0], family) + assert not calls.idempotent_source_slot(module, terminal, family, required_primitive_import=(module, clone)) + assert not calls.idempotent_source_slot(module, declaration, family) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("imported", [ + "import builtins as b\n", "import builtins, other\n", "from builtins import dict\n", "", +]) +@pytest.mark.parametrize("extra", ["", "saved = builtins\n", "builtins.dict\n", "__all__ = ['builtins']\n"]) +def test_source_qualified_mapping_init_refuses_other_import_and_root_uses(namespace_workspace, imported, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=imported + "slots = vars(fake)\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", extra=extra, + ) + calls = BuilderCalls(resolver) + # Only a direct unaliased import with its sole actual callee use is eligible. + for statement in module.tree.body: + if isinstance(statement, ast.Import): + assert not calls.source_primitive_import(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("provider", ["module", "package", "case", "link"]) +def test_source_qualified_mapping_init_requires_absent_builtin_provider(namespace_workspace, provider): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + ) + if provider == "package": + (namespace_workspace / "builtins").mkdir() + (namespace_workspace / "builtins/__init__.py").write_text("VALUE = None\n") + elif provider == "link": + (namespace_workspace / "builtins.py").symlink_to("fake.py") + else: + (namespace_workspace / ("Builtins.py" if provider == "case" else "builtins.py")).write_text("VALUE = None\n") + try: + assert not BuilderCalls(resolver).source_primitive_import(module, module.tree.body[1], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +def test_source_qualified_mapping_init_raw_constructor_route_cannot_borrow_import_proof(namespace_workspace, monkeypatch): + from agents_shipgate.inputs.python_imports import _external_constructor_use + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + ) + + def forbidden(*args, **kwargs): + pytest.fail("raw constructor observation borrowed a completed primitive import proof") + + monkeypatch.setattr(BuilderCalls, "source_primitive_import", forbidden) + assert _external_constructor_use(resolver, module, "agents", {module.path}, allow_owner_routes=False) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", [ + "saved = builtins\n", "builtins.dict\n", "__all__ = ['builtins']\n", + "def capture(value=builtins):\n pass\n", "del builtins\n", "builtins = fake\n", +]) +def test_source_qualified_mapping_init_confines_actual_imported_root(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + extra=extra, + ) + calls = BuilderCalls(resolver) + assert not calls.source_primitive_import(module, module.tree.body[1], "agents") + assert calls._source_slot_candidate(module, module.tree.body[3]) is None + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("operation", ["import", "slot"]) +def test_source_qualified_mapping_init_tree_context_cannot_borrow_live_disk_root(namespace_workspace, monkeypatch, operation): + from agents_shipgate.inputs.python_imports import RepositoryLayout, repository_layout + + files = { + "entry.py": "import fake\nimport builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, Agent=fake.Agent)\n", + "fake.py": "def Agent(tools):\n return tools\n", + } + for name, source in files.items(): + (namespace_workspace / name).write_text(source) + layout = RepositoryLayout("", lambda ref: frozenset(files) if ref == "" else None, read=lambda ref: files.get(ref)) + + def forbidden(*args): + pytest.fail("a tree primitive proof borrowed live directory currency") + + monkeypatch.setattr(BuilderCalls, "_namespace_directory_entries", forbidden) + with repository_layout(layout): + resolver = ImportResolver(namespace_workspace) + source = files["entry.py"] + module = resolver.entry(namespace_workspace / "entry.py", ast.parse(source), source) + calls = BuilderCalls(resolver) + with pytest.raises(CallLimit, match="no bound import-root currency for the dictionary initialization import"): + if operation == "import": + calls.source_primitive_import(module, module.tree.body[1], "agents") + else: + calls.idempotent_source_slot(module, module.tree.body[3], "agents") + assert not resolver._checking_source_module_slot + + +def test_source_qualified_mapping_init_rechecks_provider_currency_and_import_reentry(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + ) + calls = BuilderCalls(resolver) + declaration = module.tree.body[1] + assert calls.source_primitive_import(module, declaration, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.source_primitive_import(module, declaration, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + (namespace_workspace / "builtins.py").write_text("VALUE = None\n") + with pytest.raises(CallLimit): + calls.source_primitive_import(module, declaration, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)", "getattr(fake, '__dict__')"]) +@pytest.mark.parametrize("write", ["builtins.dict.update(slots, Agent=fake.Agent)", "builtins.dict.update(slots, {'Agent': fake.Agent})"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_source_qualified_mapping_update_separates_function_value_from_mutation_syntax(namespace_workspace, projection, write, family): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = {projection}\n{write}") + builtin_import, declaration, statement = module.tree.body[1:] + calls = BuilderCalls(resolver) + edge = calls._source_slot_edge(module, statement) + assert edge.statement is statement and edge.call is statement.value + assert edge.callee is statement.value.func + assert edge.parts[5].receiver is edge.call.args[0] + assert edge.primitive is edge.callee.value and edge.primitive.attr == "dict" + assert edge.primitive_import.statement is builtin_import + assert edge.primitive_import.root is edge.primitive.value + assert edge.target is edge.parts[5].receiver + assert edge.parts[5].projection is declaration.value + if isinstance(edge.payload, ast.keyword): + assert edge.rhs is edge.payload.value + assert edge.parts[4] is None + else: + assert edge.rhs is edge.payload.values[0] + assert edge.parts[4] is edge.payload.keys[0] + home, function, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + assert not calls.idempotent_source_slot(module, edge.payload, family) + assert not calls.idempotent_source_slot(module, declaration, family) + nodes = [edge.primitive_import.root, edge.primitive, statement, edge.call, edge.callee, edge.parts[5].receiver, edge.rhs, declaration.value, edge.parts[0]] + if isinstance(declaration.value, ast.Call): + nodes.append(declaration.value.func) + for node in nodes: + assert calls.idempotent_source_slot(module, node, family) + assert calls.source_primitive_import(module, builtin_import, family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)", "getattr(fake, '__dict__')"]) +@pytest.mark.parametrize("write", [ + "builtins.dict.update(slots, **{'Agent': fake.Agent})", "builtins.dict.update(slots, *[{'Agent': fake.Agent}])", + "builtins.dict.update(slots, Agent=fake.Agent, other=fake.Agent)", + "builtins.dict.update(slots, {'Agent': fake.Agent}, {'Agent': fake.Agent})", + "builtins.dict.update(slots, {'Agent': fake.Agent}, Agent=fake.Agent)", + "builtins.dict.update(slots, {'Agent': fake.Agent, 'Agent': fake.Agent})", + "builtins.dict.update(slots, {key: fake.Agent})", "builtins.dict.update(slots, {**opaque, 'Agent': fake.Agent})", + "builtins.dict.update(slots, mapping)", "builtins.dict.update(slots, other=fake.Agent)", + "builtins.dict.update(slots, {'other': fake.Agent})", "builtins.dict.update(slots, Agent=other.Agent)", + "saved = builtins.dict.update(slots, Agent=fake.Agent)", "consume(builtins.dict.update(slots, Agent=fake.Agent))", + "replace = slots.update\nreplace(Agent=fake.Agent)", + "dict.update(slots, Agent=fake.Agent)", "builtins.dict.update(slots, Agent=fake.Agent, other=fake.Agent)", + "if condition:\n builtins.dict.update(slots, Agent=fake.Agent)", + "builtins.dict.update(slots, Agent=fake.Agent)\nsaved = slots", + "builtins.dict.update(slots, Agent=fake.Agent)\nbuiltins.dict.update(slots, Agent=fake.Agent)", +]) +def test_source_qualified_mapping_update_refuses_other_payloads_methods_and_uses(namespace_workspace, projection, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = {projection}\n{write}") + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.Assign | ast.Expr): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["builtins.dict.update(slots, Agent=fake.Agent)", "builtins.dict.update(slots, {'Agent': fake.Agent})"]) +@pytest.mark.parametrize("consumer", ["from entry import slots\n", "from entry import fake\n", "import entry\n"]) +def test_source_qualified_mapping_update_checks_completed_mapping_and_source_imports(namespace_workspace, write, consumer): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = vars(fake)\n{write}") + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["builtins.dict.update(slots, Agent=fake.Agent)", "builtins.dict.update(slots, {'Agent': fake.Agent})"]) +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "saved = fake.Agent\n", "__all__ = ['slots']\n"]) +def test_source_qualified_mapping_update_preserves_calls_values_and_exports(namespace_workspace, write, extra): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = vars(fake)\n{write}", extra=extra) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["builtins.dict.update(slots, Agent=fake.Agent)", "builtins.dict.update(slots, {'Agent': fake.Agent})"]) +def test_source_qualified_mapping_update_rechecks_source_and_preserves_reentry(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = vars(fake)\n{write}") + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + assert calls.idempotent_source_slot(module, statement, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.idempotent_source_slot(module, statement, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", ["builtins.dict.update(slots, Agent=fake.Agent)", "builtins.dict.update(slots, {'Agent': fake.Agent})"]) +def test_source_qualified_mapping_update_cannot_borrow_receiving_owner_callbacks(namespace_workspace, monkeypatch, write): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"import builtins\nslots = vars(fake)\n{write}") + def forbidden(*args, **kwargs): + raise AssertionError("a raw saved mapping update borrowed a receiving-owner callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + + +@pytest.mark.parametrize("write", ["builtins.dict.update(slots, update=fake.update)", "builtins.dict.update(slots, {'update': fake.update})"]) +@pytest.mark.parametrize("called", [False, True]) +def test_source_qualified_mapping_update_keeps_a_same_named_function_distinct_from_the_dictionary_method(namespace_workspace, write, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"import builtins\nslots = vars(fake)\n{write}", + extra="fake.update([])\n" if called else "", home="def update(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[3] + home, function, _ = calls._source_slot_candidate(module, statement) + before = calls.callers(home, function, allow_empty=True) + assert before.limits + assert calls.idempotent_source_slot(module, statement, "agents") is (not called) + assert calls.uncalled_source_slot(home, function) is (not called) + assert calls.callers(home, function, allow_empty=True) is before + assert bool(before.sites) is called + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("called", [False, True]) +def test_source_qualified_mapping_update_keeps_builtin_callee_distinct_from_function_dict(namespace_workspace, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.update(slots, dict=fake.dict)", + extra="fake.dict([])\n" if called else "", home="def dict(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + edge = calls._source_slot_edge(module, terminal) + assert edge.primitive is terminal.value.func.value + assert edge.rhs is terminal.value.keywords[0].value + assert edge.primitive is not edge.rhs.value + assert calls.idempotent_source_slot(module, terminal, "agents") is (not called) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("field", ["Agent", "dict"]) +def test_source_qualified_mapping_update_requires_raw_canonical_primitive(namespace_workspace, monkeypatch, field): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"import builtins\nslots = vars(fake)\nbuiltins.dict.update(slots, {field}=fake.{field})", + home=f"def {field}(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + assert calls.idempotent_source_slot(module, terminal, "agents") + original = list_expressions._namespace_call_reference + raw_original = list_expressions._qualified_attribute_patches + raw_active = False + + def raw(view, **kwargs): + nonlocal raw_active + previous = raw_active + raw_active = True + try: + return raw_original(view, **kwargs) + finally: + raw_active = previous + + def altered(view, call): + if call is terminal.value and not raw_active: + return "foreign.dict.update" + return original(view, call) + + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", raw) + monkeypatch.setattr(list_expressions, "_namespace_call_reference", altered) + with pytest.raises(CallLimit, match="unread dictionary initialization primitive"): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", [ + "builtins = replacement\n", "from provider import dict\n", "import builtins\nbuiltins.dict = replacement\n", + "import builtins\nbuiltins.dict.update = replacement\n", "fake.__class__ = replacement\n", + "import builtins\nsetattr(builtins, 'dict', replacement)\n", +]) +def test_source_qualified_mapping_update_preserves_local_and_remote_primitive_stability(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.update(slots, Agent=fake.Agent)", extra=extra, + ) + calls = BuilderCalls(resolver) + try: + assert not calls.idempotent_source_slot(module, module.tree.body[3], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_source_qualified_mapping_update_requires_actual_import_receipt(namespace_workspace, family): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.update(slots, Agent=fake.Agent)", + ) + calls = BuilderCalls(resolver) + declaration, terminal = module.tree.body[1], module.tree.body[3] + clone = ast.parse("import builtins").body[0] + assert calls.source_primitive_import(module, declaration, family) + assert calls.idempotent_source_slot(module, terminal, family, required_primitive_import=(module, declaration)) + assert not calls.source_primitive_import(module, clone, family) + assert not calls.source_primitive_import(module, module.tree.body[0], family) + assert not calls.idempotent_source_slot(module, terminal, family, required_primitive_import=(module, clone)) + assert not calls.idempotent_source_slot(module, declaration, family) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("imported", [ + "import builtins as b\n", "import builtins, other\n", "from builtins import dict\n", "", +]) +@pytest.mark.parametrize("extra", ["", "saved = builtins\n", "builtins.dict\n", "__all__ = ['builtins']\n"]) +def test_source_qualified_mapping_update_refuses_other_import_and_root_uses(namespace_workspace, imported, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=imported + "slots = vars(fake)\nbuiltins.dict.update(slots, Agent=fake.Agent)", extra=extra, + ) + calls = BuilderCalls(resolver) + # Only a direct unaliased import with its sole actual callee use is eligible. + for statement in module.tree.body: + if isinstance(statement, ast.Import): + assert not calls.source_primitive_import(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("provider", ["module", "package", "case", "link"]) +def test_source_qualified_mapping_update_requires_absent_builtin_provider(namespace_workspace, provider): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.update(slots, Agent=fake.Agent)", + ) + if provider == "package": + (namespace_workspace / "builtins").mkdir() + (namespace_workspace / "builtins/update.py").write_text("VALUE = None\n") + elif provider == "link": + (namespace_workspace / "builtins.py").symlink_to("fake.py") + else: + (namespace_workspace / ("Builtins.py" if provider == "case" else "builtins.py")).write_text("VALUE = None\n") + try: + assert not BuilderCalls(resolver).source_primitive_import(module, module.tree.body[1], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +def test_source_qualified_mapping_update_raw_constructor_route_cannot_borrow_import_proof(namespace_workspace, monkeypatch): + from agents_shipgate.inputs.python_imports import _external_constructor_use + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.update(slots, Agent=fake.Agent)", + ) + + def forbidden(*args, **kwargs): + pytest.fail("raw constructor observation borrowed a completed primitive import proof") + + monkeypatch.setattr(BuilderCalls, "source_primitive_import", forbidden) + assert _external_constructor_use(resolver, module, "agents", {module.path}, allow_owner_routes=False) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", [ + "saved = builtins\n", "builtins.dict\n", "__all__ = ['builtins']\n", + "def capture(value=builtins):\n pass\n", "del builtins\n", "builtins = fake\n", +]) +def test_source_qualified_mapping_update_confines_actual_imported_root(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.update(slots, Agent=fake.Agent)", + extra=extra, + ) + calls = BuilderCalls(resolver) + assert not calls.source_primitive_import(module, module.tree.body[1], "agents") + assert calls._source_slot_candidate(module, module.tree.body[3]) is None + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("operation", ["import", "slot"]) +def test_source_qualified_mapping_update_tree_context_cannot_borrow_live_disk_root(namespace_workspace, monkeypatch, operation): + from agents_shipgate.inputs.python_imports import RepositoryLayout, repository_layout + + files = { + "entry.py": "import fake\nimport builtins\nslots = vars(fake)\nbuiltins.dict.update(slots, Agent=fake.Agent)\n", + "fake.py": "def Agent(tools):\n return tools\n", + } + for name, source in files.items(): + (namespace_workspace / name).write_text(source) + layout = RepositoryLayout("", lambda ref: frozenset(files) if ref == "" else None, read=lambda ref: files.get(ref)) + + def forbidden(*args): + pytest.fail("a tree primitive proof borrowed live directory currency") + + monkeypatch.setattr(BuilderCalls, "_namespace_directory_entries", forbidden) + with repository_layout(layout): + resolver = ImportResolver(namespace_workspace) + source = files["entry.py"] + module = resolver.entry(namespace_workspace / "entry.py", ast.parse(source), source) + calls = BuilderCalls(resolver) + with pytest.raises(CallLimit, match="no bound import-root currency for the dictionary initialization import"): + if operation == "import": + calls.source_primitive_import(module, module.tree.body[1], "agents") + else: + calls.idempotent_source_slot(module, module.tree.body[3], "agents") + assert not resolver._checking_source_module_slot + + +def test_source_qualified_mapping_update_rechecks_provider_currency_and_import_reentry(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.update(slots, Agent=fake.Agent)", + ) + calls = BuilderCalls(resolver) + declaration = module.tree.body[1] + assert calls.source_primitive_import(module, declaration, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.source_primitive_import(module, declaration, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + (namespace_workspace / "builtins.py").write_text("VALUE = None\n") + with pytest.raises(CallLimit): + calls.source_primitive_import(module, declaration, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)", "getattr(fake, '__dict__')"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_source_bare_setitem_keeps_positional_roles_and_uncached_function_proof(namespace_workspace, projection, family): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = {projection}\ndict.__setitem__(slots, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + edge = calls._source_slot_edge(module, statement) + assert edge.call is statement.value + assert edge.callee is edge.call.func + assert edge.primitive is edge.callee.value + assert edge.target is edge.call.args[0] + assert edge.parts[4] is edge.call.args[1] + assert edge.rhs is edge.call.args[2] + assert edge.payload is None and edge.primitive_import is None + home, function, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + for node in (statement, edge.call, edge.callee, edge.primitive, edge.target, edge.parts[4], edge.rhs): + assert calls.idempotent_source_slot(module, node, family) + assert not calls.idempotent_source_slot(module, module.tree.body[1], family) + assert not calls.idempotent_source_slot(module, ast.parse("dict.__setitem__(slots, 'Agent', fake.Agent)").body[0], family) + assert calls.uncalled_source_slot(home, function) + assert calls.callers(home, function, allow_empty=True) is before + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("projection", ["fake.__dict__", "vars(fake)", "getattr(fake, '__dict__')"]) +@pytest.mark.parametrize("write", [ + "slots.__setitem__('Agent', fake.Agent)", "builtins.dict.__setitem__(slots, 'Agent', fake.Agent)", + "replace = dict.__setitem__\nreplace(slots, 'Agent', fake.Agent)", + "dict.__setitem__(slots, 'Agent')", "dict.__setitem__(slots, 'Agent', fake.Agent, opaque)", + "dict.__setitem__(slots, 'Agent', fake.Agent, other=opaque)", + "dict.__setitem__(slots, key='Agent', value=fake.Agent)", + "dict.__setitem__(*opaque)", "dict.__setitem__(slots, *opaque)", + "dict.__setitem__(slots, key, fake.Agent)", "dict.__setitem__(slots, 'other', fake.Agent)", + "dict.__setitem__(slots, '_Agent', fake._Agent)", "dict.__setitem__(slots, 'Agent', other.Agent)", + "saved = dict.__setitem__(slots, 'Agent', fake.Agent)", + "consume(dict.__setitem__(slots, 'Agent', fake.Agent))", + "if condition:\n dict.__setitem__(slots, 'Agent', fake.Agent)", + "dict.__setitem__(slots, 'Agent', fake.Agent)\nsaved = slots", + "dict.__setitem__(slots, 'Agent', fake.Agent)\ndict.__setitem__(slots, 'Agent', fake.Agent)", + "dict = replacement\ndict.__setitem__(slots, 'Agent', fake.Agent)", + "def apply(dict):\n dict.__setitem__(slots, 'Agent', fake.Agent)", +]) +def test_source_bare_setitem_refuses_other_shapes_and_mapping_uses(namespace_workspace, projection, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=f"slots = {projection}\n{write}") + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.Assign | ast.Expr): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("field", ["Agent", "dict", "update"]) +@pytest.mark.parametrize("called", [False, True]) +def test_source_bare_setitem_preserves_selected_function_calls_and_names(namespace_workspace, field, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = vars(fake)\ndict.__setitem__(slots, '{field}', fake.{field})", + extra=f"fake.{field}([])\n" if called else "", home=f"def {field}(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + edge = calls._source_slot_edge(module, terminal) + assert edge.primitive is not edge.rhs.value + home, function, _ = calls._source_slot_candidate(module, terminal) + before = calls.callers(home, function, allow_empty=True) + assert calls.idempotent_source_slot(module, terminal, "agents") is (not called) + assert calls.uncalled_source_slot(home, function) is (not called) + assert calls.callers(home, function, allow_empty=True) is before + assert bool(before.sites) is called + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("consumer", ["from entry import slots\n", "from entry import fake\n", "import entry\n"]) +def test_source_bare_setitem_checks_even_unused_mapping_importers(namespace_workspace, consumer): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\ndict.__setitem__(slots, 'Agent', fake.Agent)", + ) + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + assert not resolver._checking_source_module_slot + + +def test_source_bare_setitem_cannot_borrow_receiving_owner_callbacks(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\ndict.__setitem__(slots, 'Agent', fake.Agent)", + ) + def forbidden(*args, **kwargs): + pytest.fail("a raw positional source write borrowed a receiving owner") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + + +@pytest.mark.parametrize("extra", ["from provider import dict\n", "import builtins\nbuiltins.dict = replacement\n", + "import builtins\nbuiltins.dict.__setitem__ = replacement\n", "fake.__class__ = replacement\n"]) +def test_source_bare_setitem_requires_raw_primitive_stability(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\ndict.__setitem__(slots, 'Agent', fake.Agent)", extra=extra, + ) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +def test_source_bare_setitem_rechecks_source_currency_and_reentry(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\ndict.__setitem__(slots, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + assert calls.idempotent_source_slot(module, terminal, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.idempotent_source_slot(module, terminal, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("replacement", ["foreign.dict.__setitem__", "builtins.dict.update", "builtins.dict.__init__"]) +def test_source_bare_setitem_requires_exact_actual_raw_call_identity(namespace_workspace, monkeypatch, replacement): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\ndict.__setitem__(slots, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + assert calls.idempotent_source_slot(module, terminal, "agents") + original = list_expressions._namespace_call_reference + raw_original = list_expressions._qualified_attribute_patches + raw_active = False + def raw(view, **kwargs): + nonlocal raw_active + previous = raw_active + raw_active = True + try: + return raw_original(view, **kwargs) + finally: + raw_active = previous + def altered(view, call): + if call is terminal.value and not raw_active: + return replacement + return original(view, call) + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", raw) + monkeypatch.setattr(list_expressions, "_namespace_call_reference", altered) + with pytest.raises(CallLimit, match="unread dictionary initialization primitive"): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("field", ["Agent", "unrelated"]) +def test_fresh_unbound_init_receipt_is_local_exact_data_not_a_caller_grant(namespace_workspace, family, field): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = {{}}\ndict.__init__(slots, {field}=fake.Agent)", + ) + calls = BuilderCalls(resolver) + value = module.tree.body[2].value.keywords[0].value + resolution = calls.resolve(module, value) + assert resolution.resolved and not resolution.caveats + home, function = resolution.module, resolution.definition + before = calls.callers(home, function, allow_empty=True) + assert before.limits and not before.sites + with calls.fresh_unbound_dictionary_receipt(module, value, family) as receipt: + assert receipt is not None and receipt.values == frozenset({value}) + assert receipt.proof is not calls and receipt.proof.resolver is not resolver + assert receipt.proof.resolver._modules is not resolver._modules + ordinary = receipt.proof._census(home, function, allow_empty=True) + assert ordinary.limits and not ordinary.sites + confined = receipt.proof._census( + home, function, allow_empty=True, confined_dictionary_data=True, + unused_namespace_data=True, confined_primitive_dictionary_values=receipt.values, + ) + assert not confined.limits and not confined.sites + receipt.reconfirm() + with pytest.raises(CallLimit, match="no longer active"): + receipt.reconfirm() + assert calls.callers(home, function, allow_empty=True) is before + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("field", ["Agent", "dict", "update"]) +def test_fresh_unbound_init_actual_function_calls_are_still_census_evidence(namespace_workspace, field): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"slots = {{}}\ndict.__init__(slots, unrelated=fake.{field})", + extra=f"fake.{field}([])\n", home=f"def {field}(tools):\n return tools\n", + ) + value = module.tree.body[2].value.keywords[0].value + with BuilderCalls(resolver).fresh_unbound_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + resolution = receipt.proof.resolve(module, value) + assert resolution.resolved + census = receipt.proof._census( + resolution.module, resolution.definition, allow_empty=True, + confined_dictionary_data=True, unused_namespace_data=True, + confined_primitive_dictionary_values=receipt.values, + ) + assert census.sites + assert not resolver._checking_fresh_dictionary_primitive + with pytest.raises(CallLimit, match="no longer active"): + receipt.reconfirm() + + +@pytest.mark.parametrize("write", [ + "slots = {}\nslots.__init__(Agent=fake.Agent)", + "slots = {}\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + "slots = dict()\ndict.__init__(slots, Agent=fake.Agent)", + "slots = {'other': None}\ndict.__init__(slots, Agent=fake.Agent)", + "slots = {}\ndict.__init__(slots, Agent=fake.Agent, other=None)", + "slots = {}\ndict.__init__(slots, opaque, Agent=fake.Agent)", + "slots = {}\ndict.__init__(*opaque, Agent=fake.Agent)", + "slots = {}\ndict.__init__(slots, **opaque)", + "slots = {}\nsaved = slots\ndict.__init__(slots, Agent=fake.Agent)", + "slots = {}\ndict.__init__(slots, Agent=fake.Agent)\nconsume(slots)", + "slots = {}\ndict.__init__(slots, Agent=fake.Agent)\nslots.clear()", + "slots = {}\nif condition:\n dict.__init__(slots, Agent=fake.Agent)", + "slots = {}\ndict = replacement\ndict.__init__(slots, Agent=fake.Agent)", +]) +def test_fresh_unbound_init_refuses_other_allocations_shapes_and_receiver_uses(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for node in ast.walk(module.tree): + if isinstance(node, ast.Attribute) and node.attr == "Agent": + with calls.fresh_unbound_dictionary_receipt(module, node, "agents") as receipt: + assert receipt is None + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("mutation", ["source", "new_file", "new_directory", "provider", "venv_selector"]) +def test_fresh_unbound_init_final_identity_rejects_changes_after_zero_census(namespace_workspace, mutation): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = {}\ndict.__init__(slots, Agent=fake.Agent)", + ) + value = module.tree.body[2].value.keywords[0].value + calls = BuilderCalls(resolver) + with calls.fresh_unbound_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + resolution = receipt.proof.resolve(module, value) + assert resolution.resolved and not resolution.caveats + home, function = resolution.module, resolution.definition + census = receipt.proof._census( + home, function, allow_empty=True, confined_dictionary_data=True, + unused_namespace_data=True, confined_primitive_dictionary_values=receipt.values, + ) + assert not census.sites and not census.limits + if mutation == "source": + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return changed\n") + elif mutation == "new_file": + (namespace_workspace / "new.py").write_text("VALUE = None\n") + elif mutation == "new_directory": + (namespace_workspace / "new").mkdir() + elif mutation == "provider": + (namespace_workspace / "agents.py").write_text("VALUE = None\n") + else: + (namespace_workspace / "pyvenv.cfg").write_text("home = changed\n") + with pytest.raises((CallLimit, ValueError, OSError)): + receipt.reconfirm() + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("consumer", ["from entry import slots\n", "import entry\n"]) +def test_fresh_unbound_init_refuses_even_unused_dictionary_importers(namespace_workspace, consumer): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = {}\ndict.__init__(slots, Agent=fake.Agent)", + ) + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imported fresh dictionary allocation"): + with BuilderCalls(resolver).fresh_unbound_dictionary_receipt( + module, module.tree.body[2].value.keywords[0].value, "agents", + ): + pytest.fail("an imported dictionary received a confined-data receipt") + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("change", ["source", "directory"]) +def test_fresh_unbound_init_rejects_initial_stale_snapshot_evidence(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = {}\ndict.__init__(slots, Agent=fake.Agent)", + ) + snapshot = StaticInputSnapshot(namespace_workspace) + token = activate_static_input_snapshot(snapshot) + try: + BuilderCalls(resolver).namespace_source_context("agents") + if change == "source": + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return changed\n") + else: + (namespace_workspace / "new.py").write_text("VALUE = None\n") + with pytest.raises(CallLimit): + with BuilderCalls(resolver).fresh_unbound_dictionary_receipt( + module, module.tree.body[2].value.keywords[0].value, "agents", + ): + pytest.fail("stale snapshot data received a fresh receipt") + finally: + reset_static_input_snapshot(token) + assert not resolver._checking_fresh_dictionary_primitive + + +def test_fresh_unbound_init_preserves_unknown_family_and_both_reentry_guards(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = {}\ndict.__init__(slots, Agent=fake.Agent)", + ) + calls = BuilderCalls(resolver) + value = module.tree.body[2].value.keywords[0].value + with calls.fresh_unbound_dictionary_receipt(module, value, "unknown") as receipt: + assert receipt is None + with calls.fresh_unbound_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + for caller in (calls, receipt.proof): + with pytest.raises(CallLimit, match="reentered"): + with caller.fresh_unbound_dictionary_receipt(module, value, "agents"): + pytest.fail("a nested primitive receipt reentered") + receipt.reconfirm() + assert not resolver._checking_fresh_dictionary_primitive + assert not receipt.proof.resolver._checking_fresh_dictionary_primitive + + +def test_fresh_member_sink_requires_actual_queued_edge_and_exact_function_identity(namespace_workspace): + from agents_shipgate.inputs.list_expressions import ListExpressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = {}\ndict.__init__(slots, Agent=fake.Agent)", + home="def Agent(tools):\n tools.append(None)\ndef other(tools):\n tools.append(None)\n", + ) + calls = BuilderCalls(resolver) + data = module.tree.body[2].value.keywords[0].value + resolution = calls.resolve(module, data) + home, function = resolution.module, resolution.definition + expression = function.body[0].value.func.value + reader = ListExpressions( + ref=module.ref, tree=module.tree, scopes=calls.scopes(module), bindings=module.bindings, + module=module, resolver=resolver, agent_reads=lambda *args: False, builder_calls=calls, + ) + view = reader._foreign(home) + ordinary = calls.callers(home, function, allow_empty=True) + before = calls.callers(home, function, allow_empty=True, confined_dictionary_data=True, + unused_namespace_data=True) + assert before.limits and not before.sites + assert not reader._uncalled_member_sink(view, expression) + resolver._constructor_dictionary_sinks[("agents", id(data))] = (module, data, "agents") + assert not reader._uncalled_member_sink(view, expression) + resolver._constructor_member_sinks[id(expression)] = (home, expression) + assert not reader._uncalled_member_sink(view, expression) + reader._checking_constructor_namespaces = True + assert reader._uncalled_member_sink(view, expression) + assert not reader._fresh_dictionary_function_sink_owned( + reader.entry, data, "agents", expected_module=home, expected_function=home.tree.body[1], + ) + assert calls.callers(home, function, allow_empty=True, confined_dictionary_data=True, + unused_namespace_data=True) == before + assert calls.callers(home, function, allow_empty=True) is ordinary + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("field", ["Agent", "unrelated"]) +def test_fresh_qualified_init_admits_only_actual_import_and_exact_data(namespace_workspace, family, field): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"import builtins\nslots = {{}}\nbuiltins.dict.__init__(slots, {field}=fake.Agent)", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[1] + value = module.tree.body[3].value.keywords[0].value + edge = calls._fresh_unbound_dictionary_edge(module, value) + record = edge[4] + assert record.statement is statement + assert record.imported is statement.names[0] + assert record.root is module.tree.body[3].value.func.value.value + assert record.primitive is module.tree.body[3].value.func.value + resolution = calls.resolve(module, value) + before = calls.callers(resolution.module, resolution.definition, allow_empty=True) + assert before.limits and not before.sites + assert calls.fresh_dictionary_primitive_import(module, statement, family) + assert not calls.fresh_dictionary_primitive_import(module, ast.parse("import builtins").body[0], family) + assert not calls.fresh_dictionary_primitive_import(module, record.imported, family) + with calls.fresh_unbound_dictionary_receipt(module, value, family) as receipt: + assert receipt is not None and receipt.values == frozenset({value}) + receipt.reconfirm() + assert calls.callers(resolution.module, resolution.definition, allow_empty=True) is before + assert before.limits + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("write", [ + "slots = {}\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + "from builtins import dict\nslots = {}\ndict.__init__(slots, Agent=fake.Agent)", + "import builtins as ordinary\nslots = {}\nordinary.dict.__init__(slots, Agent=fake.Agent)", + "import builtins\nslots = {}\nbuiltins = replacement\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + "import builtins\nsaved = builtins\nslots = {}\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + "import builtins\nslots = {}\nbuiltins.dict.__init__(slots, Agent=fake.Agent)\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", +]) +def test_fresh_qualified_init_refuses_missing_aliased_rebound_or_shared_import(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for node in module.tree.body: + if isinstance(node, ast.Import): + assert not calls.fresh_dictionary_primitive_import(module, node, "agents") + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("consumer", ["from entry import builtins\n", "from entry import slots\n", "import entry\n"]) +def test_fresh_qualified_init_checks_unused_importers_of_both_roots(namespace_workspace, consumer): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = {}\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + ) + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imported fresh dictionary"): + BuilderCalls(resolver).fresh_dictionary_primitive_import(module, module.tree.body[1], "agents") + assert not resolver._checking_fresh_dictionary_primitive + + +def test_fresh_qualified_import_adapter_keeps_raw_refusal_and_does_not_borrow_owners(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions, python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = {}\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + ) + def forbidden(*args, **kwargs): + pytest.fail("a qualified fresh import borrowed a receiving owner") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).fresh_dictionary_primitive_import(module, module.tree.body[1], "agents") + result = python_imports._external_constructor_use( + resolver, module, "agents", {module.path}, allow_owner_routes=False, + ) + assert result and "builtin or dynamic import machinery" in result + assert not resolver._checking_fresh_dictionary_primitive + + +def test_fresh_qualified_import_adapter_cannot_erase_actual_function_calls(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = {}\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + extra="fake.Agent([])\n", + ) + assert not BuilderCalls(resolver).fresh_dictionary_primitive_import(module, module.tree.body[1], "agents") + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("change", ["builtin_provider", "source", "new_initializer"]) +def test_fresh_qualified_init_reconfirms_sources_and_import_currency_after_zero_census(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = {}\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + ) + calls = BuilderCalls(resolver) + value = module.tree.body[3].value.keywords[0].value + with calls.fresh_unbound_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + resolution = receipt.proof.resolve(module, value) + census = receipt.proof._census( + resolution.module, resolution.definition, allow_empty=True, + confined_dictionary_data=True, unused_namespace_data=True, + confined_primitive_dictionary_values=receipt.values, + ) + assert not census.sites and not census.limits + if change == "builtin_provider": + (namespace_workspace / "builtins.py").write_text("dict = replacement\n") + elif change == "source": + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return changed\n") + else: + (namespace_workspace / "__init__.py").write_text("VALUE = None\n") + with pytest.raises((CallLimit, ValueError, OSError)): + receipt.reconfirm() + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("home", [ + "class Agent:\n pass\n", + "@decorate\ndef Agent(tools):\n return tools\n", + "Agent = opaque\n", +]) +def test_fresh_qualified_init_import_admission_requires_read_undecorated_function(namespace_workspace, home): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = {}\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + home=home, + ) + try: + assert not BuilderCalls(resolver).fresh_dictionary_primitive_import(module, module.tree.body[1], "agents") + except CallLimit: + pass + assert not resolver._checking_fresh_dictionary_primitive + + +def test_fresh_qualified_init_adapter_preserves_unknown_family_and_reentry(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = {}\nbuiltins.dict.__init__(slots, Agent=fake.Agent)", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[1] + assert not calls.fresh_dictionary_primitive_import(module, statement, "unknown") + value = module.tree.body[3].value.keywords[0].value + with calls.fresh_unbound_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + with pytest.raises(CallLimit, match="reentered"): + calls.fresh_dictionary_primitive_import(module, statement, "agents") + receipt.reconfirm() + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "mapping", "Handler", "extra_field"), + ("service", "table", "dispatch", "new_entry")]) +def test_absent_field_bound_init_has_independent_actual_roles(namespace_workspace, family, names): + source, mapping, function, field = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\n{mapping} = vars({source})\n{mapping}.__init__({field}={source}.{function})\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + getter = module.tree.body[1].value.func + value = module.tree.body[2].value.keywords[0].value + resolution = calls.resolve(module, value) + ordinary = calls.callers(resolution.module, resolution.definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + assert calls.absent_field_dictionary_getter(module, getter, family) + assert not calls.absent_field_dictionary_getter(module, ast.Name(id="vars", ctx=ast.Load()), family) + with calls.absent_field_dictionary_receipt(module, value, family) as receipt: + assert receipt is not None + assert receipt.roles.home is resolution.module + assert receipt.roles.function is resolution.definition + assert receipt.roles.value is value + census = receipt.census() + assert not census.sites and not census.limits + receipt.reconfirm() + with pytest.raises(CallLimit, match="active exact census"): + receipt.census() + assert calls.callers(resolution.module, resolution.definition, allow_empty=True) is ordinary + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("home,extra", [ + ("unrelated = None\n", ""), + ("unrelated: int\n", ""), + ("", "result = fake.unrelated\n"), + ("", "from fake import unrelated\n"), + ("", "saved = fake\n"), + ("", "saved = slots\n"), + ("", "fake.Agent([])\n"), + ("", "result = 'unrelated'\n"), + ("", "slots.__init__(unrelated=fake.Agent)\n"), +]) +def test_absent_field_bound_init_refuses_observers_existing_fields_escapes_and_calls(namespace_workspace, home, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\nslots.__init__(unrelated=fake.Agent)", extra=extra, + home=home + "def Agent(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + try: + assert not calls.absent_field_dictionary_getter(module, module.tree.body[1].value.func, "agents") + except CallLimit: + pass + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("write", [ + "slots = vars(fake)\nslots.__init__(__loader__=fake.Agent)", + "slots = vars(fake)\nslots.__init__(vars=fake.Agent)", + "slots = vars(fake)\ndict.__init__(slots, unrelated=fake.Agent, extra_field=fake.Agent)", + "import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent, extra_field=fake.Agent)", + "saved = vars\nslots = saved(fake)\nslots.__init__(unrelated=fake.Agent)", + "slots = fake.__dict__\nslots.__init__(unrelated=fake.Agent)", +]) +def test_absent_field_bound_init_does_not_widen_getters_primitives_or_metadata(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + value = module.tree.body[-1].value.keywords[0].value + try: + with calls.absent_field_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is None + except CallLimit: + pass + assert not resolver._checking_fresh_dictionary_primitive + + +def test_absent_field_getter_preserves_raw_constructor_refusal(namespace_workspace): + from agents_shipgate.inputs import python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\nslots.__init__(unrelated=fake.Agent)", + ) + result = python_imports._external_constructor_use( + resolver, module, "agents", {module.path}, allow_owner_routes=False, + ) + assert result and "reflective machinery" in result + + +@pytest.mark.parametrize("consumer", ["from fake import Agent\n", "import fake\n", "from entry import slots\n"]) +def test_absent_field_bound_init_refuses_imported_namespace_and_mapping(namespace_workspace, consumer): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\nslots.__init__(unrelated=fake.Agent)", + ) + (namespace_workspace / "consumer.py").write_text(consumer) + try: + assert not BuilderCalls(resolver).absent_field_dictionary_getter(module, module.tree.body[1].value.func, "agents") + except CallLimit: + pass + + +@pytest.mark.parametrize("change", ["source", "provider", "observer"]) +def test_absent_field_bound_init_reconfirms_after_zero_census(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\nslots.__init__(unrelated=fake.Agent)", + ) + value = module.tree.body[2].value.keywords[0].value + with BuilderCalls(resolver).absent_field_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + census = receipt.census() + assert not census.sites and not census.limits + if change == "source": + (namespace_workspace / "fake.py").write_text("unrelated = None\ndef Agent(tools):\n return tools\n") + elif change == "provider": + (namespace_workspace / "builtins.py").write_text("vars = None\n") + else: + (namespace_workspace / "observer.py").write_text("from entry import slots\n") + with pytest.raises((CallLimit, ValueError, OSError)): + receipt.reconfirm() + assert not resolver._checking_fresh_dictionary_primitive + + +def test_absent_field_roles_cannot_enter_another_census(namespace_workspace): + from dataclasses import replace + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\nslots.__init__(unrelated=fake.Agent)", + ) + calls = BuilderCalls(resolver) + value = module.tree.body[2].value.keywords[0].value + with calls.absent_field_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + with pytest.raises(CallLimit, match="active exact census"): + calls._census(receipt.roles.home, receipt.roles.function, allow_empty=True, + absent_field_roles=receipt.roles) + with pytest.raises(CallLimit, match="active exact census"): + receipt.proof._census(receipt.roles.home, receipt.roles.function, allow_empty=True, + absent_field_roles=replace(receipt.roles, home=module)) + assert not receipt.census().limits + receipt.reconfirm() + + +def test_absent_field_bound_init_never_borrows_same_slot_or_receiving_owners(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\nslots.__init__(unrelated=fake.Agent)", + ) + def forbidden(*args, **kwargs): + pytest.fail("an unread-field proof borrowed another ownership result") + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).absent_field_dictionary_getter(module, module.tree.body[1].value.func, "agents") + + +def test_absent_field_bound_init_keeps_explicit_family_and_reentry_boundary(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\nslots.__init__(unrelated=fake.Agent)", + ) + calls = BuilderCalls(resolver) + value = module.tree.body[2].value.keywords[0].value + with calls.absent_field_dictionary_receipt(module, value, "unknown") as receipt: + assert receipt is None + with calls.absent_field_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + with pytest.raises(CallLimit, match="reentered"): + with calls.absent_field_dictionary_receipt(module, value, "agents"): + pass + receipt.reconfirm() + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "mapping", "Handler", "extra_field"), + ("service", "table", "dispatch", "new_entry")]) +def test_absent_field_unbound_init_proves_exact_primitive_and_distinct_roles(namespace_workspace, family, names): + source, mapping, function, field = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\n{mapping} = vars({source})\ndict.__init__({mapping}, {field}={source}.{function})\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + getter = module.tree.body[1].value.func + call = module.tree.body[2].value + value = call.keywords[0].value + edge = calls._absent_field_dictionary_edge(module, value) + assert edge[6] is call.func.value + resolution = calls.resolve(module, value) + ordinary = calls.callers(resolution.module, resolution.definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + assert calls.absent_field_dictionary_getter(module, getter, family) + assert calls.absent_field_dictionary_namespace(module, module.tree.body[1].value.args[0], family) + assert not calls.absent_field_dictionary_namespace(module, value.value, family) + with calls.absent_field_dictionary_receipt(module, value, family) as receipt: + assert receipt is not None + assert call.func.value in receipt.roles.namespace_nodes + census = receipt.census() + assert not census.sites and not census.limits + receipt.reconfirm() + assert calls.callers(resolution.module, resolution.definition, allow_empty=True) is ordinary + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("write", [ + "dict = replacement\nslots = vars(fake)\ndict.__init__(slots, unrelated=fake.Agent)", + "slots = vars(fake)\ndict.__init__(slots, slots, unrelated=fake.Agent)", + "slots = vars(fake)\ndict.__init__(slots, unrelated=fake.Agent, other=fake.Agent)", + "slots = vars(fake)\ndict.__init__(slots, **payload)", + "replace = dict.__init__\nslots = vars(fake)\nreplace(slots, unrelated=fake.Agent)", + "import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent, extra_field=fake.Agent)", + "from builtins import dict\nslots = vars(fake)\ndict.__init__(slots, unrelated=fake.Agent)", +]) +def test_absent_field_unbound_init_keeps_shadow_alias_payload_and_qualified_refusals(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + call = module.tree.body[-1].value + value = call.keywords[0].value + try: + with BuilderCalls(resolver).absent_field_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is None + except CallLimit: + pass + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "saved = slots\n", "result = fake.unrelated\n"]) +def test_absent_field_unbound_init_keeps_calls_escape_and_field_observer_visible(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\ndict.__init__(slots, unrelated=fake.Agent)", extra=extra, + ) + try: + assert not BuilderCalls(resolver).absent_field_dictionary_getter(module, module.tree.body[1].value.func, "agents") + except CallLimit: + pass + + +@pytest.mark.parametrize("change", ["primitive", "source", "importer"]) +def test_absent_field_unbound_init_final_census_currency_remains_bound(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\ndict.__init__(slots, unrelated=fake.Agent)", + ) + value = module.tree.body[2].value.keywords[0].value + with BuilderCalls(resolver).absent_field_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + assert not receipt.census().limits + if change == "primitive": + with (namespace_workspace / "entry.py").open("a") as out: + out.write("dict = replacement\n") + elif change == "source": + (namespace_workspace / "fake.py").write_text("unrelated = None\ndef Agent(tools):\n return tools\n") + else: + (namespace_workspace / "observer.py").write_text("from entry import slots\n") + with pytest.raises((CallLimit, ValueError, OSError)): + receipt.reconfirm() + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("called", [False, True]) +def test_absent_field_unbound_init_does_not_confuse_source_function_with_primitive_method(namespace_workspace, family, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\ndict.__init__(slots, unrelated=fake.__init__)", + extra="fake.__init__([])\n" if called else "", + home="def __init__(tools):\n return tools\n", + ) + assert BuilderCalls(resolver).absent_field_dictionary_getter(module, module.tree.body[1].value.func, family) is (not called) + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "mapping", "Handler", "extra_field"), + ("service", "table", "dispatch", "new_entry")]) +def test_absent_field_qualified_init_binds_actual_import_and_independent_data(namespace_workspace, family, names): + source, mapping, function, field = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\nimport builtins\n{mapping} = vars({source})\nbuiltins.dict.__init__({mapping}, {field}={source}.{function})\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + statement = module.tree.body[1] + value = module.tree.body[3].value.keywords[0].value + record = calls._absent_field_dictionary_edge(module, value)[7] + assert record.statement is statement and record.imported is statement.names[0] + assert record.primitive is module.tree.body[3].value.func.value + resolution = calls.resolve(module, value) + ordinary = calls.callers(resolution.module, resolution.definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + assert calls.absent_field_dictionary_primitive_import(module, statement, family) + assert not calls.absent_field_dictionary_primitive_import(module, ast.parse("import builtins").body[0], family) + assert not calls.absent_field_dictionary_primitive_import(module, record.imported, family) + with calls.absent_field_dictionary_receipt(module, value, family) as receipt: + assert receipt is not None + census = receipt.census() + assert not census.sites and not census.limits + receipt.reconfirm() + assert calls.callers(resolution.module, resolution.definition, allow_empty=True) is ordinary + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("write", [ + "slots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent)", + "slots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent)\nimport builtins", + "import builtins as ordinary\nslots = vars(fake)\nordinary.dict.__init__(slots, unrelated=fake.Agent)", + "from builtins import dict\nslots = vars(fake)\ndict.__init__(slots, unrelated=fake.Agent)", + "import builtins\nbuiltins = replacement\nslots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent)", + "import builtins\nsaved = builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent)", + "import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent, other=fake.Agent)", +]) +def test_absent_field_qualified_init_keeps_actual_import_and_payload_boundary(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for node in module.tree.body: + if isinstance(node, ast.Import): + assert not calls.absent_field_dictionary_primitive_import(module, node, "agents") + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("consumer", ["from entry import builtins\n", "from entry import slots\n", "import entry\n"]) +def test_absent_field_qualified_init_refuses_all_writer_namespace_imports(namespace_workspace, consumer): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent)", + ) + (namespace_workspace / "consumer.py").write_text(consumer) + with pytest.raises(CallLimit, match="imports"): + BuilderCalls(resolver).absent_field_dictionary_primitive_import(module, module.tree.body[1], "agents") + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "result = fake.unrelated\n", "saved = slots\n"]) +def test_absent_field_qualified_import_requires_zero_calls_and_no_field_observer(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent)", + extra=extra, + ) + try: + assert not BuilderCalls(resolver).absent_field_dictionary_primitive_import(module, module.tree.body[1], "agents") + except CallLimit: + pass + assert not resolver._checking_fresh_dictionary_primitive + + +def test_absent_field_qualified_import_never_borrows_owners_and_preserves_raw_refusal(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions, python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent)", + ) + def forbidden(*args, **kwargs): + pytest.fail("the qualified unread-field proof borrowed a receiving owner") + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).absent_field_dictionary_primitive_import(module, module.tree.body[1], "agents") + result = python_imports._external_constructor_use(resolver, module, "agents", {module.path}, allow_owner_routes=False) + assert result and "builtin or dynamic import machinery" in result + + +@pytest.mark.parametrize("change", ["provider", "source", "importer"]) +def test_absent_field_qualified_init_final_receipt_reconfirms_all_provenance(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent)", + ) + value = module.tree.body[3].value.keywords[0].value + with BuilderCalls(resolver).absent_field_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + assert not receipt.census().limits + if change == "provider": + (namespace_workspace / "builtins.py").write_text("dict = None\n") + elif change == "source": + (namespace_workspace / "fake.py").write_text("unrelated = None\ndef Agent(tools):\n return tools\n") + else: + (namespace_workspace / "observer.py").write_text("from entry import builtins\n") + with pytest.raises((CallLimit, ValueError, OSError)): + receipt.reconfirm() + assert not resolver._checking_fresh_dictionary_primitive + + +def test_absent_field_qualified_import_keeps_explicit_family_and_reentry_refusal(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nslots = vars(fake)\nbuiltins.dict.__init__(slots, unrelated=fake.Agent)", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[1] + assert not calls.absent_field_dictionary_primitive_import(module, statement, "unknown") + value = module.tree.body[3].value.keywords[0].value + with calls.absent_field_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + with pytest.raises(CallLimit, match="reentered"): + calls.absent_field_dictionary_primitive_import(module, statement, "agents") + receipt.reconfirm() + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "mapping", "Handler", "extra_field"), + ("service", "table", "dispatch", "new_entry")]) +def test_absent_field_bound_update_reuses_only_independent_source_data_roles(namespace_workspace, family, names): + source, mapping, function, field = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\n{mapping} = vars({source})\n{mapping}.update({field}={source}.{function})\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + getter = module.tree.body[1].value.func + value = module.tree.body[2].value.keywords[0].value + edge = calls._absent_field_dictionary_edge(module, value) + assert edge[6] is None and edge[7] is None + resolution = calls.resolve(module, value) + ordinary = calls.callers(resolution.module, resolution.definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + assert calls.absent_field_dictionary_getter(module, getter, family) + with calls.absent_field_dictionary_receipt(module, value, family) as receipt: + assert receipt is not None + assert receipt.roles.value is value + census = receipt.census() + assert not census.sites and not census.limits + receipt.reconfirm() + assert calls.callers(resolution.module, resolution.definition, allow_empty=True) is ordinary + + +@pytest.mark.parametrize("write", [ + "slots = vars(fake)\ndict.update(slots, unrelated=fake.Agent)", + "import builtins\nslots = vars(fake)\nbuiltins.dict.update(slots, unrelated=fake.Agent)", + "slots = vars(fake)\nslots.update(unrelated=fake.Agent, other=fake.Agent)", + "slots = vars(fake)\nslots.update({'unrelated': fake.Agent})", + "slots = vars(fake)\nslots.update(**payload)", + "slots = fake.__dict__\nslots.update(unrelated=fake.Agent, extra_field=fake.Agent)", + "slots = getattr(fake, '__dict__')\nslots.update(unrelated=fake.Agent, extra_field=fake.Agent)", +]) +def test_absent_field_bound_update_keeps_receiver_getter_and_payload_bounds(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + try: + assert not calls.absent_field_dictionary_getter(module, module.tree.body[1].value.func if isinstance(module.tree.body[1], ast.Assign) and isinstance(module.tree.body[1].value, ast.Call) else ast.Name(id="vars", ctx=ast.Load()), "agents") + except CallLimit: + pass + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "result = fake.unrelated\n", "saved = slots\n", + "__all__ = ['slots']\n", "slots.update(other=fake.Agent)\n"]) +def test_absent_field_bound_update_keeps_calls_reads_exports_and_other_mutations_visible(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\nslots.update(unrelated=fake.Agent)", extra=extra, + ) + try: + assert not BuilderCalls(resolver).absent_field_dictionary_getter(module, module.tree.body[1].value.func, "agents") + except CallLimit: + pass + + +@pytest.mark.parametrize("called", [False, True]) +def test_absent_field_bound_update_keeps_function_and_dictionary_method_identity_separate(namespace_workspace, called): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\nslots.update(unrelated=fake.update)", + extra="fake.update([])\n" if called else "", home="def update(tools):\n return tools\n", + ) + assert BuilderCalls(resolver).absent_field_dictionary_getter(module, module.tree.body[1].value.func, "agents") is (not called) + + +def test_absent_field_bound_update_keeps_raw_refusal_and_same_key_proof_distinct(namespace_workspace): + from agents_shipgate.inputs import python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = vars(fake)\nslots.update(Agent=fake.Agent)", + ) + calls = BuilderCalls(resolver) + assert calls.idempotent_source_slot(module, module.tree.body[1].value.func, "agents") + assert not calls.absent_field_dictionary_getter(module, module.tree.body[1].value.func, "agents") + result = python_imports._external_constructor_use(resolver, module, "agents", {module.path}, allow_owner_routes=False) + assert result and "reflective machinery" in result + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "mapping", "Handler", "extra_field"), + ("service", "table", "dispatch", "new_entry")]) +def test_absent_field_getattr_update_keeps_metadata_and_function_roles_distinct(namespace_workspace, family, names): + source, mapping, function, field = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\n{mapping} = getattr({source}, '__dict__')\n{mapping}.update({field}={source}.{function})\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + projection = module.tree.body[1].value + value = module.tree.body[2].value.keywords[0].value + resolution = calls.resolve(module, value) + ordinary = calls.callers(resolution.module, resolution.definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + assert calls.absent_field_dictionary_getter(module, projection.func, family) + assert calls.absent_field_dictionary_namespace(module, projection.args[0], family) + assert not calls.absent_field_dictionary_namespace(module, projection.args[1], family) + with calls.absent_field_dictionary_receipt(module, value, family) as receipt: + assert receipt is not None + assert receipt.roles.getter_metadata is projection.args[1] + assert receipt.roles.getter_metadata not in receipt.roles.namespace_nodes + assert receipt.roles.value is value + census = receipt.census() + assert not census.sites and not census.limits + receipt.reconfirm() + assert calls.callers(resolution.module, resolution.definition, allow_empty=True) is ordinary + + +@pytest.mark.parametrize("write", [ + "slots = getattr(fake, '__dict__')\nslots.__init__(unrelated=fake.Agent)", + "slots = getattr(fake, '__dict__')\ndict.__init__(slots, unrelated=fake.Agent)", + "import builtins\nslots = getattr(fake, '__dict__')\nbuiltins.dict.__init__(slots, unrelated=fake.Agent)", + "slots = getattr(fake, '__dict__', {})\nslots.update(unrelated=fake.Agent)", + "key = '__dict__'\nslots = getattr(fake, key)\nslots.update(unrelated=fake.Agent)", + "slots = getattr(fake, '__globals__')\nslots.update(unrelated=fake.Agent)", + "saved = getattr\nslots = saved(fake, '__dict__')\nslots.update(unrelated=fake.Agent)", + "getattr = replacement\nslots = getattr(fake, '__dict__')\nslots.update(unrelated=fake.Agent)", + "slots = getattr(fake, '__dict__')\nslots.update(unrelated=fake.Agent, other=fake.Agent)", +]) +def test_absent_field_getattr_stays_exact_and_update_only(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + try: + for node in ast.walk(module.tree): + if isinstance(node, ast.Name) and node.id == "getattr": + assert not calls.absent_field_dictionary_getter(module, node, "agents") + except CallLimit: + pass + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("extra", ["other = '__dict__'\n", "saved = slots\n", "fake.Agent([])\n", "result = fake.unrelated\n"]) +def test_absent_field_getattr_does_not_exempt_other_metadata_or_namespace_uses(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = getattr(fake, '__dict__')\nslots.update(unrelated=fake.Agent)", extra=extra, + ) + try: + assert not BuilderCalls(resolver).absent_field_dictionary_getter(module, module.tree.body[1].value.func, "agents") + except CallLimit: + pass + assert not resolver._checking_fresh_dictionary_primitive + + +def test_absent_field_getattr_keeps_raw_refusal_and_receiving_owners_out(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions, python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = getattr(fake, '__dict__')\nslots.update(unrelated=fake.Agent)", + ) + def forbidden(*args, **kwargs): + pytest.fail("the source metadata proof borrowed an ownership assertion") + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).absent_field_dictionary_getter(module, module.tree.body[1].value.func, "agents") + result = python_imports._external_constructor_use(resolver, module, "agents", {module.path}, allow_owner_routes=False) + assert result and "reflective machinery" in result + + +@pytest.mark.parametrize("change", ["source", "importer", "getter"]) +def test_absent_field_getattr_final_receipt_reconfirms_getter_and_home(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = getattr(fake, '__dict__')\nslots.update(unrelated=fake.Agent)", + ) + value = module.tree.body[2].value.keywords[0].value + with BuilderCalls(resolver).absent_field_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + assert not receipt.census().limits + if change == "source": + (namespace_workspace / "fake.py").write_text("unrelated = None\ndef Agent(tools):\n return tools\n") + elif change == "importer": + (namespace_workspace / "observer.py").write_text("from entry import slots\n") + else: + with (namespace_workspace / "entry.py").open("a") as out: + out.write("getattr = replacement\n") + with pytest.raises((CallLimit, ValueError, OSError)): + receipt.reconfirm() + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "mapping", "Handler", "extra_field"), + ("service", "table", "dispatch", "new_entry")]) +def test_absent_field_attribute_update_keeps_metadata_and_function_roles_distinct(namespace_workspace, family, names): + source, mapping, function, field = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\n{mapping} = {source}.__dict__\n{mapping}.update({field}={source}.{function})\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + projection = module.tree.body[1].value + value = module.tree.body[2].value.keywords[0].value + resolution = calls.resolve(module, value) + ordinary = calls.callers(resolution.module, resolution.definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + assert calls.absent_field_dictionary_projection(module, projection, family) + assert calls.absent_field_dictionary_namespace(module, projection.value, family) + assert not calls.absent_field_dictionary_projection(module, projection.value, family) + assert not calls.absent_field_dictionary_projection(module, value, family) + assert not calls.absent_field_dictionary_getter(module, projection, family) + with calls.absent_field_dictionary_receipt(module, value, family) as receipt: + assert receipt is not None + assert receipt.roles.getter_metadata is None + assert receipt.roles.intrinsic_projection is projection + assert projection not in receipt.roles.namespace_nodes + assert receipt.roles.value is value + census = receipt.census() + assert not census.sites and not census.limits + receipt.reconfirm() + assert calls.callers(resolution.module, resolution.definition, allow_empty=True) is ordinary + + +@pytest.mark.parametrize("write", [ + "slots = fake.__dict__\nslots.__init__(unrelated=fake.Agent)", + "slots = fake.__dict__\ndict.__init__(slots, unrelated=fake.Agent)", + "import builtins\nslots = fake.__dict__\nbuiltins.dict.__init__(slots, unrelated=fake.Agent)", + "slots = fake.__globals__\nslots.update(unrelated=fake.Agent)", + "slots = fake.__dict__\nslots.update({'unrelated': fake.Agent})", + "slots = fake.__dict__\ndict.update(slots, unrelated=fake.Agent)", + "slots = fake.__dict__\nslots.update(unrelated=fake.Agent, other=fake.Agent)", + "slots = fake.__dict__\nsaved = slots\nsaved.update(unrelated=fake.Agent)", + "slots = fake.__dict__\nslots.update(unrelated=lambda: fake.Agent)", +]) +def test_absent_field_attribute_stays_exact_and_update_only(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + try: + for node in ast.walk(module.tree): + if isinstance(node, ast.Attribute) and node.attr == "__dict__": + assert not calls.absent_field_dictionary_projection(module, node, "agents") + except CallLimit: + pass + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("extra", ["other = '__dict__'\n", "saved = slots\n", "fake.Agent([])\n", "result = fake.unrelated\n"]) +def test_absent_field_attribute_does_not_exempt_other_metadata_or_namespace_uses(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\nslots.update(unrelated=fake.Agent)", extra=extra, + ) + try: + assert not BuilderCalls(resolver).absent_field_dictionary_projection(module, module.tree.body[1].value, "agents") + except CallLimit: + pass + assert not resolver._checking_fresh_dictionary_primitive + + +def test_absent_field_attribute_keeps_raw_refusal_and_receiving_owners_out(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions, python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\nslots.update(unrelated=fake.Agent)", + ) + def forbidden(*args, **kwargs): + pytest.fail("the source metadata proof borrowed an ownership assertion") + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).absent_field_dictionary_projection(module, module.tree.body[1].value, "agents") + value = module.tree.body[2].value.keywords[0].value + home = BuilderCalls(resolver).resolve(module, value).module + assert home is not None + result = python_imports._external_constructor_use( + resolver, module, "agents", {module.path, home.path}, allow_owner_routes=False, + ) + assert result is not None + + +@pytest.mark.parametrize("change", ["source", "importer", "getter"]) +def test_absent_field_attribute_final_receipt_reconfirms_getter_and_home(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\nslots.update(unrelated=fake.Agent)", + ) + value = module.tree.body[2].value.keywords[0].value + with BuilderCalls(resolver).absent_field_dictionary_receipt(module, value, "agents") as receipt: + assert receipt is not None + assert not receipt.census().limits + if change == "source": + (namespace_workspace / "fake.py").write_text("unrelated = None\ndef Agent(tools):\n return tools\n") + elif change == "importer": + (namespace_workspace / "observer.py").write_text("from entry import slots\n") + else: + with (namespace_workspace / "entry.py").open("a") as out: + out.write("fake.__dict__ = replacement\n") + with pytest.raises((CallLimit, ValueError, OSError)): + receipt.reconfirm() + assert not resolver._checking_fresh_dictionary_primitive + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "mapping", "Handler"), ("service", "table", "dispatch")]) +def test_source_bound_ior_preserves_actual_same_slot_roles_and_ordinary_census(namespace_workspace, family, names): + source, mapping, function = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\n{mapping} = {source}.__dict__\n{mapping}.__ior__({{'{function}': {source}.{function}}})\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + home, definition, statement = calls._source_slot_candidate(module, terminal) + assert statement is terminal + ordinary = calls.callers(home, definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + edge = calls._source_slot_edge(module, terminal) + assert edge.primitive is None + assert edge.call is terminal.value + assert edge.callee is terminal.value.func + assert edge.payload is terminal.value.args[0] + assert edge.rhs is edge.payload.values[0] + assert calls.idempotent_source_slot(module, terminal, family) + assert calls.uncalled_source_slot(home, definition) + assert calls.callers(home, definition, allow_empty=True) is ordinary + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "slots = fake.__dict__\nslots.__ior__(Agent=fake.Agent)", + "slots = fake.__dict__\nslots.__ior__({'other': fake.Agent})", + "slots = fake.__dict__\nslots.__ior__({'Agent': fake.Agent, 'extra': fake.Agent})", + "slots = fake.__dict__\nslots.__ior__({**{'Agent': fake.Agent}})", + "slots = fake.__dict__\nslots.__ior__(*({'Agent': fake.Agent},))", + "slots = fake.__dict__\ndict.__ior__(slots, {'Agent': fake.Agent})", + "import builtins\nslots = fake.__dict__\nbuiltins.dict.__ior__(slots, {'Agent': fake.Agent})", + "slots = vars(fake)\nslots.__ior__({'Agent': fake.Agent})", + "slots = getattr(fake, '__dict__')\nslots.__ior__({'Agent': fake.Agent})", + "slots = fake.__dict__\nresult = slots.__ior__({'Agent': fake.Agent})", +]) +def test_source_bound_ior_keeps_literal_bound_projection_and_discarded_result_limits(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for statement in module.tree.body: + if (isinstance(statement, ast.Expr) + or isinstance(statement, ast.Assign) and isinstance(statement.value, ast.Call)): + assert calls._source_slot_edge(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", ["saved = slots\n", "slots.update(Agent=fake.Agent)\n", "fake.Agent([])\n", + "__all__ = ['slots']\n", "getattr(fake, 'Agent')\n"]) +def test_source_bound_ior_keeps_calls_exports_and_competing_uses_visible(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\nslots.__ior__({'Agent': fake.Agent})", extra=extra, + ) + calls = BuilderCalls(resolver) + try: + assert not calls.idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +def test_source_bound_ior_cannot_borrow_receiving_ownership_or_raw_false(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions, python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\nslots.__ior__({'Agent': fake.Agent})", + ) + calls = BuilderCalls(resolver) + home, definition, _ = calls._source_slot_candidate(module, module.tree.body[2]) + def forbidden(*args, **kwargs): + pytest.fail("a raw same-slot merge borrowed a receiving-owner callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert calls.idempotent_source_slot(module, module.tree.body[2], "agents") + assert calls.uncalled_source_slot(home, definition) + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + assert python_imports._external_constructor_use( + resolver, module, "agents", {module.path, home.path}, allow_owner_routes=False, + ) is not None + + +def test_source_bound_ior_rechecks_actual_source_and_reentry(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\nslots.__ior__({'Agent': fake.Agent})", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[2] + assert calls.idempotent_source_slot(module, statement, "agents") + resolver._checking_source_module_slot = True + with pytest.raises(CallLimit, match="reentered"): + calls.idempotent_source_slot(module, statement, "agents") + assert resolver._checking_source_module_slot + resolver._checking_source_module_slot = False + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "Handler"), ("service", "dispatch")]) +def test_qualified_source_setter_keeps_source_key_function_and_builtin_roles_distinct(namespace_workspace, family, names): + source, function = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\nimport builtins\nbuiltins.setattr({source}, '{function}', {source}.{function})\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + statement = module.tree.body[2] + home, definition, actual = calls._source_slot_candidate(module, statement) + assert actual is statement + ordinary = calls.callers(home, definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + edge = calls._source_slot_edge(module, statement) + record = edge.setter_import + assert record.statement is module.tree.body[1] + assert record.callee is statement.value.func + assert record.root is record.callee.value + assert edge.primitive is None and edge.primitive_import is None + assert edge.parts[3] is None and edge.parts[5] is None + assert edge.parts[0] is statement.value.args[0] + assert edge.parts[4] is statement.value.args[1] + assert edge.rhs is statement.value.args[2] + assert calls.idempotent_source_slot(module, record.callee, family) + assert calls.idempotent_source_slot(module, record.root, family) + assert calls.idempotent_source_slot(module, edge.parts[4], family) + assert calls.source_setter_import(module, record.statement, family) + assert not calls.source_primitive_import(module, record.statement, family) + assert calls.uncalled_source_slot(home, definition) + assert calls.callers(home, definition, allow_empty=True) is ordinary + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "import builtins\nbuiltins.setattr(fake, 'unrelated', fake.Agent)", + "import builtins\nbuiltins.setattr(fake, key, fake.Agent)", + "import builtins\nbuiltins.setattr(fake, 'Agent', fake.Replacement)", + "import builtins\nbuiltins.setattr(fake, 'Agent', fake.Agent, None)", + "import builtins\nbuiltins.setattr(fake, 'Agent', fake.Agent, extra=None)", + "import builtins\nbuiltins.setattr(*(fake, 'Agent', fake.Agent))", + "import builtins as runtime\nruntime.setattr(fake, 'Agent', fake.Agent)", + "from builtins import setattr as replace\nreplace(fake, 'Agent', fake.Agent, extra=None)", + "import builtins\nreplace = builtins.setattr\nreplace(fake, 'Agent', fake.Agent, extra=None)", + "setattr(fake, 'Agent', fake.Agent, extra=None)", + "import builtins\nresult = builtins.setattr(fake, 'Agent', fake.Agent)", + "import builtins\nimport fake as alias\nbuiltins.setattr(alias, 'Agent', alias.Agent)", + "import builtins\nbuiltins = replacement\nbuiltins.setattr(fake, 'Agent', fake.Agent)", +]) +def test_qualified_source_setter_preserves_exact_import_call_and_same_slot_limits(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for statement in module.tree.body: + if isinstance(statement, ast.Expr) or isinstance(statement, ast.Assign) and isinstance(statement.value, ast.Call): + assert calls._source_slot_edge(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", ["saved = fake\n", "fake.Agent([])\n", "saved = builtins\n", + "__all__ = ['fake']\n", "getattr(fake, 'Agent')\n"]) +def test_qualified_source_setter_keeps_calls_namespace_exports_and_other_uses_visible(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nbuiltins.setattr(fake, 'Agent', fake.Agent)", extra=extra, + ) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("home", [ + "def Agent(tools):\n return tools\ndef __getattr__(name):\n return Agent\n", + "class Carrier:\n pass\ndef Agent(tools):\n return tools\n", + "@decorate\ndef Agent(tools):\n return tools\n", +]) +def test_qualified_source_setter_does_not_establish_hook_or_decorator_purity(namespace_workspace, home): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nbuiltins.setattr(fake, 'Agent', fake.Agent)", home=home, + ) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("phase", ["raw", "canonical"]) +def test_qualified_source_setter_requires_raw_canonical_setter_before_token_admission(namespace_workspace, monkeypatch, phase): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nbuiltins.setattr(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + assert calls.idempotent_source_slot(module, terminal, "agents") + original = list_expressions._namespace_call_reference + raw_original = list_expressions._qualified_attribute_patches + raw_active = False + raw_visits = 0 + changed_calls = 0 + def raw(view, **kwargs): + nonlocal raw_active, raw_visits + previous = raw_active + raw_active = True + raw_visits += 1 + try: + return raw_original(view, **kwargs) + finally: + raw_active = previous + def wrong(view, node): + nonlocal changed_calls + if node is terminal.value and (phase == "raw" or not raw_active): + changed_calls += 1 + return "foreign.setattr" + return original(view, node) + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", raw) + monkeypatch.setattr(list_expressions, "_namespace_call_reference", wrong) + diagnostic = "unstable namespace primitives" if phase == "raw" else "setter primitive" + with pytest.raises(CallLimit, match=diagnostic): + calls.idempotent_source_slot(module, terminal, "agents") + assert raw_visits and changed_calls + assert not resolver._checking_source_module_slot + + +def test_qualified_source_setter_raw_false_and_receiving_owners_cannot_borrow_its_proof(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions, python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nbuiltins.setattr(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + home, definition, _ = calls._source_slot_candidate(module, module.tree.body[2]) + def forbidden(*args, **kwargs): + pytest.fail("the source setter borrowed a receiving-owner proof") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert calls.idempotent_source_slot(module, module.tree.body[2], "agents") + assert calls.uncalled_source_slot(home, definition) + monkeypatch.setattr(BuilderCalls, "source_setter_import", forbidden) + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + assert python_imports._external_constructor_use( + resolver, module, "agents", {module.path, home.path}, allow_owner_routes=False, + ) is not None + + +@pytest.mark.parametrize("change", ["source", "builtin_provider"]) +def test_qualified_source_setter_reconfirms_source_and_primitive_provider_currency(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nbuiltins.setattr(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[2] + assert calls.idempotent_source_slot(module, statement, "agents") + if change == "source": + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + else: + (namespace_workspace / "builtins.py").write_text("def setattr(*args):\n return None\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "Handler", "apply"), ("service", "dispatch", "replace")]) +def test_saved_source_setter_keeps_initializer_assignment_callee_and_function_roles_distinct(namespace_workspace, family, names): + source, function, alias = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\nimport builtins\n{alias} = builtins.setattr\n{alias}({source}, '{function}', {source}.{function})\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + home, definition, actual = calls._source_slot_candidate(module, terminal) + assert actual is terminal + ordinary = calls.callers(home, definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + edge = calls._source_slot_edge(module, terminal) + record = edge.setter_import + assert record.statement is module.tree.body[1] + assert record.assignment is module.tree.body[2] + assert record.target is record.assignment.targets[0] + assert record.initializer is record.assignment.value + assert record.root is record.initializer.value + assert record.callee is terminal.value.func + assert record.callee is not record.target + assert edge.rhs is terminal.value.args[2] + assert edge.primitive is None and edge.primitive_import is None + assert calls.idempotent_source_slot(module, record.callee, family) + assert not calls.idempotent_source_slot(module, record.root, family) + assert calls.source_setter_initializer(module, record.initializer, family) + assert not calls.source_setter_initializer(module, edge.rhs, family) + assert calls.source_setter_import(module, record.statement, family) + assert not calls.source_primitive_import(module, record.statement, family) + assert calls.uncalled_source_slot(home, definition) + assert calls.callers(home, definition, allow_empty=True) is ordinary + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "import builtins\nreplace = builtins.setattr\nreplace(fake, 'unrelated', fake.Agent)", + "import builtins\nreplace = builtins.setattr\nreplace(fake, key, fake.Agent)", + "import builtins\nreplace = builtins.setattr\nreplace(fake, 'Agent', fake.Replacement)", + "import builtins\nreplace = builtins.setattr\nreplace(fake, 'Agent', fake.Agent, extra=None)", + "import builtins\nreplace = builtins.setattr\nresult = replace(fake, 'Agent', fake.Agent)", + "import builtins\nreplace, other = builtins.setattr, None\nreplace(fake, 'Agent', fake.Agent)", + "import builtins\nreplace: object = builtins.setattr\nreplace(fake, 'Agent', fake.Agent)", + "import builtins\nreplace = builtins.setattr\nreplace = replacement\nreplace(fake, 'Agent', fake.Agent)", + "import builtins as runtime\nreplace = runtime.setattr\nreplace(fake, 'Agent', fake.Agent)", + "from builtins import setattr as replace\nreplace(fake, 'Agent', fake.Agent, extra=None)", + "setattr(fake, 'Agent', fake.Agent, extra=None)", +]) +def test_saved_source_setter_keeps_exact_alias_assignment_and_terminal_limits(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for statement in module.tree.body: + if isinstance(statement, ast.Expr) or isinstance(statement, ast.Assign) and isinstance(statement.value, ast.Call): + assert calls._source_slot_edge(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", ["saved = replace\n", "replace(fake, 'Agent', fake.Agent)\n", "fake.Agent([])\n", + "__all__ = ['replace']\n", "getattr(fake, 'Agent')\n"]) +def test_saved_source_setter_keeps_alias_escapes_calls_and_namespace_uses_visible(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nreplace = builtins.setattr\nreplace(fake, 'Agent', fake.Agent)", extra=extra, + ) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +def test_saved_source_setter_cannot_borrow_raw_false_or_receiving_ownership(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions, python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nreplace = builtins.setattr\nreplace(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + home, definition, _ = calls._source_slot_candidate(module, module.tree.body[3]) + def forbidden(*args, **kwargs): + pytest.fail("the saved source setter borrowed an ownership shortcut") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert calls.idempotent_source_slot(module, module.tree.body[3], "agents") + assert calls.uncalled_source_slot(home, definition) + monkeypatch.setattr(BuilderCalls, "source_setter_initializer", forbidden) + monkeypatch.setattr(BuilderCalls, "source_setter_import", forbidden) + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + assert python_imports._external_constructor_use( + resolver, module, "agents", {module.path, home.path}, allow_owner_routes=False, + ) is not None + + +def test_saved_source_setter_requires_terminal_canonical_identity_after_raw_proof(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nreplace = builtins.setattr\nreplace(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + assert calls.idempotent_source_slot(module, terminal, "agents") + original = list_expressions._namespace_call_reference + raw_original = list_expressions._qualified_attribute_patches + raw_active = False + def raw(view, **kwargs): + nonlocal raw_active + previous = raw_active + raw_active = True + try: + return raw_original(view, **kwargs) + finally: + raw_active = previous + def wrong(view, node): + return "foreign.setattr" if node is terminal.value and not raw_active else original(view, node) + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", raw) + monkeypatch.setattr(list_expressions, "_namespace_call_reference", wrong) + with pytest.raises(CallLimit, match="setter primitive"): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("change", ["source", "builtin_provider"]) +def test_saved_source_setter_reconfirms_source_and_provider_currency(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import builtins\nreplace = builtins.setattr\nreplace(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + assert calls.idempotent_source_slot(module, terminal, "agents") + if change == "source": + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + else: + (namespace_workspace / "builtins.py").write_text("def setattr(*args):\n return None\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "Handler", "apply"), ("service", "dispatch", "replace")]) +def test_from_source_setter_keeps_actual_import_alias_terminal_and_function_roles_distinct(namespace_workspace, family, names): + source, function, alias = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\nfrom builtins import setattr as {alias}\n{alias}({source}, '{function}', {source}.{function})\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + home, definition, actual = calls._source_slot_candidate(module, terminal) + assert actual is terminal + ordinary = calls.callers(home, definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + edge = calls._source_slot_edge(module, terminal) + record = edge.setter_import + assert record.statement is module.tree.body[1] + assert isinstance(record.statement, ast.ImportFrom) + assert record.imported is record.statement.names[0] + assert record.imported.asname == alias + assert record.callee is terminal.value.func + assert not hasattr(record, "root") and not hasattr(record, "initializer") + assert not hasattr(record, "assignment") and not hasattr(record, "target") + assert edge.rhs is terminal.value.args[2] + assert edge.primitive is None and edge.primitive_import is None + assert calls.idempotent_source_slot(module, record.callee, family) + assert calls.source_from_setter_import(module, record.statement, family) + assert not calls.source_from_setter_import(module, module.tree.body[0], family) + assert not calls.source_setter_import(module, record.statement, family) + assert not calls.source_primitive_import(module, record.statement, family) + assert not calls.source_setter_initializer(module, record.callee, family) + assert calls.uncalled_source_slot(home, definition) + assert calls.callers(home, definition, allow_empty=True) is ordinary + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "from builtins import setattr as replace\nreplace(fake, 'unrelated', fake.Agent)", + "from builtins import setattr as replace\nreplace(fake, key, fake.Agent)", + "from builtins import setattr as replace\nreplace(fake, 'Agent', fake.Replacement)", + "from builtins import setattr as replace\nreplace(fake, 'Agent', fake.Agent, extra=None)", + "from builtins import setattr as replace\nresult = replace(fake, 'Agent', fake.Agent)", + "from builtins import setattr as replace\nother = replace\nother(fake, 'Agent', fake.Agent)", + "from builtins import setattr as replace\nreplace = replacement\nreplace(fake, 'Agent', fake.Agent)", + "from .builtins import setattr as replace\nreplace(fake, 'Agent', fake.Agent)", + "from foreign import setattr as replace\nreplace(fake, 'Agent', fake.Agent)", + "from builtins import setattr\nsetattr(fake, 'Agent', fake.Agent)", + "from builtins import setattr as replace, getattr as read\nreplace(fake, 'Agent', fake.Agent)", + "from builtins import *\nreplace(fake, 'Agent', fake.Agent)", + "from builtins import setattr as replace\nreplace(*(fake, 'Agent', fake.Agent))", + "setattr(fake, 'Agent', fake.Agent, extra=None)", +]) +def test_from_source_setter_keeps_exact_import_alias_and_terminal_limits(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for statement in module.tree.body: + if isinstance(statement, ast.Expr) or isinstance(statement, ast.Assign) and isinstance(statement.value, ast.Call): + assert calls._source_slot_edge(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", ["saved = replace\n", "replace(fake, 'Agent', fake.Agent)\n", "fake.Agent([])\n", + "__all__ = ['replace']\n", "getattr(fake, 'Agent')\n"]) +def test_from_source_setter_keeps_alias_escapes_calls_and_namespace_uses_visible(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="from builtins import setattr as replace\nreplace(fake, 'Agent', fake.Agent)", extra=extra, + ) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +def test_from_source_setter_cannot_borrow_raw_false_or_receiving_ownership(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions, python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="from builtins import setattr as replace\nreplace(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + home, definition, _ = calls._source_slot_candidate(module, module.tree.body[2]) + def forbidden(*args, **kwargs): + pytest.fail("the from source setter borrowed an ownership shortcut") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert calls.idempotent_source_slot(module, module.tree.body[2], "agents") + assert calls.uncalled_source_slot(home, definition) + monkeypatch.setattr(BuilderCalls, "source_setter_initializer", forbidden) + monkeypatch.setattr(BuilderCalls, "source_from_setter_import", forbidden) + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + assert python_imports._external_constructor_use( + resolver, module, "agents", {module.path, home.path}, allow_owner_routes=False, + ) is not None + + +def test_from_source_setter_requires_terminal_canonical_identity_after_raw_proof(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="from builtins import setattr as replace\nreplace(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + assert calls.idempotent_source_slot(module, terminal, "agents") + original = list_expressions._namespace_call_reference + raw_original = list_expressions._qualified_attribute_patches + raw_active = False + def raw(view, **kwargs): + nonlocal raw_active + previous = raw_active + raw_active = True + try: + return raw_original(view, **kwargs) + finally: + raw_active = previous + def wrong(view, node): + return "foreign.setattr" if node is terminal.value and not raw_active else original(view, node) + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", raw) + monkeypatch.setattr(list_expressions, "_namespace_call_reference", wrong) + with pytest.raises(CallLimit, match="setter primitive"): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("change", ["source", "builtin_provider"]) +def test_from_source_setter_reconfirms_source_and_provider_currency(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="from builtins import setattr as replace\nreplace(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + assert calls.idempotent_source_slot(module, terminal, "agents") + if change == "source": + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + else: + (namespace_workspace / "builtins.py").write_text("def setattr(*args):\n return None\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("alias", ["eval", "exec", "compile", "globals", "type", "object"]) +def test_from_source_setter_keeps_imported_alias_metadata_separate_from_unsafe_alias_spelling(namespace_workspace, alias): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"from builtins import setattr as {alias}\n{alias}(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + assert calls._source_slot_edge(module, terminal) is not None + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "Handler", "apply"), ("service", "dispatch", "replace")]) +def test_bare_saved_setter_keeps_initializer_assignment_callee_and_function_roles_distinct(namespace_workspace, family, names): + source, function, alias = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\n{alias} = setattr\n{alias}({source}, '{function}', {source}.{function})\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + home, definition, actual = calls._source_slot_candidate(module, terminal) + assert actual is terminal + ordinary = calls.callers(home, definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + edge = calls._source_slot_edge(module, terminal) + record = edge.bare_setter + assert edge.setter_import is None + assert not hasattr(record, "statement") and not hasattr(record, "imported") and not hasattr(record, "root") + assert record.assignment is module.tree.body[1] + assert record.target is record.assignment.targets[0] + assert record.initializer is record.assignment.value + assert isinstance(record.initializer, ast.Name) and record.initializer.id == "setattr" + assert record.callee is terminal.value.func + assert record.callee is not record.target + assert edge.rhs is terminal.value.args[2] + assert edge.primitive is None and edge.primitive_import is None + assert calls.idempotent_source_slot(module, record.callee, family) + assert not calls.idempotent_source_slot(module, record.initializer, family) + assert calls.source_bare_setter_initializer(module, record.initializer, family) + assert not calls.source_bare_setter_initializer(module, edge.rhs, family) + assert not calls.source_setter_import(module, module.tree.body[0], family) + assert not calls.source_primitive_import(module, module.tree.body[0], family) + assert calls.uncalled_source_slot(home, definition) + assert calls.callers(home, definition, allow_empty=True) is ordinary + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "replace = setattr\nreplace(fake, 'unrelated', fake.Agent)", + "replace = setattr\nreplace(fake, key, fake.Agent)", + "replace = setattr\nreplace(fake, 'Agent', fake.Replacement)", + "replace = setattr\nreplace(fake, 'Agent', fake.Agent, extra=None)", + "replace = setattr\nresult = replace(fake, 'Agent', fake.Agent)", + "replace, other = setattr, None\nreplace(fake, 'Agent', fake.Agent)", + "replace: object = setattr\nreplace(fake, 'Agent', fake.Agent)", + "replace = setattr\nreplace = replacement\nreplace(fake, 'Agent', fake.Agent)", + "import builtins as runtime\nreplace = runtime.setattr\nreplace(fake, 'Agent', fake.Agent)", + "from builtins import setattr as replace\nreplace(fake, 'Agent', fake.Agent, extra=None)", + "setattr(fake, 'Agent', fake.Agent, extra=None)", +]) +def test_bare_saved_setter_keeps_exact_alias_assignment_and_terminal_limits(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for statement in module.tree.body: + if isinstance(statement, ast.Expr) or isinstance(statement, ast.Assign) and isinstance(statement.value, ast.Call): + assert calls._source_slot_edge(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("extra", ["saved = replace\n", "replace(fake, 'Agent', fake.Agent)\n", "fake.Agent([])\n", + "__all__ = ['replace']\n", "getattr(fake, 'Agent')\n"]) +def test_bare_saved_setter_keeps_alias_escapes_calls_and_namespace_uses_visible(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="replace = setattr\nreplace(fake, 'Agent', fake.Agent)", extra=extra, + ) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +def test_bare_saved_setter_cannot_borrow_raw_false_or_receiving_ownership(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions, python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="replace = setattr\nreplace(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + home, definition, _ = calls._source_slot_candidate(module, module.tree.body[2]) + def forbidden(*args, **kwargs): + pytest.fail("the bare saved setter borrowed an ownership shortcut") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert calls.idempotent_source_slot(module, module.tree.body[2], "agents") + assert calls.uncalled_source_slot(home, definition) + monkeypatch.setattr(BuilderCalls, "source_bare_setter_initializer", forbidden) + monkeypatch.setattr(BuilderCalls, "source_setter_import", forbidden) + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + assert python_imports._external_constructor_use( + resolver, module, "agents", {module.path, home.path}, allow_owner_routes=False, + ) is not None + + +def test_bare_saved_setter_requires_terminal_canonical_identity_after_raw_proof(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="replace = setattr\nreplace(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + assert calls.idempotent_source_slot(module, terminal, "agents") + original = list_expressions._namespace_call_reference + raw_original = list_expressions._qualified_attribute_patches + raw_active = False + def raw(view, **kwargs): + nonlocal raw_active + previous = raw_active + raw_active = True + try: + return raw_original(view, **kwargs) + finally: + raw_active = previous + def wrong(view, node): + return "foreign.setattr" if node is terminal.value and not raw_active else original(view, node) + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", raw) + monkeypatch.setattr(list_expressions, "_namespace_call_reference", wrong) + with pytest.raises(CallLimit, match="setter primitive"): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("change", ["source", "builtin_provider"]) +def test_bare_saved_setter_reconfirms_source_and_provider_currency(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="replace = setattr\nreplace(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[2] + assert calls.idempotent_source_slot(module, terminal, "agents") + if change == "source": + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + else: + (namespace_workspace / "builtins.py").write_text("def setattr(*args):\n return None\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "setattr = replacement\nreplace = setattr\nreplace(fake, 'Agent', fake.Agent)", + "from foreign import setattr\nreplace = setattr\nreplace(fake, 'Agent', fake.Agent)", + "replace = setattr\nsetattr = replacement\nreplace(fake, 'Agent', fake.Agent)", +]) +def test_bare_saved_setter_refuses_shadowed_native_initializer(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + terminal = next(n for n in reversed(module.tree.body) if isinstance(n, ast.Expr)) + assert BuilderCalls(resolver)._source_slot_edge(module, terminal) is None + assert not BuilderCalls(resolver).idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("alias", ["eval", "exec", "type", "object"]) +def test_bare_saved_setter_keeps_unsafe_alias_binding_spelling_visible(namespace_workspace, alias): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"{alias} = setattr\n{alias}(fake, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, module.tree.body[2], "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "Handler", "extract", "slots"), ("service", "dispatch", "replace", "mapping")]) +def test_saved_vars_store_keeps_getter_mapping_namespace_and_function_roles_distinct(namespace_workspace, family, names): + source, function, alias, mapping_name = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = f"import {source}\n{alias} = vars\n{mapping_name} = {alias}({source})\n{mapping_name}['{function}'] = {source}.{function}\n" + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + home, definition, actual = calls._source_slot_candidate(module, terminal) + assert actual is terminal + ordinary = calls.callers(home, definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + edge = calls._source_slot_edge(module, terminal) + mapping = edge.parts[5] + getter = mapping.saved_getter + assert mapping.declaration is module.tree.body[2] + assert mapping.target is mapping.declaration.targets[0] + assert mapping.receiver is terminal.targets[0].value + assert mapping.projection is getter.projection is mapping.declaration.value + assert getter.assignment is module.tree.body[1] + assert getter.target is getter.assignment.targets[0] + assert getter.initializer is getter.assignment.value + assert isinstance(getter.initializer, ast.Name) and getter.initializer.id == "vars" + assert getter.callee is getter.projection.func and getter.callee is not getter.target + assert mapping.namespace is getter.projection.args[0] + assert calls._source_slot_projection(module, getter.projection) is None + assert edge.setter_import is None and edge.bare_setter is None and edge.primitive is None + assert calls.idempotent_source_slot(module, terminal, family) + assert calls.idempotent_source_slot(module, getter.callee, family) + assert not calls.idempotent_source_slot(module, getter.initializer, family) + assert calls.source_saved_vars_initializer(module, getter.initializer, family) + assert not calls.source_saved_vars_initializer(module, getter.callee, family) + assert not calls.source_saved_vars_initializer(module, edge.rhs, family) + assert calls.callers(home, definition, allow_empty=True) is ordinary + assert ordinary.limits and not ordinary.sites + assert calls.uncalled_source_slot(home, definition) + + +@pytest.mark.parametrize("write", [ + "extract = vars\nextract(fake)['Agent'] = fake.Agent", + "extract = vars\nslots = extract(fake)\nslots.update({'Agent': fake.Agent})", + "extract = vars\nslots = extract(fake)\nslots.__init__({'Agent': fake.Agent})", + "extract = vars\nslots = extract(fake)\nslots |= {'Agent': fake.Agent}", + "extract = vars\nslots = extract(fake)\nslots.__ior__({'Agent': fake.Agent})", + "extract: object = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + "extract = other = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + "extract = vars\nother = extract\nslots = other(fake)\nslots['Agent'] = fake.Agent", + "extract = builtins.vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + "extract = vars\nslots = extract(fake, other)\nslots['Agent'] = fake.Agent", + "extract = vars\nslots = extract(source=fake)\nslots['Agent'] = fake.Agent", + "extract = vars\nslots = extract(*args)\nslots['Agent'] = fake.Agent", + "extract = vars\nslots = extract(fake)\nslots[key] = fake.Agent", + "extract = vars\nslots = extract(fake)\nslots['other'] = fake.Agent", + "extract = vars\nslots = extract(fake)\nslots['Agent'] = other.Agent", +]) +def test_saved_vars_store_keeps_other_projection_and_mutation_shapes_unproved(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for statement in ast.walk(module.tree): + if isinstance(statement, ast.Assign | ast.Expr | ast.AugAssign): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + + +@pytest.mark.parametrize("extra", [ + "other = extract\n", "other = slots\n", "other = extract(fake)\n", "getattr(fake, 'extract')\n", + "fake.Agent([])\n", "__all__ = ['extract']\n", +]) +def test_saved_vars_store_preserves_getter_mapping_escapes_exports_and_source_calls(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", extra=extra, + ) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +def test_saved_vars_store_cannot_borrow_raw_false_or_receiving_ownership(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions, python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + home, definition, _ = calls._source_slot_candidate(module, module.tree.body[3]) + def forbidden(*args, **kwargs): + pytest.fail("the saved getter borrowed an ownership shortcut") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert calls.idempotent_source_slot(module, module.tree.body[3], "agents") + assert calls.uncalled_source_slot(home, definition) + monkeypatch.setattr(BuilderCalls, "source_saved_vars_initializer", forbidden) + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + assert python_imports._external_constructor_use( + resolver, module, "agents", {module.path, home.path}, allow_owner_routes=False, + ) is not None + + +def test_saved_vars_store_requires_actual_canonical_getter_after_raw_proof(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + projection = module.tree.body[2].value + assert calls.idempotent_source_slot(module, terminal, "agents") + original = list_expressions._namespace_call_reference + raw_original = list_expressions._qualified_attribute_patches + raw_active = False + def raw(view, **kwargs): + nonlocal raw_active + previous = raw_active + raw_active = True + try: + return raw_original(view, **kwargs) + finally: + raw_active = previous + def wrong(view, node): + return "foreign.vars" if node is projection and not raw_active else original(view, node) + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", raw) + monkeypatch.setattr(list_expressions, "_namespace_call_reference", wrong) + with pytest.raises(CallLimit, match="saved getter primitive"): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("change", ["source", "builtin_provider", "getter_importer", "mapping_importer"]) +def test_saved_vars_store_reconfirms_source_provider_and_handle_currency(namespace_workspace, change): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + assert calls.idempotent_source_slot(module, terminal, "agents") + if change == "source": + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + elif change == "builtin_provider": + (namespace_workspace / "builtins.py").write_text("def vars(*args):\n return replacement\n") + else: + name = "extract" if change == "getter_importer" else "slots" + (namespace_workspace / "consumer.py").write_text(f"from entry import {name}\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "vars = replacement\nextract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + "from foreign import vars\nextract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + "extract = vars\nvars = replacement\nslots = extract(fake)\nslots['Agent'] = fake.Agent", +]) +def test_saved_vars_store_refuses_shadowed_builtin_initializer(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + + +@pytest.mark.parametrize("alias", ["eval", "exec", "type", "object"]) +def test_saved_vars_store_keeps_unsafe_getter_alias_spelling_visible(namespace_workspace, alias): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"{alias} = vars\nslots = {alias}(fake)\nslots['Agent'] = fake.Agent", + ) + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], "agents") + assert not resolver._checking_source_module_slot + + +def test_saved_vars_store_raw_producer_evidence_preserves_default_marker_and_first_line(): + from agents_shipgate.inputs.python_imports import MODULE_TABLE_COMPUTED, _attribute_patches + + tree = ast.parse("extract = vars; other = globals()\nmore = globals\n") + producers = {} + ordinary = _attribute_patches(tree) + observed = _attribute_patches(tree, patch_producers=producers) + assert ordinary == observed == {MODULE_TABLE_COMPUTED: 1} + assert producers[MODULE_TABLE_COMPUTED] == {tree.body[0].value, tree.body[2].value, None} + assert _attribute_patches(tree) == ordinary + + +@pytest.mark.parametrize("write,extra", [ + ("extract = vars; other = globals\nslots = extract(fake)\nslots['Agent'] = fake.Agent", ""), + ("extract = vars; other = globals()\nslots = extract(fake)\nslots['Agent'] = fake.Agent", ""), + ("extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", "import sys\nsys.modules[key] = fake\n"), + ("extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", "import builtins\nother = builtins.vars\n"), +]) +def test_saved_vars_store_requires_complete_raw_marker_origin_population(namespace_workspace, write, extra): + from agents_shipgate.inputs.python_imports import MODULE_TABLE_COMPUTED, _attribute_patches + + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write, extra=extra) + calls = BuilderCalls(resolver) + terminals = [node for node in module.tree.body if isinstance(node, ast.Assign) and calls._source_slot_candidate(module, node)] + assert len(terminals) == 1 + terminal = terminals[0] + getter = calls._source_slot_edge(module, terminal).parts[5].saved_getter + producers = {} + assert _attribute_patches(module.tree, patch_producers=producers) == module.attribute_patches + assert producers[MODULE_TABLE_COMPUTED] != {getter.initializer} + with pytest.raises(CallLimit, match="raw package-path or module-namespace mutation markers"): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +def test_saved_vars_store_replays_pending_raw_marker_after_zero_census(namespace_workspace, monkeypatch): + import agents_shipgate.inputs.builder_calls as builder_calls + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + assert calls.idempotent_source_slot(module, terminal, "agents") + original = builder_calls._attribute_patches + replays = 0 + def unknown_on_reconfirm(tree, **kwargs): + nonlocal replays + answer = original(tree, **kwargs) + if tree is module.tree and kwargs.get("patch_producers") is not None: + replays += 1 + if replays == 2: + kwargs["patch_producers"][builder_calls.MODULE_TABLE_COMPUTED].add(None) + return answer + monkeypatch.setattr(builder_calls, "_attribute_patches", unknown_on_reconfirm) + with pytest.raises(CallLimit, match="undischarged saved getter namespace marker"): + calls.idempotent_source_slot(module, terminal, "agents") + assert replays == 2 + assert not resolver._checking_source_module_slot + + +def test_saved_vars_store_table_scan_keeps_actual_module_origin_and_ordinary_markers(namespace_workspace): + from agents_shipgate.inputs.python_imports import MODULE_TABLE_COMPUTED + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + ) + line = module.tree.body[1].lineno + ordinary = dict(module.attribute_patches) + runtime = resolver._runtime_attribute_patches(module) + scan = resolver._patched_names(module.path) + assert ordinary == runtime == {MODULE_TABLE_COMPUTED: line} + assert scan.patched[MODULE_TABLE_COMPUTED] == [(module.ref, line, None)] + assert scan.table_modules[(MODULE_TABLE_COMPUTED, module.ref, line)] == (module,) + assert BuilderCalls(resolver).source_saved_vars_table_marker(module, MODULE_TABLE_COMPUTED, line) + resolution = resolver.resolve(module, "fake.Agent") + assert resolution.resolved and not resolution.caveats + assert module.attribute_patches == ordinary + assert resolver._runtime_attribute_patches(module) is runtime and runtime == ordinary + assert resolver._patched_names(module.path) is scan + assert scan.patched[MODULE_TABLE_COMPUTED] == [(module.ref, line, None)] + + +@pytest.mark.parametrize("provenance", ["missing", "ambiguous", "wrong_module", "copied_module", "duplicate_identity"]) +def test_saved_vars_store_table_consumption_refuses_missing_or_ambiguous_module_origin(namespace_workspace, provenance): + from dataclasses import replace + + from agents_shipgate.inputs.python_imports import MODULE_TABLE_COMPUTED + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + ) + scan = resolver._patched_names(module.path) + row = (MODULE_TABLE_COMPUTED, module.ref, module.tree.body[1].lineno) + if provenance == "missing": + scan.table_modules.clear() + elif provenance == "ambiguous": + scan.table_modules[row] = (module, replace(module)) + elif provenance == "wrong_module": + scan.table_modules[row] = (resolver._patch_scan(namespace_workspace / "fake.py"),) + elif provenance == "copied_module": + scan.table_modules[row] = (replace(module),) + else: + scan.table_modules[row] = (module, module) + resolution = resolver.resolve(module, "fake.Agent") + assert resolution.caveats and any("computed" in caveat for caveat in resolution.caveats) + assert resolver._runtime_attribute_patches(module) == module.attribute_patches + + +@pytest.mark.parametrize("flag", ["_checking_source_module_slot", "_checking_fresh_dictionary_primitive"]) +def test_saved_vars_store_table_marker_refuses_reentry_before_any_model(namespace_workspace, monkeypatch, flag): + from agents_shipgate.inputs.python_imports import MODULE_TABLE_COMPUTED + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + def forbidden(*args, **kwargs): + pytest.fail("a reentered table marker attempted another model") + monkeypatch.setattr(calls, "source_saved_vars_initializer", forbidden) + setattr(resolver, flag, True) + try: + assert not calls.source_saved_vars_table_marker(module, MODULE_TABLE_COMPUTED, module.tree.body[1].lineno) + finally: + setattr(resolver, flag, False) + + +def test_saved_vars_store_table_marker_keeps_unknown_same_line_origin_unread(namespace_workspace, monkeypatch): + from agents_shipgate.inputs.python_imports import MODULE_TABLE_COMPUTED + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="extract = vars; other = globals()\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + ) + calls = BuilderCalls(resolver) + def forbidden(*args, **kwargs): + pytest.fail("an unknown producer reached a source proof") + monkeypatch.setattr(calls, "source_saved_vars_initializer", forbidden) + assert not calls.source_saved_vars_table_marker(module, MODULE_TABLE_COMPUTED, module.tree.body[1].lineno) + assert resolver.resolve(module, "fake.Agent").caveats + + +def test_saved_vars_store_table_admission_rechecks_source_after_a_completed_read(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", + ) + assert not resolver.resolve(module, "fake.Agent").caveats + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + assert resolver.resolve(module, "fake.Agent").caveats + assert not resolver._checking_source_module_slot + + +def test_saved_vars_store_table_marker_retains_nonzero_actual_function_census(namespace_workspace): + from agents_shipgate.inputs.python_imports import MODULE_TABLE_COMPUTED + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="extract = vars\nslots = extract(fake)\nslots['Agent'] = fake.Agent", extra="fake.Agent([])\n", + ) + assert not BuilderCalls(resolver).source_saved_vars_table_marker(module, MODULE_TABLE_COMPUTED, module.tree.body[1].lineno) + assert resolver.resolve(module, "fake.Agent").caveats + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("names", [("producer", "Handler", "slots", "combine"), ("service", "dispatch", "mapping", "apply")]) +def test_saved_bound_ior_keeps_method_mapping_and_actual_function_roles_distinct(namespace_workspace, family, names): + source, function, mapping_name, handle = names + (namespace_workspace / f"{source}.py").write_text(f"def {function}(tools):\n return tools\n") + text = (f"import {source}\n{mapping_name} = {source}.__dict__\n{handle} = {mapping_name}.__ior__\n" + f"{handle}({{'{function}': {source}.{function}}})\n") + path = namespace_workspace / "entry.py" + path.write_text(text) + resolver = ImportResolver(namespace_workspace) + module = resolver.entry(path, ast.parse(text), text) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + edge = calls._source_slot_edge(module, terminal) + mapping, method = edge.parts[5], edge.saved_bound_ior + assert mapping.declaration is module.tree.body[1] + assert mapping.projection is mapping.declaration.value + assert method.assignment is module.tree.body[2] + assert method.target is method.assignment.targets[0] + assert method.initializer is method.assignment.value + assert mapping.receiver is method.receiver is method.initializer.value + assert method.callee is edge.callee is terminal.value.func + assert method.call is edge.call is terminal.value + assert method.payload is edge.payload is terminal.value.args[0] + assert method.callee is not method.target and method.initializer is not method.callee + assert edge.rhs is method.payload.values[0] and edge.parts[4] is method.payload.keys[0] + assert edge.primitive is None and edge.setter_import is None and edge.bare_setter is None + home, definition, actual = calls._source_slot_candidate(module, terminal) + assert actual is terminal + ordinary = calls.callers(home, definition, allow_empty=True) + assert ordinary.limits and not ordinary.sites + assert calls.idempotent_source_slot(module, terminal, family) + assert calls.idempotent_source_slot(module, method.callee, family) + assert calls.idempotent_source_slot(module, mapping.receiver, family) + assert not calls.idempotent_source_slot(module, method.initializer, family) + assert calls.source_saved_bound_ior_initializer(module, method.initializer, family) + assert not calls.source_saved_bound_ior_initializer(module, method.callee, family) + assert calls.callers(home, definition, allow_empty=True) is ordinary + assert ordinary.limits and not ordinary.sites + assert calls.uncalled_source_slot(home, definition) + + +@pytest.mark.parametrize("write", [ + "slots = vars(fake)\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})", + "slots = getattr(fake, '__dict__')\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})", + "slots = fake.__dict__\ncombine = slots.update\ncombine({'Agent': fake.Agent})", + "slots = fake.__dict__\ncombine = slots.__init__\ncombine({'Agent': fake.Agent})", + "slots = fake.__dict__\ncombine: object = slots.__ior__\ncombine({'Agent': fake.Agent})", + "slots = fake.__dict__\ncombine = other = slots.__ior__\ncombine({'Agent': fake.Agent})", + "slots = fake.__dict__\ncombine = slots.__ior__\nother = combine\nother({'Agent': fake.Agent})", + "slots = fake.__dict__\ncombine = slots.__ior__\nresult = combine({'Agent': fake.Agent})", + "slots = fake.__dict__\ncombine = slots.__ior__\nconsume(combine({'Agent': fake.Agent}))", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'other': fake.Agent})", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({key: fake.Agent})", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': other.Agent})", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent, 'Other': fake.Agent})", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine(Agent=fake.Agent)", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine(*args)", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})\ncombine({'Agent': fake.Agent})", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})\ncopy = combine", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})\ncopy = slots", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})\ndef capture():\n return combine", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})\ndef capture():\n return slots", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})\ndel combine", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})\ndel slots", + "slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})\n__all__ = ['combine']", +]) +def test_saved_bound_ior_keeps_other_shapes_and_either_lifetime_escape_unproved(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + for statement in module.tree.body: + if isinstance(statement, ast.Expr): + assert calls._source_slot_candidate(module, statement) is None + assert not calls.idempotent_source_slot(module, statement, "agents") + + +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "import builtins\nbuiltins.dict.__ior__ = other\n", "fake.__class__ = other\n"]) +def test_saved_bound_ior_preserves_nonzero_calls_and_native_provider_changes(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})", extra=extra, + ) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], "agents") + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("name", ["slots", "combine"]) +def test_saved_bound_ior_preserves_mapping_and_method_importer_obligations(namespace_workspace, name): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})", + ) + (namespace_workspace / "consumer.py").write_text(f"from entry import {name}\n") + with pytest.raises(CallLimit, match="imported intrinsic source mapping namespace"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], "agents") + + +def test_saved_bound_ior_requires_full_actual_wildcard_producer_bag(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + assert calls.idempotent_source_slot(module, terminal, "agents") + raw = list_expressions._qualified_attribute_patches + def ambiguous(view, **kwargs): + patches = raw(view, **kwargs) + if view.module is module and kwargs.get("patch_producers") is not None: + kwargs["patch_producers"].setdefault("fake.*", set()).add(None) + return patches + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", ambiguous) + with pytest.raises(CallLimit, match="wildcard provenance"): + calls.idempotent_source_slot(module, terminal, "agents") + assert not resolver._checking_source_module_slot + + +def test_saved_bound_ior_retains_default_raw_maps_and_cannot_borrow_owner_callbacks(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions, python_imports + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})", + ) + assert module.attribute_patches == {} + cached = resolver._runtime_attribute_patches(module) + calls = BuilderCalls(resolver) + home, definition, _ = calls._source_slot_candidate(module, module.tree.body[3]) + def forbidden(*args, **kwargs): + pytest.fail("the saved method borrowed a receiving-owner shortcut") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", + "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert calls.idempotent_source_slot(module, module.tree.body[3], "agents") + assert resolver._runtime_attribute_patches(module) is cached and cached == {} + monkeypatch.setattr(BuilderCalls, "source_saved_bound_ior_initializer", forbidden) + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + assert python_imports._external_constructor_use( + resolver, module, "agents", {module.path, home.path}, allow_owner_routes=False, + ) is not None + + +def test_saved_bound_ior_replays_entire_origin_bag_after_zero_census(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})", + ) + census = BuilderCalls._census + raw = list_expressions._qualified_attribute_patches + finished = False + def complete(self, *args, **kwargs): + nonlocal finished + result = census(self, *args, **kwargs) + finished = True + return result + def changed(view, **kwargs): + patches = raw(view, **kwargs) + if finished and view.module is module and kwargs.get("patch_producers") is not None: + kwargs["patch_producers"].setdefault("fake.*", set()).add(None) + return patches + monkeypatch.setattr(BuilderCalls, "_census", complete) + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", changed) + with pytest.raises(CallLimit, match="undischarged saved method wildcard"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], "agents") + assert finished and not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("flag", ["_checking_source_module_slot", "_checking_fresh_dictionary_primitive"]) +def test_saved_bound_ior_initializer_refuses_active_reentry_before_model(namespace_workspace, monkeypatch, flag): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})", + ) + setattr(resolver, flag, True) + def forbidden(*args, **kwargs): + pytest.fail("the saved initializer attempted a model under reentry") + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", forbidden) + assert not BuilderCalls(resolver).source_saved_bound_ior_initializer(module, module.tree.body[2].value, "agents") + assert getattr(resolver, flag) + + +def test_saved_bound_ior_rechecks_source_currency_after_completed_proof(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})", + ) + calls = BuilderCalls(resolver) + assert calls.idempotent_source_slot(module, module.tree.body[3], "agents") + (namespace_workspace / "fake.py").write_text("def Agent(tools):\n return replacement\n") + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, module.tree.body[3], "agents") + assert not resolver._checking_source_module_slot + + +def test_saved_bound_ior_rejects_a_new_replayed_hook_with_selected_producer_intact(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'Agent': fake.Agent})", + ) + census = BuilderCalls._census + raw = list_expressions._qualified_attribute_patches + finished = False + def complete(self, *args, **kwargs): + nonlocal finished + result = census(self, *args, **kwargs) + finished = True + return result + def new_hook(view, **kwargs): + patches = raw(view, **kwargs) + if finished and view.module is module and kwargs.get("patch_producers") is not None: + initializer = module.tree.body[2].value + assert kwargs["patch_producers"]["fake.*"] == {initializer} + patches.add("fake.__getattribute__") + kwargs["patch_producers"]["fake.__getattribute__"] = {module.tree.body[0]} + return patches + monkeypatch.setattr(BuilderCalls, "_census", complete) + monkeypatch.setattr(list_expressions, "_qualified_attribute_patches", new_hook) + with pytest.raises(CallLimit, match="undischarged saved method wildcard"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], "agents") + assert finished and not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("function", ["update", "clear", "pop"]) +def test_saved_bound_ior_closes_only_the_selected_actual_function_read_origin(namespace_workspace, family, function): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, home=f"def {function}(tools):\n return tools\n", + write=f"slots = fake.__dict__\ncombine = slots.__ior__\ncombine({{'{function}': fake.{function}}})", + ) + calls = BuilderCalls(resolver) + terminal = module.tree.body[3] + edge = calls._source_slot_edge(module, terminal) + home, definition, selected = calls._source_slot_candidate(module, terminal) + assert definition.name == function and selected is terminal + view = list_expressions._View(module.ref, module.tree, calls.scopes(module), module.bindings, module, set()) + view.lookup = list_expressions.bindings_at(view.scopes, view.bindings) + view.resolver = resolver + origins = {} + patches = list_expressions._qualified_attribute_patches(view, raw_namespaces=True, patch_producers=origins) + expected = {edge.saved_bound_ior.initializer, edge.rhs} + assert origins["fake.*"] == expected and not view.namespace_builtins_changed + assert calls.idempotent_source_slot(module, terminal, family) + assert calls.source_saved_bound_ior_initializer(module, edge.saved_bound_ior.initializer, family) + assert calls.uncalled_source_slot(home, definition) + after = {} + assert list_expressions._qualified_attribute_patches(view, raw_namespaces=True, patch_producers=after) == patches + assert after == origins and module.attribute_patches == {} + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_saved_bound_ior_function_read_origin_does_not_discharge_a_function_call(namespace_workspace, family): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, home="def update(tools):\n return tools\n", + write="slots = fake.__dict__\ncombine = slots.__ior__\ncombine({'update': fake.update})", + extra="fake.update([])\n", + ) + try: + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], family) + except CallLimit: + pass + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("change", ["caller", "nested_caller", "empty_directory", "removed_file", "file_to_directory", "directory_to_file", "venv_added", "venv_removed", "venv_kind"]) +def test_source_slot_reconfirms_consumed_inventory_after_zero_census(namespace_workspace, monkeypatch, change): + from agents_shipgate.core.static_inputs import active_static_input_snapshot + + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace) + assert active_static_input_snapshot() is None + (namespace_workspace / "quiet.py").write_text("# unrelated source\n") + nested = namespace_workspace / "nested" + nested.mkdir() + venv = namespace_workspace / "environment" + venv.mkdir() + marker = venv / "pyvenv.cfg" + if change in {"venv_removed", "venv_kind"}: + marker.write_text("home = unused\n") + (venv / "caller.py").write_text("import fake\nfake.Agent([])\n") + original = BuilderCalls._census + changed = False + def census(self, *args, **kwargs): + nonlocal changed + result = original(self, *args, **kwargs) + if kwargs.get("source_slot_values") and not changed: + assert not result.sites and not result.limits + changed = True + if change == "caller": + (namespace_workspace / "late.py").write_text("import fake\nfake.Agent([])\n") + elif change == "nested_caller": + (nested / "late.py").write_text("import fake\nfake.Agent([])\n") + elif change == "empty_directory": + (namespace_workspace / "late").mkdir() + elif change == "removed_file": + (namespace_workspace / "quiet.py").unlink() + elif change == "file_to_directory": + (namespace_workspace / "quiet.py").unlink() + (namespace_workspace / "quiet.py").mkdir() + elif change == "directory_to_file": + nested.rmdir() + nested.write_text("# changed kind\n") + elif change == "venv_added": + marker.write_text("home = unused\n") + elif change == "venv_removed": + marker.unlink() + else: + marker.unlink() + marker.mkdir() + return result + monkeypatch.setattr(BuilderCalls, "_census", census) + with pytest.raises(CallLimit, match="caller inventory"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[1].targets[0].value, "agents") + assert changed and not resolver._checking_source_module_slot + + +def test_cached_inventory_keeps_original_currency_obligations(namespace_workspace): + resolver, _, _ = _source_dictionary_slot_fixture(namespace_workspace) + calls = BuilderCalls(resolver) + first = calls._inventory() + assert calls._inventory() is first + calls._namespace_directories = {} + calls._namespace_directory_currency() + (namespace_workspace / "late.py").write_text("import fake\nfake.Agent([])\n") + assert calls._inventory() is first and all(path.name != "late.py" for path in first) + with pytest.raises(CallLimit, match="caller inventory"): + calls._namespace_directory_currency() + + + +def test_inventory_currency_rejects_new_names_before_reading_their_kinds(namespace_workspace, monkeypatch): + resolver, _, _ = _source_dictionary_slot_fixture(namespace_workspace) + calls = BuilderCalls(resolver) + calls._inventory() + calls._namespace_directories = {} + late = namespace_workspace / "late.py" + late.write_text("import fake\nfake.Agent([])\n") + original = Path.lstat + def lstat(path, *args, **kwargs): + if path == late: + raise AssertionError("a new entry kind was read before name drift was refused") + return original(path, *args, **kwargs) + monkeypatch.setattr(Path, "lstat", lstat) + with pytest.raises(CallLimit, match="caller inventory"): + calls._namespace_directory_currency() + + +@pytest.mark.parametrize("excluded", ["tests", "node_modules"]) +def test_inventory_currency_does_not_expand_consumed_source_scope(namespace_workspace, excluded): + resolver, _, _ = _source_dictionary_slot_fixture(namespace_workspace) + ignored = namespace_workspace / excluded + if excluded == "tests": + ignored /= "fixtures" # The listing of tests itself is consumed; this child directory is skipped. + ignored.mkdir(parents=True) + (ignored / "unread.py").write_text("import fake\nfake.Agent([])\n") + calls = BuilderCalls(resolver) + assert ignored / "unread.py" not in calls._inventory() + calls._namespace_directories = {} + (ignored / "late.py").write_text("unknown framework contents\n") + calls._namespace_directory_currency() + + + +def test_inventory_currency_keeps_consumed_test_directory_membership(namespace_workspace): + resolver, _, _ = _source_dictionary_slot_fixture(namespace_workspace) + directory = namespace_workspace / "tests" + directory.mkdir() + (directory / "unread.py").write_text("import fake\nfake.Agent([])\n") + calls = BuilderCalls(resolver) + assert directory / "unread.py" not in calls._inventory() + calls._namespace_directories = {} + calls._namespace_directory_currency() + (directory / "late.py").write_text("import fake\nfake.Agent([])\n") + with pytest.raises(CallLimit, match="caller inventory"): + calls._namespace_directory_currency() + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("function", ["Agent", "change", "update", "operator"]) +def test_direct_operator_setitem_keeps_actual_primitive_projection_and_function_roles(namespace_workspace, family, function): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, home=f"def {function}(tools):\n return tools\n", + write=f"import operator\noperator.setitem(fake.__dict__, '{function}', fake.{function})", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[2] + record = calls._source_operator_setitem(module, statement) + assert record.statement is module.tree.body[1] and record.imported is record.statement.names[0] + assert record.call is statement.value and record.callee is record.call.func and record.root is record.callee.value + assert record.projection is record.call.args[0] and record.key is record.call.args[1] and record.rhs is record.call.args[2] + edge = calls._source_slot_edge(module, statement) + assert edge.operator_setitem is record or edge.operator_setitem == record + assert edge.setter_import is None and edge.primitive_import is None and edge.saved_bound_ior is None + for node in (statement, record.call, record.callee, record.root, record.projection, record.key, record.rhs): + assert calls.idempotent_source_slot(module, node, family) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "import operator as op\nop.setitem(fake.__dict__, 'Agent', fake.Agent)", + "from operator import setitem\nsetitem(fake.__dict__, 'Agent', fake.Agent)", + "import operator\nchange = operator.setitem\nchange(fake.__dict__, 'Agent', fake.Agent)", + "import operator\nslots = fake.__dict__\noperator.setitem(slots, 'Agent', fake.Agent)", + "import operator\noperator.ior(fake.__dict__, {'Agent': fake.Agent})", + "import operator\nresult = operator.setitem(fake.__dict__, 'Agent', fake.Agent)", + "import operator\noperator.setitem(fake.__dict__, key, fake.Agent)", + "import operator\noperator.setitem(fake.__dict__, 'Other', fake.Agent)", + "import operator\noperator.setitem(fake.__dict__, 'Agent', replacement)", + "import operator\noperator.setitem(fake.__dict__, 'Agent', fake.Agent, **extra)", + "import operator\noperator.setitem(fake.__dict__, 'Agent', fake.Agent)\nconsume(operator)", + "import operator\noperator.setitem(fake.__dict__, 'Agent', fake.Agent)\ndef capture():\n return operator", + "import operator\noperator.setitem(fake.__dict__, 'Agent', fake.Agent)\noperator.setitem = replacement", +]) +def test_direct_operator_setitem_leaves_aliases_other_primitives_and_escapes_unproved(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + assert not any(calls._source_operator_setitem(module, statement) for statement in module.tree.body) + + +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "import operator\noperator.setitem = replacement\n"]) +def test_direct_operator_setitem_preserves_calls_and_foreign_native_patches(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write="import operator\noperator.setitem(fake.__dict__, 'Agent', fake.Agent)") + (namespace_workspace / "other.py").write_text("import fake\n" + extra) + calls = BuilderCalls(resolver) + if extra == "fake.Agent([])\n": + assert not calls.idempotent_source_slot(module, module.tree.body[2], "agents") + else: + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, module.tree.body[2], "agents") + assert not resolver._checking_source_module_slot + + +def test_direct_operator_setitem_keeps_generic_context_unsupported(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write="import operator\noperator.setitem(fake.__dict__, 'Agent', fake.Agent)") + with pytest.raises(CallLimit): + BuilderCalls(resolver).namespace_source_context("agents") + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("provider", ["file", "package", "link"]) +def test_direct_operator_setitem_requires_absent_native_provider(namespace_workspace, family, provider): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write="import operator\noperator.setitem(fake.__dict__, 'Agent', fake.Agent)") + if provider == "package": + path = namespace_workspace / "operator" + path.mkdir() + (path / "__init__.py").write_text("def setitem(*args):\n return None\n") + elif provider == "file": + (namespace_workspace / "operator.py").write_text("def setitem(*args):\n return None\n") + else: + target = namespace_workspace / "counterfeit.txt" + target.write_text("def setitem(*args):\n return None\n") + (namespace_workspace / "operator.py").symlink_to(target) + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], family) + + +@pytest.mark.parametrize("home", [ + "def Agent(tools):\n return tools\ndef __getattr__(name):\n return replacement\n", + "def Agent(tools):\n return tools\n__class__ = replacement\n", + "class Agent:\n pass\n", +]) +def test_direct_operator_setitem_cannot_prove_a_custom_module_or_class_field(namespace_workspace, home): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, home=home, write="import operator\noperator.setitem(fake.__dict__, 'Agent', fake.Agent)") + try: + result = BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + except CallLimit: + result = False + assert not result + + +def test_direct_operator_setitem_cannot_borrow_receiving_callbacks(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write="import operator\noperator.setitem(fake.__dict__, 'Agent', fake.Agent)") + def forbidden(*args, **kwargs): + raise AssertionError("the independent operator proof borrowed a receiving callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], "agents") + + +@pytest.mark.parametrize("repetitions", [1, 200]) +def test_direct_operator_candidate_extraction_uses_structural_positions(namespace_workspace, repetitions): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import operator\n" + "operator.setitem(fake.__dict__, 'Agent', fake.Agent)\n" * repetitions, + ) + calls = BuilderCalls(resolver) + calls.scopes(module) + class Body(list): + def __contains__(self, value): + raise AssertionError("candidate extraction performed a linear body membership scan") + def index(self, *args, **kwargs): + raise AssertionError("candidate extraction performed a linear body index scan") + module.tree.body = Body(module.tree.body) # Same physical parsed nodes, instrument only the container. + records = [calls._source_operator_setitem(module, statement) for statement in module.tree.body] + assert sum(record is not None for record in records) == (1 if repetitions == 1 else 0) + assert len(calls._source_operator_positions[module.tree]) == len(module.tree.body) + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("change", ["caller", "same_size_caller", "home", "writer", "expanded", "same_size_expanded"]) +def test_source_slot_reconfirms_consumed_text_after_zero_census(namespace_workspace, monkeypatch, family, change): + from agents_shipgate.core.static_inputs import active_static_input_snapshot + + assert active_static_input_snapshot() is None + quiet = namespace_workspace / ("test_helper.py" if "expanded" in change else "other.py") + initial = "#" + " " * 199 + "\n" + quiet.write_text(initial) + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import operator\noperator.setitem(fake.__dict__, 'Agent', fake.Agent)", + extra="import test_helper\n" if "expanded" in change else "", + ) + original = BuilderCalls._census + changed = False + def census(self, *args, **kwargs): + nonlocal changed + result = original(self, *args, **kwargs) + if kwargs.get("source_slot_values") and not changed: + assert not result.sites and not result.limits + assert quiet in self._texts + changed = True + if change == "home": + path = namespace_workspace / "fake.py" + path.write_text(path.read_text() + "import fake\nfake.Agent([])\n") + elif change == "writer": + path = namespace_workspace / "entry.py" + path.write_text(path.read_text() + "fake.Agent([])\n") + else: + replacement = "import fake\nfake.Agent([])\n" + if change.startswith("same_size"): + replacement += "#" + " " * (len(initial.encode("utf-8")) - len(replacement.encode("utf-8")) - 2) + "\n" + assert len(replacement.encode("utf-8")) == len(initial.encode("utf-8")) + quiet.write_text(replacement) + return result + monkeypatch.setattr(BuilderCalls, "_census", census) + with pytest.raises(CallLimit, match="namespace source bytes"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[2], family) + assert changed and not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("change", ["invalid_utf8", "grown_before_loader"]) +def test_consumed_text_currency_refuses_unread_or_grown_input(namespace_workspace, monkeypatch, change): + from agents_shipgate.inputs import builder_calls + + quiet = namespace_workspace / "other.py" + quiet.write_text("# quiet\n") + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace) + original = BuilderCalls._census + loader = builder_calls.load_text_file + changed = False + def load(path): + if changed and path == quiet and change == "grown_before_loader": + raise AssertionError("grown consumed bytes reached the loader before size refusal") + return loader(path) + def census(self, *args, **kwargs): + nonlocal changed + result = original(self, *args, **kwargs) + if kwargs.get("source_slot_values") and not changed: + assert not result.sites and not result.limits + assert quiet in self._texts + changed = True + quiet.write_bytes(b"\xff" * 8 if change == "invalid_utf8" else b"#" * 100) + return result + monkeypatch.setattr(builder_calls, "load_text_file", load) + monkeypatch.setattr(BuilderCalls, "_census", census) + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[1], "agents") + assert changed and not resolver._checking_source_module_slot + + +def test_consumed_text_currency_reconfirms_cached_bytes_without_replacing_them(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import builder_calls + + quiet = namespace_workspace / "other.py" + quiet.write_text("# unchanged µ\n") + resolver, _, _ = _source_dictionary_slot_fixture(namespace_workspace) + calls = BuilderCalls(resolver) + calls.namespace_source_context("agents") + captured = dict(calls._texts) + accounted = calls._read_bytes + loader = builder_calls.load_text_file + reads = [] + def load(path): + reads.append(path) + return loader(path) + monkeypatch.setattr(builder_calls, "load_text_file", load) + for _ in range(2): + calls._namespace_directory_currency() + assert calls._texts == captured and calls._read_bytes == accounted + assert reads.count(quiet) == 2 + + +@pytest.mark.parametrize("excluded", ["tests/test_quiet.py", "node_modules/quiet.py"]) +def test_consumed_text_currency_does_not_read_unconsumed_files(namespace_workspace, monkeypatch, excluded): + path = namespace_workspace / excluded + path.parent.mkdir() + path.write_text("# quiet\n") + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace) + original = BuilderCalls._census + changed = False + def census(self, *args, **kwargs): + nonlocal changed + result = original(self, *args, **kwargs) + if kwargs.get("source_slot_values") and not changed: + assert not result.sites and not result.limits + assert path not in self._texts + changed = True + path.write_text("import fake\nfake.Agent([])\n") + return result + monkeypatch.setattr(BuilderCalls, "_census", census) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[1], "agents") + assert changed and not resolver._checking_source_module_slot + + +def test_consumed_text_currency_checks_aggregate_bound_before_loading(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import builder_calls + + resolver, _, _ = _source_dictionary_slot_fixture(namespace_workspace) + calls = BuilderCalls(resolver) + calls.namespace_source_context("agents") + monkeypatch.setattr(builder_calls, "MAX_TOTAL_BYTES", calls._read_bytes - 1) + def forbidden(path): + raise AssertionError("an over-budget consumed population reached the loader") + monkeypatch.setattr(builder_calls, "load_text_file", forbidden) + with pytest.raises(CallLimit, match="source bytes"): + calls._namespace_directory_currency() + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("name", ["Agent", "update", "ior", "operator"]) +def test_direct_operator_ior_keeps_actual_saved_mapping_and_function_roles(namespace_workspace, family, name): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, home=f"def {name}(tools):\n return tools\n", + write=f"import operator\nslots = fake.__dict__\noperator.ior(slots, {{'{name}': fake.{name}}})", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[3] + edge = calls._source_slot_edge(module, statement) + assert edge is not None and edge.operator_ior is not None + record = edge.operator_ior + assert record.call is statement.value and record.mapping.receiver is record.call.args[0] + assert record.mapping.declaration is module.tree.body[2] + assert record.payload is record.call.args[1] and record.rhs is record.payload.values[0] + assert edge.operator_setitem is None and edge.saved_bound_ior is None and edge.primitive_import is None + for node in (statement, record.call, record.callee, record.root, record.mapping.receiver, + record.mapping.projection, record.key, record.rhs): + assert calls.idempotent_source_slot(module, node, family) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "import operator as op\nslots = fake.__dict__\nop.ior(slots, {'Agent': fake.Agent})", + "from operator import ior\nslots = fake.__dict__\nior(slots, {'Agent': fake.Agent})", + "import operator\nslots = fake.__dict__\nchange = operator.ior\nchange(slots, {'Agent': fake.Agent})", + "import operator\nslots = vars(fake)\noperator.ior(slots, {'Agent': fake.Agent})", + "import operator\nslots = getattr(fake, '__dict__')\noperator.ior(slots, {'Agent': fake.Agent})", + "import operator\nother = fake\nslots = other.__dict__\noperator.ior(slots, {'Agent': fake.Agent})", + "import operator\nslots = fake.__dict__\nsecond = slots\noperator.ior(second, {'Agent': fake.Agent})", + "import operator\nslots: dict = fake.__dict__\noperator.ior(slots, {'Agent': fake.Agent})", + "import operator\nslots = second = fake.__dict__\noperator.ior(slots, {'Agent': fake.Agent})", + "import operator\nslots = fake.__dict__\nslots = replacement\noperator.ior(slots, {'Agent': fake.Agent})", + "import operator\nslots = fake.__dict__\nresult = operator.ior(slots, {'Agent': fake.Agent})", + "import operator\nslots = fake.__dict__\noperator.ior(slots, {'Agent': fake.Agent}, extra)", + "import operator\nslots = fake.__dict__\noperator.ior(slots, {'Agent': fake.Agent}, **extra)", + "import operator\nslots = fake.__dict__\noperator.ior(*slots, {'Agent': fake.Agent})", + "import operator\nslots = fake.__dict__\noperator.ior(slots, {**extra, 'Agent': fake.Agent})", + "import operator\nslots = fake.__dict__\noperator.ior(slots, {key: fake.Agent})", + "import operator\nslots = fake.__dict__\noperator.ior(slots, {'Other': fake.Agent})", + "import operator\nslots = fake.__dict__\noperator.ior(slots, {'Agent': replacement})", + "import operator\nslots = fake.__dict__\noperator.ior(slots, {'Agent': fake.Agent})\nconsume(slots)", + "import operator\nslots = fake.__dict__\noperator.ior(slots, {'Agent': fake.Agent})\ndef capture():\n return slots", + "import operator\nslots = fake.__dict__\noperator.ior(slots, {'Agent': fake.Agent})\nconsume(operator)", +]) +def test_direct_operator_ior_does_not_admit_other_grammar_or_escapes(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + assert not any(calls._source_operator_ior(module, statement) for statement in module.tree.body) + + +@pytest.mark.parametrize("provider", ["file", "package", "link"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_direct_operator_ior_requires_absent_native_provider(namespace_workspace, family, provider): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import operator\nslots = fake.__dict__\noperator.ior(slots, {'Agent': fake.Agent})", + ) + if provider == "package": + path = namespace_workspace / "operator" + path.mkdir() + (path / "__init__.py").write_text("def ior(*args):\n return None\n") + elif provider == "file": + (namespace_workspace / "operator.py").write_text("def ior(*args):\n return None\n") + else: + target = namespace_workspace / "counterfeit.txt" + target.write_text("def ior(*args):\n return None\n") + (namespace_workspace / "operator.py").symlink_to(target) + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], family) + + +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "import operator\noperator.ior = replacement\n"]) +def test_direct_operator_ior_preserves_real_calls_and_native_patches(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import operator\nslots = fake.__dict__\noperator.ior(slots, {'Agent': fake.Agent})", + ) + (namespace_workspace / "other.py").write_text("import fake\n" + extra) + calls = BuilderCalls(resolver) + if extra == "fake.Agent([])\n": + assert not calls.idempotent_source_slot(module, module.tree.body[3], "agents") + else: + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, module.tree.body[3], "agents") + assert not resolver._checking_source_module_slot + + +def test_direct_operator_ior_keeps_generic_context_and_receiving_callbacks_separate(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import operator\nslots = fake.__dict__\noperator.ior(slots, {'Agent': fake.Agent})", + ) + with pytest.raises(CallLimit): + BuilderCalls(resolver).namespace_source_context("agents") + def forbidden(*args, **kwargs): + raise AssertionError("the independent operator proof borrowed a receiving callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], "agents") + + +@pytest.mark.parametrize("repetitions", [1, 200]) +def test_direct_operator_ior_candidate_extraction_uses_structural_positions(namespace_workspace, repetitions): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import operator\nslots = fake.__dict__\n" + "operator.ior(slots, {'Agent': fake.Agent})\n" * repetitions, + ) + calls = BuilderCalls(resolver) + calls.scopes(module) + class Body(list): + def __contains__(self, value): + raise AssertionError("candidate extraction performed a linear body membership scan") + def index(self, *args, **kwargs): + raise AssertionError("candidate extraction performed a linear body index scan") + module.tree.body = Body(module.tree.body) + records = [calls._source_operator_ior(module, statement) for statement in module.tree.body] + assert sum(record is not None for record in records) == (1 if repetitions == 1 else 0) + assert len(calls._source_operator_positions[module.tree]) == len(module.tree.body) + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("name", ["Agent", "update", "ior", "operator"]) +def test_from_operator_ior_keeps_actual_saved_mapping_and_function_roles(namespace_workspace, family, name): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, home=f"def {name}(tools):\n return tools\n", + write=f"from operator import ior as combine\nslots = fake.__dict__\ncombine(slots, {{'{name}': fake.{name}}})", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[3] + edge = calls._source_slot_edge(module, statement) + assert edge is not None and edge.from_operator_ior is not None + record = edge.from_operator_ior + assert record.call is statement.value and record.mapping.receiver is record.call.args[0] + assert record.mapping.declaration is module.tree.body[2] + assert record.payload is record.call.args[1] and record.rhs is record.payload.values[0] + assert edge.operator_setitem is None and edge.saved_bound_ior is None and edge.primitive_import is None + for node in (statement, record.call, record.callee, record.mapping.receiver, + record.mapping.projection, record.key, record.rhs): + assert calls.idempotent_source_slot(module, node, family) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("provider", ["file", "package", "link"]) +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_from_operator_ior_requires_absent_native_provider(namespace_workspace, family, provider): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="from operator import ior as combine\nslots = fake.__dict__\ncombine(slots, {'Agent': fake.Agent})", + ) + if provider == "package": + path = namespace_workspace / "operator" + path.mkdir() + (path / "__init__.py").write_text("def ior(*args):\n return None\n") + elif provider == "file": + (namespace_workspace / "operator.py").write_text("def ior(*args):\n return None\n") + else: + target = namespace_workspace / "counterfeit.txt" + target.write_text("def ior(*args):\n return None\n") + (namespace_workspace / "operator.py").symlink_to(target) + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], family) + + +@pytest.mark.parametrize("extra", ["fake.Agent([])\n", "import operator\noperator.ior = replacement\n"]) +def test_from_operator_ior_preserves_real_calls_and_native_patches(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="from operator import ior as combine\nslots = fake.__dict__\ncombine(slots, {'Agent': fake.Agent})", + ) + (namespace_workspace / "other.py").write_text("import fake\n" + extra) + calls = BuilderCalls(resolver) + if extra == "fake.Agent([])\n": + assert not calls.idempotent_source_slot(module, module.tree.body[3], "agents") + else: + with pytest.raises(CallLimit): + calls.idempotent_source_slot(module, module.tree.body[3], "agents") + assert not resolver._checking_source_module_slot + + +def test_from_operator_ior_keeps_generic_context_and_receiving_callbacks_separate(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="from operator import ior as combine\nslots = fake.__dict__\ncombine(slots, {'Agent': fake.Agent})", + ) + with pytest.raises(CallLimit): + BuilderCalls(resolver).namespace_source_context("agents") + def forbidden(*args, **kwargs): + raise AssertionError("the independent operator proof borrowed a receiving callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[3], "agents") + + +@pytest.mark.parametrize("repetitions", [1, 200]) +def test_from_operator_ior_candidate_extraction_uses_structural_positions(namespace_workspace, repetitions): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="from operator import ior as combine\nslots = fake.__dict__\n" + "combine(slots, {'Agent': fake.Agent})\n" * repetitions, + ) + calls = BuilderCalls(resolver) + calls.scopes(module) + class Body(list): + def __contains__(self, value): + raise AssertionError("candidate extraction performed a linear body membership scan") + def index(self, *args, **kwargs): + raise AssertionError("candidate extraction performed a linear body index scan") + module.tree.body = Body(module.tree.body) + records = [calls._source_from_operator_ior(module, statement) for statement in module.tree.body] + assert sum(record is not None for record in records) == (1 if repetitions == 1 else 0) + assert len(calls._source_operator_positions[module.tree]) == len(module.tree.body) + + + +@pytest.mark.parametrize("write", [ + "from operator import ior\nslots = fake.__dict__\nior(slots, {'Agent': fake.Agent})", + "from operator import ior as vars\nslots = fake.__dict__\nvars(slots, {'Agent': fake.Agent})", + "from operator import ior as Agent\nslots = fake.__dict__\nAgent(slots, {'Agent': fake.Agent})", + "from operator import ior as fake\nslots = fake.__dict__\nfake(slots, {'Agent': fake.Agent})", + "from operator import ior as slots\nslots = fake.__dict__\nslots(slots, {'Agent': fake.Agent})", + "from operator import ior as combine, setitem\nslots = fake.__dict__\ncombine(slots, {'Agent': fake.Agent})", + "from .operator import ior as combine\nslots = fake.__dict__\ncombine(slots, {'Agent': fake.Agent})", + "from other import ior as combine\nslots = fake.__dict__\ncombine(slots, {'Agent': fake.Agent})", + "from operator import setitem as combine\nslots = fake.__dict__\ncombine(slots, {'Agent': fake.Agent})", + "from operator import ior as combine\nslots = fake.__dict__\ncombine = replacement\ncombine(slots, {'Agent': fake.Agent})", + "from operator import ior as combine\nslots = fake.__dict__\nsecond = combine\nsecond(slots, {'Agent': fake.Agent})", + "from operator import ior as combine\nslots = fake.__dict__\nresult = combine(slots, {'Agent': fake.Agent})", + "from operator import ior as combine\nslots = vars(fake)\ncombine(slots, {'Agent': fake.Agent})", + "from operator import ior as combine\nslots = fake.__dict__\ncombine(slots, {'Agent': fake.Agent}, **extra)", + "from operator import ior as combine\nslots = fake.__dict__\ncombine(slots, {**extra, 'Agent': fake.Agent})", + "from operator import ior as combine\nslots = fake.__dict__\ncombine(slots, {'Other': fake.Agent})", + "from operator import ior as combine\nslots = fake.__dict__\ncombine(slots, {'Agent': replacement})", + "from operator import ior as combine\nslots = fake.__dict__\ncombine(slots, {'Agent': fake.Agent})\nconsume(combine)", + "from operator import ior as combine\nslots = fake.__dict__\ncombine(slots, {'Agent': fake.Agent})\nconsume(slots)", +]) +def test_from_operator_ior_leaves_other_imports_and_escapes_unproved(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + assert not any(calls._source_from_operator_ior(module, statement) for statement in module.tree.body) + + +@pytest.mark.parametrize("repetitions", [1, 200]) +def test_from_operator_ior_distinct_candidates_share_only_structural_indexes(namespace_workspace, monkeypatch, repetitions): + from agents_shipgate.inputs import list_expressions + + writes = "\n".join( + f"from operator import ior as combine{i}\nslots{i} = fake.__dict__\ncombine{i}(slots{i}, {{'Agent': fake.Agent}})" + for i in range(repetitions) + ) + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=writes) + calls = BuilderCalls(resolver) + calls.scopes(module) + original_walk = ast.walk + walks = 0 + def walk(node): + nonlocal walks + if node is module.tree: + walks += 1 + return original_walk(node) + monkeypatch.setattr(ast, "walk", walk) + statements = list(module.tree.body) + records = [calls._source_from_operator_ior(module, statement) for statement in statements] + assert sum(record is not None for record in records) == repetitions + assert walks == 1 + class Body(list): + def __iter__(self): + raise AssertionError("import-boundary selection rescanned every statement") + def __contains__(self, value): + raise AssertionError("import-boundary selection rescanned statement membership") + def index(self, *args, **kwargs): + raise AssertionError("import-boundary selection rescanned statement positions") + module.tree.body = Body(module.tree.body) + monkeypatch.setattr(list_expressions, "_standard_operator", lambda view: False) + for record in records: + if record is not None: + assert not calls._namespace_from_operator_ior_import(module, record.statement, record.imported) + assert walks == 1 # Failed native proof never grants a role; only syntax routing is instrumented. + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("alias", [False, True]) +@pytest.mark.parametrize("name", ["Agent", "update", "operator"]) +def test_direct_bare_source_setter_keeps_actual_callee_namespace_and_function_roles(namespace_workspace, family, alias, name): + write = ("other = fake\n" if alias else "") + f"setattr({'other' if alias else 'fake'}, '{name}', fake.{name})" + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=write, home=f"def {name}(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + edge = calls._source_slot_edge(module, statement) + assert edge is not None and edge.direct_bare_setter is not None + record = edge.direct_bare_setter + assert record.callee is statement.value.func + assert not any(hasattr(record, name) for name in ("assignment", "statement", "root", "initializer")) + assert edge.bare_setter is None and edge.setter_import is None and edge.primitive_import is None + nodes = [statement, record.callee, edge.call, edge.target, edge.rhs, edge.parts[4]] + if alias: + initializer = module.tree.body[1].value + assert calls._source_slot_receiver(module, edge.target, statement)[1] is initializer + nodes.append(initializer) + for node in nodes: + assert calls.idempotent_source_slot(module, node, family) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "setattr = replacement\nsetattr(fake, 'Agent', fake.Agent)", + "from builtins import setattr\nsetattr(fake, 'Agent', fake.Agent)", + "setattr(fake, 'Agent', fake.Agent, extra=None)", + "setattr(fake, 'Agent', fake.Agent, None)", + "setattr(*(fake, 'Agent', fake.Agent))", + "setattr(fake, key, fake.Agent)", + "setattr(fake, 'Other', fake.Agent)", + "setattr(fake, 'Agent', replacement)", + "result = setattr(fake, 'Agent', fake.Agent)", + "other: object = fake\nsetattr(other, 'Agent', fake.Agent)", + "other = fake\nsecond = other\nsetattr(second, 'Agent', fake.Agent)", + "other = fake\nsetattr(fake, 'Agent', other.Agent)", + "first = fake\nsecond = fake\nsetattr(first, 'Agent', second.Agent)", + "import fake as other\nsetattr(other, 'Agent', other.Agent)", + "other = fake\nother = replacement\nsetattr(other, 'Agent', fake.Agent)", + "other = fake\nsetattr(other, 'Agent', fake.Agent)\nconsume(other)", + "other = fake\nsetattr(other, 'Agent', fake.Agent)\ndef capture():\n return other", + "setattr(fake, 'Agent', fake.Agent)\nconsume(fake)", + "setattr(fake, 'Agent', fake.Agent)\n__all__ = ['fake']", +]) +def test_direct_bare_source_setter_keeps_other_grammar_and_namespace_escapes_unproved(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + assert not any((edge := calls._source_slot_edge(module, statement)) is not None + and edge.direct_bare_setter is not None + for statement in module.tree.body if isinstance(statement, ast.Assign | ast.Expr | ast.AugAssign)) + + +@pytest.mark.parametrize("extra", [ + "import builtins\nbuiltins.setattr = replacement\n", + "import sys\nsys.modules['builtins'] = replacement\n", + "import fake\nfake.__class__ = replacement\n", + "import fake\ndef __getattr__(name):\n return fake\n", +]) +def test_direct_bare_source_setter_preserves_raw_native_and_module_machinery(namespace_workspace, extra): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="other = fake\nsetattr(other, 'Agent', fake.Agent)", + ) + (namespace_workspace / "other.py").write_text(extra) + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("alias", [False, True]) +def test_direct_bare_source_setter_does_not_discharge_an_actual_function_call(namespace_workspace, alias): + write = ("other = fake\n" if alias else "") + f"setattr({'other' if alias else 'fake'}, 'Agent', fake.Agent)" + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + (namespace_workspace / "caller.py").write_text("import fake\nfake.Agent([])\n") + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + assert not resolver._checking_source_module_slot + + +def test_direct_bare_source_setter_cannot_borrow_receiving_callbacks(namespace_workspace, monkeypatch): + from agents_shipgate.inputs import list_expressions + + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="other = fake\nsetattr(other, 'Agent', fake.Agent)", + ) + def forbidden(*args, **kwargs): + raise AssertionError("the independent bare setter proof borrowed a receiving callback") + for name in ("_independent_foreign_class_slot_write", "_independent_plain_class_slot_write", "_agent_instance", "_provisional_agent_list_clear"): + monkeypatch.setattr(list_expressions, name, forbidden) + assert BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + + +@pytest.mark.parametrize("alias", [False, True]) +def test_direct_bare_source_setter_reconfirms_consumed_text_after_zero_census(namespace_workspace, monkeypatch, alias): + quiet = namespace_workspace / "quiet.py" + original_text = "#" + " " * 99 + "\n" + quiet.write_text(original_text) + write = ("other = fake\n" if alias else "") + f"setattr({'other' if alias else 'fake'}, 'Agent', fake.Agent)" + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + original = BuilderCalls._census + changed = False + def census(self, *args, **kwargs): + nonlocal changed + result = original(self, *args, **kwargs) + if kwargs.get("source_slot_values") and not changed: + assert not result.sites and not result.limits and quiet in self._texts + changed = True + replacement = "import fake\nfake.Agent([])\n" + replacement += "#" + " " * (len(original_text) - len(replacement) - 2) + "\n" + assert len(replacement.encode()) == len(original_text.encode()) + quiet.write_text(replacement) + return result + monkeypatch.setattr(BuilderCalls, "_census", census) + with pytest.raises(CallLimit, match="namespace source bytes"): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], "agents") + assert changed and not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +@pytest.mark.parametrize("name", ["Agent", "update", "operator"]) +def test_imported_bare_setter_receiver_keeps_two_actual_imports_and_original_function(namespace_workspace, family, name): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write=f"import fake as other\nsetattr(other, '{name}', fake.{name})", + home=f"def {name}(tools):\n return tools\n", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + left, right = statement.value.args[0], statement.value.args[2].value + assert calls._source_direct_bare_receivers(module, statement) == ((left, None, None), (right, None, None)) + assert module.bindings[left.id][0].statement is module.tree.body[1] + assert module.bindings[right.id][0].statement is module.tree.body[0] + assert module.bindings[left.id][0].node is module.tree.body[1].names[0] + candidate = calls._source_slot_candidate(module, statement) + assert candidate is not None and candidate[0].path == namespace_workspace / 'fake.py' + assert candidate[1] is candidate[0].tree.body[0] + edge = calls._source_slot_edge(module, statement) + assert edge is not None and edge.direct_bare_setter is not None + for node in (statement, edge.call, edge.callee, edge.target, edge.rhs, edge.parts[4]): + assert calls.idempotent_source_slot(module, node, family) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("write", [ + "import foreign as other\nsetattr(other, 'Agent', fake.Agent)", + "import fake.child as other\nsetattr(other, 'Agent', fake.Agent)", + "import fake as other, foreign\nsetattr(other, 'Agent', fake.Agent)", + "from fake import other\nsetattr(other, 'Agent', fake.Agent)", + "import fake as other\nsetattr(other, 'Agent', other.Agent)", + "import fake as other\nsetattr(fake, 'Agent', other.Agent)", + "import fake as other\nimport fake as other\nsetattr(other, 'Agent', fake.Agent)", + "import fake as other\nother = fake\nsetattr(other, 'Agent', fake.Agent)", + "import fake as other\nconsume(other)\nsetattr(other, 'Agent', fake.Agent)", + "import fake as other\nsetattr(other, 'Agent', fake.Agent)\nconsume(fake)", + "import fake as other\nsetattr(other, 'Agent', fake.Agent)\n__all__ = ['other']", + "import fake as other\nsetattr(other, 'Agent', fake.Agent)\ndef capture():\n return other", + "import fake as setattr\nsetattr(setattr, 'Agent', fake.Agent)", +]) +def test_imported_bare_setter_receiver_keeps_unproved_import_and_escape_shapes(namespace_workspace, write): + resolver, module, _ = _source_dictionary_slot_fixture(namespace_workspace, write=write) + calls = BuilderCalls(resolver) + assert not any(calls._source_slot_edge(module, statement) is not None for statement in module.tree.body + if isinstance(statement, ast.Expr)) + + +@pytest.mark.parametrize("importer", [ + "from entry import other\n", + "from entry import fake\n", + "from entry import fake as retained\n", + "import entry\nconsume(entry.other)\n", +]) +def test_imported_bare_setter_receiver_refuses_both_writer_namespace_exports(namespace_workspace, importer): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import fake as other\nsetattr(other, 'Agent', fake.Agent)", + ) + (namespace_workspace / 'importer.py').write_text(importer) + with pytest.raises(CallLimit): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], 'agents') + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ["agents", "google.adk"]) +def test_imported_bare_setter_receiver_preserves_actual_function_calls(namespace_workspace, family): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import fake as other\nsetattr(other, 'Agent', fake.Agent)", + ) + (namespace_workspace / 'caller.py').write_text("import fake\nfake.Agent([])\n") + assert not BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], family) + assert not resolver._checking_source_module_slot + + +def test_imported_bare_setter_receiver_rechecks_caller_bytes_after_real_zero(namespace_workspace, monkeypatch): + quiet = namespace_workspace / 'quiet.py' + original_text = '#' + ' ' * 99 + '\n' + quiet.write_text(original_text) + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import fake as other\nsetattr(other, 'Agent', fake.Agent)", + ) + original = BuilderCalls._census + changed = False + def census(self, *args, **kwargs): + nonlocal changed + result = original(self, *args, **kwargs) + if kwargs.get('source_slot_values') and not changed: + assert not result.sites and not result.limits and quiet in self._texts + changed = True + replacement = "import fake\nfake.Agent([])\n" + replacement += '#' + ' ' * (len(original_text) - len(replacement) - 2) + '\n' + assert len(replacement.encode()) == len(original_text.encode()) + quiet.write_text(replacement) + return result + monkeypatch.setattr(BuilderCalls, '_census', census) + with pytest.raises(CallLimit, match='namespace source bytes'): + BuilderCalls(resolver).idempotent_source_slot(module, module.tree.body[-1], 'agents') + assert changed and not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ['agents', 'google.adk']) +def test_same_text_entry_preserves_retained_source_slot_function_proof(namespace_workspace, family): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import fake as other\nsetattr(other, 'Agent', fake.Agent)", + ) + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + home, function, _ = calls._source_slot_candidate(module, statement) + assert resolver._scanned[home.path] is home and home.path not in resolver._modules + retained_scopes = calls.scopes(home) + assert home.path not in resolver._modules + fresh_tree = ast.parse(home.text) + registered = resolver.entry(home.path, fresh_tree, home.text) + assert registered is home and registered.tree.body[0] is function + assert calls.scopes(registered) is retained_scopes + assert calls._source_slot_candidate(module, statement)[:2] == (home, function) + assert BuilderCalls(resolver).uncalled_source_slot(home, function) + assert calls.idempotent_source_slot(module, statement, family) + assert not resolver._checking_source_module_slot + + +@pytest.mark.parametrize("family", ['agents', 'google.adk']) +def test_same_text_entry_cannot_drop_an_actual_source_function_caller(namespace_workspace, family): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import fake as other\nsetattr(other, 'Agent', fake.Agent)", + ) + (namespace_workspace / 'caller.py').write_text('import fake\nfake.Agent([])\n') + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + home, function, _ = calls._source_slot_candidate(module, statement) + assert resolver.entry(home.path, ast.parse(home.text), home.text) is home + assert not BuilderCalls(resolver).uncalled_source_slot(home, function) + assert not calls.idempotent_source_slot(module, statement, family) + assert not resolver._checking_source_module_slot + + +def test_same_text_entry_keeps_captured_source_currency_obligation(namespace_workspace): + resolver, module, _ = _source_dictionary_slot_fixture( + namespace_workspace, write="import fake as other\nsetattr(other, 'Agent', fake.Agent)", + ) + snapshot = StaticInputSnapshot(namespace_workspace) + token = activate_static_input_snapshot(snapshot) + try: + calls = BuilderCalls(resolver) + statement = module.tree.body[-1] + home, function, _ = calls._source_slot_candidate(module, statement) + assert resolver.entry(home.path, ast.parse(home.text), home.text) is home + assert calls.idempotent_source_slot(module, statement, 'agents') + assert home.tree.body[0] is function + assert home.path in snapshot.dependency_paths() + home.path.write_text(home.text.replace('return tools', 'return None ')) + with pytest.raises((ValueError, OSError)): + snapshot.finish() + finally: + reset_static_input_snapshot(token) + + +@pytest.mark.parametrize("imported", [ + "from producer import made", "from producer import made as retained", + "import producer", "from producer import *", +]) +def test_constructor_export_refusal_retains_actual_import_location(tmp_path, imported): + source = "made = object()\n" + (tmp_path / "producer.py").write_text(source) + (tmp_path / "consumer.py").write_text("# actual importer\n" + imported + "\n") + resolver = ImportResolver(tmp_path) + module = resolver.entry(tmp_path / "producer.py", ast.parse(source), source) + call = module.tree.body[0].value + proof = BuilderCalls(resolver) + assert proof.exported_elsewhere(module, call) + reason = proof.export_limit(call) + assert reason is not None and "consumer.py:2" in reason and "producer.py:1" in reason + assert proof.exported_elsewhere(module, call) # Existing Boolean cache retains its route. + assert proof.export_limit(call) == reason + + +def test_an_unrelated_same_named_import_cannot_create_an_export_route(tmp_path): + source = "made = object()\n" + (tmp_path / "producer.py").write_text(source) + (tmp_path / "another.py").write_text(source) + (tmp_path / "consumer.py").write_text("# producer candidate\nfrom another import made\n") + resolver = ImportResolver(tmp_path) + module = resolver.entry(tmp_path / "producer.py", ast.parse(source), source) + call = module.tree.body[0].value + proof = BuilderCalls(resolver) + assert not proof.exported_elsewhere(module, call) + assert proof.export_limit(call) is None + + +def test_constructor_scope_cache_preserves_actual_tree_roles_without_verdicts(tmp_path): + source = "from agents import Agent\nagent = Agent(name='app', tools=[])\n" + path = tmp_path / "agent.py" + path.write_text(source) + resolver = ImportResolver(tmp_path) + module = resolver.entry(path, ast.parse(source), source) + first = resolver._constructor_scope(module) + assert resolver._constructor_scope(module) is first + call = module.tree.body[1].value + assert first.parents[call] is module.tree.body[1] + # A different actual tree cannot borrow the old tree's roles, even with identical text. + from agents_shipgate.inputs.python_imports import _module + other = _module(path, "agent.py", ast.parse(source), source) + second = resolver._constructor_scope(other) + assert second is not first and second.parents[other.tree.body[1].value] is other.tree.body[1] + assert len(resolver._constructor_scopes) == 2 + + +@pytest.mark.parametrize("depth", [40, 80]) +def test_constructor_reference_work_does_not_resolve_each_attribute_prefix(tmp_path, monkeypatch, depth): + from agents_shipgate.inputs.python_imports import _external_constructor_use + + source = "from agents import Agent\nAgent" + ".metadata" * depth + "\n" + path = tmp_path / "agent.py" + path.write_text(source) + resolver = ImportResolver(tmp_path) + module = resolver.entry(path, ast.parse(source), source) + original = ImportResolver._constructor_reference + visits = [] + + def count(self, current, node, scopes): + visits.append(node) + return original(self, current, node, scopes) + + monkeypatch.setattr(ImportResolver, "_constructor_reference", count) + assert _external_constructor_use(resolver, module, "agents", {path}, allow_owner_routes=False) is None + assert len(visits) <= depth + 5 + assert resolver._constructor_scope(module) is resolver._constructor_scopes[module.tree] diff --git a/tests/test_builtin_namespace_ownership.py b/tests/test_builtin_namespace_ownership.py new file mode 100644 index 000000000..fb5519e67 --- /dev/null +++ b/tests/test_builtin_namespace_ownership.py @@ -0,0 +1,113 @@ +"""A replaced module builtin namespace cannot support lexical builtin proofs.""" +from __future__ import annotations + +import ast + +import pytest + +from agents_shipgate.inputs.python_imports import ScopeIndex, replaces_builtin_namespace +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_framework_constructor_bindings import _files +from tests.test_imported_tool_bindings import _commit, _compare, _git + + +@pytest.mark.parametrize("source", [ + "__builtins__ = {}", "__builtins__: object = {}", "__builtins__ |= {}", "del __builtins__", + "import custom_builtins as __builtins__", "from shim import VALUE as __builtins__", + "def __builtins__():\n pass", "class __builtins__:\n pass", + "for __builtins__ in values:\n pass", + "try:\n run()\nexcept Exception as __builtins__:\n pass", + "match value:\n case __builtins__:\n pass", + "match value:\n case {**__builtins__}:\n pass", + "def update(value):\n global __builtins__\n match value:\n case {**__builtins__}:\n pass", + "def update():\n global __builtins__\n __builtins__ = {}", + "def make(arg=(__builtins__ := {})):\n __builtins__ = 1", + "[(__builtins__ := {}) for item in (0,)]", + "def update():\n global __builtins__\n [(__builtins__ := {}) for item in (0,)]", + "def make(arg=[(__builtins__ := {}) for item in (0,)]):\n __builtins__ = 1", + "value = lambda arg=(__builtins__ := {}): arg", + "value = lambda arg=[(__builtins__ := {}) for item in (0,)]: arg", + "class Built(metaclass=[(__builtins__ := {}) for item in (0,)][0]):\n pass", + "@decorate([(__builtins__ := {}) for item in (0,)])\ndef built():\n __builtins__ = 1", +]) +def test_module_builtin_namespace_binding_forms_are_unread(source): + tree = ast.parse(source) + scopes = ScopeIndex(tree) + assert any(replaces_builtin_namespace(node, scopes) for node in ast.walk(tree)) + + +@pytest.mark.parametrize("source", [ + "def local():\n __builtins__ = {}", + "def local():\n import custom_builtins as __builtins__", + "def local():\n from shim import VALUE as __builtins__", + "class Local:\n __builtins__ = {}", + "class Local:\n import custom_builtins as __builtins__", + "def local():\n def __builtins__():\n pass", + "def local():\n del __builtins__", + "class Local:\n del __builtins__", + "def local(value):\n match value:\n case {**__builtins__}:\n pass", + "class Local:\n match value:\n case {**__builtins__}:\n pass", + "def local():\n [(__builtins__ := {}) for item in (0,)]", + "def local():\n {(__builtins__ := item) for item in (0,)}", + "def local():\n {item: (__builtins__ := item) for item in (0,)}", + "def local():\n ((__builtins__ := {}) for item in (0,))", + "def local():\n [[(__builtins__ := {}) for inner in (0,)] for outer in (0,)]", + "value = lambda: (__builtins__ := {})", + "value = lambda: [(__builtins__ := {}) for item in (0,)]", + "def outer():\n __builtins__ = {}\n def inner():\n nonlocal __builtins__\n [(__builtins__ := {}) for item in (0,)]", + "def outer():\n def inner(arg=[(__builtins__ := {}) for item in (0,)]):\n pass", + "def outer():\n [(__builtins__ := {}) for item in (0,)]\n def inner():\n nonlocal __builtins__\n __builtins__ = {}", + "def outer():\n [(__builtins__ := {}) for item in (0,)]\n def inner():\n nonlocal __builtins__\n [(__builtins__ := {}) for item in (0,)]", + "def outer():\n def initialize(value=(__builtins__ := {})):\n pass\n def inner():\n nonlocal __builtins__\n __builtins__ = {}", + "def outer():\n def initialize(value=(__builtins__ := {})):\n pass\n def inner():\n nonlocal __builtins__\n (__builtins__ := {})", +]) +def test_ordinary_lexical_builtin_namespace_bindings_stay_local(source): + tree = ast.parse(source) + scopes = ScopeIndex(tree) + assert not any(replaces_builtin_namespace(node, scopes) for node in ast.walk(tree)) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("factory", [False, True]) +@pytest.mark.parametrize("binding", ["__builtins__ = {}", "import custom_builtins as __builtins__", + "from shim import VALUE as __builtins__"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_replaced_builtin_namespace_withholds_capability_changes(tmp_path, framework, factory, binding, change): + before = _files(framework, "direct", "clean", factory=factory) + after = _files(framework, "direct", "clean", "[read, write]" if change == "added" else "[]" if change == "removed" else "[read]", + "return 2" if change == "changed" else "return 1", factory=factory) + for files in (before, after): + files["custom_builtins.py"] = "str = None\n" + files["shim.py"] += "VALUE = {}\n" + path = "factory.py" if factory else "app.py" + after[path] = binding + "\n" + after[path] + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert not result["base"]["coverage_gaps"] + assert result["comparison_status"] == "partial" + assert result["head"]["coverage_gaps"] + assert all(row["change"] == "not_established" for row in result["rows"]) + assert any("builtin namespace" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("local", [ + "def helper():\n __builtins__ = {}\n", "class Helper:\n __builtins__ = {}\n", + "def helper():\n [(__builtins__ := {}) for item in (0,)]\n", + "def helper():\n [(__builtins__ := {}) for item in (0,)]\n def inner():\n nonlocal __builtins__\n __builtins__ = {}\n", + "def helper():\n def initialize(value=(__builtins__ := {})):\n pass\n def inner():\n nonlocal __builtins__\n __builtins__ = {}\n", +]) +def test_local_namespace_data_does_not_hide_a_real_capability_change(tmp_path, framework, local): + before = _files(framework, "direct", "clean") + after = _files(framework, "direct", "clean", "[read, write]") + for files in (before, after): + files["factory.py"] += local + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert [(row["agent"], row["tool"], row["change"]) for row in result["rows"]] == [ + ("a" if framework == "sdk" else "Built", "write", "added") + ] diff --git a/tests/test_ci_timing.py b/tests/test_ci_timing.py new file mode 100644 index 000000000..623edb0ff --- /dev/null +++ b/tests/test_ci_timing.py @@ -0,0 +1,181 @@ +"""CI observations preserve selection and never turn partial files into weights.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +from ci_timing import PREFIX, FileTimings + +ROOT = Path(__file__).resolve().parent.parent + + +def _finish(timings, node, *, setup="passed", call="passed", teardown="passed"): + events = timings.report(node, "setup", 1.0, setup) + if setup == "passed": + events += timings.report(node, "call", 2.0, call) + return events + timings.report(node, "teardown", 3.0, teardown) + + +def test_interleaved_reports_require_every_selected_teardown(): + timings = FileTimings() + ids = ["tests/test_a.py::test_one", "tests/test_a.py::test_two", "tests/test_b.py::test_three"] + (census,) = timings.collection(ids) + assert census["selected"] == 3 + assert timings.collection(ids) == [] # Two workers share one census. + assert _finish(timings, ids[0]) == [] + (other,) = _finish(timings, ids[2]) + assert other["file"] == "tests/test_b.py" and other["seconds"] == 6.0 + assert timings.report(ids[1], "setup", 1.0, "passed") == [] + assert timings.report(ids[1], "call", 2.0, "passed") == [] + (complete,) = timings.report(ids[1], "teardown", 3.0, "passed") + assert complete["selected"] == complete["completed"] == 2 + assert complete["seconds"] == 12.0 + + +@pytest.mark.parametrize("setup,call,teardown,seconds,failed", [ + ("skipped", "passed", "passed", 4.0, 0), + ("failed", "passed", "passed", 4.0, 1), + ("passed", "failed", "passed", 6.0, 1), + ("passed", "passed", "failed", 6.0, 1), +]) +def test_failure_and_skip_timings_remain_outcome_bearing(setup, call, teardown, seconds, failed): + timings = FileTimings() + node = "tests/test_a.py::test_one" + timings.collection([node]) + (event,) = _finish(timings, node, setup=setup, call=call, teardown=teardown) + assert event["kind"] == "complete_file" and event["seconds"] == seconds + assert event["phase_outcomes"].get("failed", 0) == failed + + +@pytest.mark.parametrize("phase,duration,outcome", [ + ("???", 0.0, "failed"), + ("call", float("nan"), "passed"), + ("call", -1.0, "passed"), + ("call", True, "passed"), + ("call", 1.0, "rerun"), + ("setup", 1.0, "passed"), +]) +def test_unsupported_reports_cannot_establish_a_complete_file(phase, duration, outcome): + timings = FileTimings() + node = "tests/test_a.py::test_one" + timings.collection([node]) + timings.report(node, "setup", 1.0, "passed") + assert timings.report(node, phase, duration, outcome)[0]["kind"] == "invalidate_file" + assert timings.report(node, "teardown", 1.0, "passed") == [] + + +def test_retry_after_emission_and_collection_mismatch_revoke_prior_measurements(): + timings = FileTimings() + node = "tests/test_a.py::test_one" + timings.collection([node]) + assert _finish(timings, node)[0]["kind"] == "complete_file" + assert timings.report(node, "setup", 1.0, "passed")[0]["kind"] == "invalidate_file" + assert timings.collection(["tests/test_b.py::test_other"])[0]["kind"] == "invalidate_session" + + +def test_cancellation_preserves_only_completed_files_and_omits_parameter_payloads(): + timings = FileTimings() + ids = ["tests/test_a.py::test_one[PRIVATE_PARAMETER]", "tests/test_b.py::test_unfinished"] + census = timings.collection(ids) + completed = _finish(timings, ids[0]) + assert timings.report(ids[1], "setup", 1.0, "passed") == [] + assert timings.report(ids[1], "call", 1.0, "passed") == [] + assert [event["file"] for event in completed] == ["tests/test_a.py"] + assert "PRIVATE_PARAMETER" not in json.dumps(census + completed) + assert census[0]["files"]["tests/test_b.py"] == 1 + + +def _run(tmp_path, code, *, hosted="true", distributed=True, extra_args=()): + for name in ("conftest.py", "ci_sharding.py", "ci_timing.py"): + shutil.copyfile(ROOT / name, tmp_path / name) + (tmp_path / "tests").mkdir() + (tmp_path / "tests/test_probe.py").write_text(code) + env = {**os.environ, "GITHUB_ACTIONS": hosted} + for key in ("SHIPGATE_TEST_SHARD", "SHIPGATE_TEST_SHARDS", "PYTEST_ADDOPTS", "GITHUB_STEP_SUMMARY"): + env.pop(key, None) + command = [sys.executable, "-m", "pytest", "-q", "-o", "junit_duration_report=total", + "--junitxml=" + str(tmp_path / "result.xml")] + if distributed: + command += ["-n", "2", "--max-worker-restart=0"] + command += list(extra_args) + result = subprocess.run(command, cwd=tmp_path, env=env, text=True, capture_output=True, timeout=60) + records = [line for line in result.stdout.splitlines() if PREFIX in line] + assert all(line.startswith(PREFIX) for line in records), result.stdout + events = [json.loads(line.removeprefix(PREFIX)) for line in records] + return result, events + + +def test_actual_xdist_phase_timings_match_explicit_total_junit_and_selected_cases(tmp_path): + result, events = _run(tmp_path, """import pytest +@pytest.mark.parametrize('value', [1, 2], ids=['PRIVATE_ONE', 'PRIVATE_TWO']) +def test_pass(value): + assert value +@pytest.fixture +def skip_setup(): + pytest.skip('fixture skip') +def test_skip(skip_setup): + assert False +@pytest.fixture +def broken_setup(): + raise RuntimeError('fixture error') +def test_error(broken_setup): + assert False +""") + assert result.returncode == 1, result.stdout + result.stderr + census = [event for event in events if event["kind"] == "census"] + complete = [event for event in events if event["kind"] == "complete_file"] + assert len(census) == len(complete) == 1 + assert census[0]["selected"] == complete[0]["completed"] == 4 + assert complete[0]["phase_outcomes"]["failed"] == 1 + assert complete[0]["phase_outcomes"]["skipped"] == 1 + assert complete[0]["workers_observed"] == 2 + cases = list(ET.parse(tmp_path / "result.xml").getroot().iter("testcase")) + assert len(cases) == 4 + assert complete[0]["seconds"] == pytest.approx(sum(float(case.get("time")) for case in cases), abs=0.01) + assert "PRIVATE_ONE" not in json.dumps(events) and "PRIVATE_TWO" not in json.dumps(events) + + +@pytest.mark.parametrize("hosted,distributed", [("false", True), ("true", False)]) +def test_non_hosted_and_serial_pytest_emit_no_timing_records(tmp_path, hosted, distributed): + result, events = _run(tmp_path, "def test_pass():\n assert True\n", hosted=hosted, distributed=distributed) + assert result.returncode == 0, result.stdout + result.stderr + assert not events + + +def test_actual_worker_crash_does_not_publish_a_complete_file(tmp_path): + result, events = _run(tmp_path, "import os\ndef test_crash():\n os._exit(99)\n") + assert result.returncode == 1, result.stdout + result.stderr + assert any(event["kind"] in {"invalidate_file", "invalidate_session"} for event in events) + assert not any(event["kind"] == "complete_file" for event in events) + + +def test_passed_setup_without_a_call_cannot_complete_the_file(): + timings = FileTimings() + node = "tests/test_a.py::test_one" + timings.collection([node]) + timings.report(node, "setup", 1.0, "passed") + assert timings.report(node, "teardown", 1.0, "passed")[0]["kind"] == "invalidate_file" + + +@pytest.mark.parametrize("setup", ["skipped", "failed"]) +def test_a_call_after_unexecuted_setup_invalidates_timing(setup): + timings = FileTimings() + node = "tests/test_a.py::test_one" + timings.collection([node]) + timings.report(node, "setup", 1.0, setup) + assert timings.report(node, "call", 1.0, "passed")[0]["kind"] == "invalidate_file" + assert timings.report(node, "teardown", 1.0, "passed") == [] + + +def test_actual_setup_only_run_is_inert_and_never_claims_execution(tmp_path): + result, events = _run(tmp_path, "def test_not_run():\n raise AssertionError('body must remain unexecuted')\n", extra_args=("--setup-only",)) + assert result.returncode == 0, result.stdout + result.stderr + assert not events diff --git a/tests/test_constructor_dependency_ownership.py b/tests/test_constructor_dependency_ownership.py new file mode 100644 index 000000000..bddcba152 --- /dev/null +++ b/tests/test_constructor_dependency_ownership.py @@ -0,0 +1,314 @@ +"""Inherited hooks and eager tool metadata cannot establish unchanged wiring.""" + +from __future__ import annotations + +import pytest + +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_framework_constructor_bindings import _files +from tests.test_imported_tool_bindings import _commit, _compare, _git + +DEPENDENCIES = [ + ("adk", "google.adk.agents", "BaseAgent", "__init__"), + ("adk", "google.adk.agents.base_agent", "BaseAgent", "__new__"), + ("adk", "google.adk.agents.llm_agent", "BaseAgent", "__init__"), + ("adk", "google.adk.workflow", "BaseNode", "__init__"), + ("adk", "google.adk.workflow._base_node", "BaseNode", "__new__"), + ("adk", "google.adk.agents.base_agent", "BaseNode", "__init__"), + ("adk", "google.adk.tools", "BaseTool", "__init__"), + ("adk", "google.adk.tools.base_tool", "BaseTool", "__new__"), + ("adk", "google.adk.tools.function_tool", "BaseTool", "__init__"), + ("adk", "google.adk.agents.llm_agent", "BaseTool", "__new__"), + ("adk", "google.adk.agents.llm_agent", "FunctionTool", "__init__"), + ("adk", "google.adk.tools.long_running_tool", "LongRunningFunctionTool", "__init__"), + ("adk", "google.adk.agents.base_agent", "BaseModel", "__init__"), + ("adk", "google.adk.agents.llm_agent", "BaseModel", "__new__"), + ("adk", "google.adk.workflow._base_node", "BaseModel", "__init__"), + ("adk", "google.adk.tools.base_tool", "BaseModel", "__new__"), + ("adk", "google.adk.tools.base_tool", "ABC", "__new__"), + ("adk", "abc", "ABC", "__new__"), + ("sdk", "agents", "AgentBase", "__setattr__"), + ("sdk", "agents.agent", "AgentBase", "__new__"), + ("sdk", "agents", "FunctionTool", "__post_init__"), + ("sdk", "agents.tool", "FunctionTool", "__init__"), + ("sdk", "typing", "Generic", "__new__"), + ("sdk", "agents.agent", "Generic", "__new__"), + ("sdk", "agents.tool", "Generic", "__new__"), + ("sdk", "agents.agent", "BaseModel", "model_json_schema"), + ("sdk", "agents.tool", "BaseModel", "model_json_schema"), + ("sdk", "agents.function_schema", "BaseModel", "model_json_schema"), + *[(framework, namespace, symbol, hook) + for framework in ("sdk", "adk") + for namespace, symbol, hook in ( + ("pydantic", "BaseModel", "__init__"), + ("pydantic.main", "BaseModel", "model_json_schema"), + ("pydantic._internal._model_construction", "ModelMetaclass", "__new__"), + ("pydantic._internal._model_construction", "ABCMeta", "__call__"), + ("abc", "ABCMeta", "__call__"), + )], +] + + +def _versions(framework, layout, change): + before = _files(framework, layout, "clean", factory=False) + after = _files(framework, layout, "clean", + "[read, write]" if change == "added" else "[]" if change == "removed" else "[read]", + "return 2" if change == "changed" else "return 1", factory=False) + for files in (before, after): + if framework == "adk": + # BaseTool hooks matter here because these are actual wrappers, + # rather than plain functions awaiting a later runtime conversion. + files["app.py"] = "from google.adk.tools import FunctionTool\n" + files["app.py"] + files["app.py"] = files["app.py"].replace("[read, write]", "[FunctionTool(read), FunctionTool(write)]") + files["app.py"] = files["app.py"].replace("[read]", "[FunctionTool(read)]") + files["tools.py"] = files["tools.py"].replace("def read():", "def read(value: int):").replace("def write():", "def write(value: int):") + return before, after + + +def _comparison(root, before, after): + _git(root, "init", "-q", "-b", "main") + base, head = _commit(root, before), _commit(root, after) + return _compare(root, base, head, "--scope", ".") + + +def _unread(result): + assert not result["base"]["coverage_gaps"] + assert result["comparison_status"] == "partial" + assert result["head"]["coverage_gaps"] + assert all(row["change"] == "not_established" for row in result["rows"]) + assert any("constructor" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + + +@pytest.mark.parametrize("framework,namespace,symbol,hook", DEPENDENCIES) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_inherited_and_schema_dependency_changes_are_unread(tmp_path, framework, namespace, symbol, hook, change): + layout = "direct" if framework == "adk" else {"added": "direct", "changed": "builder", "removed": "caller"}[change] + before, after = _versions(framework, layout, change) + source = "builders.py" if layout == "builder" else "app.py" + after[source] = f"from {namespace} import {symbol} as Carrier\nfrom shim import replacement\nCarrier.{hook} = replacement\n" + after[source] + _unread(_comparison(tmp_path, before, after)) + + +@pytest.mark.parametrize("framework,namespace,symbol", [ + ("adk", "google.adk.agents.base_agent", "BaseNode"), + ("adk", "google.adk.agents.llm_agent", "abc"), + ("adk", "google.adk.tools.function_tool", "pydantic"), + ("adk", "pydantic.main", "_model_construction"), + ("sdk", "agents.agent", "AgentBase"), + ("sdk", "agents.tool", "typing"), + ("sdk", "agents.function_schema", "create_model"), + ("sdk", "agents.tool", "function_schema"), + ("sdk", "agents.run_internal.agent_tool_configuration", "assign_agent_tools"), +]) +@pytest.mark.parametrize("route", ["opaque", "local_reexport", "namespace_reexport", "saved_rebound"]) +def test_dependency_retention_through_real_reexports_is_unread(tmp_path, framework, namespace, symbol, route): + before, after = _versions(framework, "direct", "added") + after["bridge.py"] = f"from {namespace} import {symbol} as Carrier\n" + patch = { + "opaque": f"from {namespace} import {symbol} as Carrier\nreplacement(Carrier)\n", + "local_reexport": "from bridge import Carrier\nreplacement(Carrier)\n", + "namespace_reexport": "import bridge\nreplacement(bridge)\n", + "saved_rebound": f"from {namespace} import {symbol} as Carrier\nSaved = Carrier\nCarrier = None\nreplacement(Saved)\n", + }[route] + after["app.py"] = "from shim import replacement\n" + patch + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + +@pytest.mark.parametrize("framework,patch", [ + ("sdk", "import agents.run_internal.agent_tool_configuration as config\nconfig.assign_agent_tools = replacement\n"), + ("sdk", "import agents.function_schema as schema\nschema.create_model = replacement\n"), + ("sdk", "import pydantic\npydantic.create_model = replacement\n"), + ("sdk", "import typing\ndel typing.Generic.__class_getitem__\n"), + ("adk", "import google.adk.agents.llm_agent as llm\nllm.BaseAgent.__init__ = replacement\n"), + ("adk", "import pydantic.main as models\nmodels._model_construction.ModelMetaclass.__call__ = replacement\n"), + ("adk", "import abc\nabc.ABC.__new__ = replacement\n"), + ("adk", "import google.adk.tools.function_tool as wrappers\nwrappers.pydantic.BaseModel.__init__ = replacement\n"), +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_dependency_namespace_patches_cannot_establish_changes(tmp_path, framework, patch, change): + before, after = _versions(framework, "direct", change) + after["patcher.py"] = "from shim import replacement\n" + patch + after["bridge.py"] = "import patcher\n" + after["app.py"] = "import bridge\n" + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + +@pytest.mark.parametrize("framework,module", [("adk", "pydantic.main"), ("adk", "abc"), + ("sdk", "pydantic._internal._model_construction"), ("sdk", "typing")]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_dependency_module_table_replacement_is_unread(tmp_path, framework, module, change): + before, after = _versions(framework, "direct", change) + after["app.py"] = f"import sys\nsys.modules['{module}'] = None\n" + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + +@pytest.mark.parametrize("namespace,symbol", [("google.adk.agents", "BaseAgent"), + ("google.adk.workflow", "BaseNode"), ("pydantic", "BaseModel")]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +@pytest.mark.parametrize("layout", ["builder", "caller"]) +def test_inherited_agent_dependencies_are_checked_in_builder_contexts(tmp_path, namespace, symbol, change, layout): + before = _files("adk", layout, "clean", factory=False) + after = _files("adk", layout, "clean", + "[read, write]" if change == "added" else "[]" if change == "removed" else "[read]", + "return 2" if change == "changed" else "return 1", factory=False) + source = "builders.py" if layout == "builder" else "app.py" + after[source] = f"from {namespace} import {symbol} as Carrier\nfrom shim import replacement\nCarrier.__init__ = replacement\n" + after[source] + _unread(_comparison(tmp_path, before, after)) + + +@pytest.mark.parametrize("framework,provider", [("sdk", "pydantic.py"), ("sdk", "pydantic/__init__.py"), + ("sdk", "typing.py"), ("adk", "pydantic.py"), + ("adk", "pydantic/__init__.py"), ("adk", "typing.py"), + ("adk", "google/genai/__init__.py"), ("sdk", "griffe.py"), + *[(framework, provider) for framework in ("sdk", "adk") + for provider in ("typing_extensions.py", "typing_extensions/__init__.py")]]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_local_dependency_provider_is_unread_without_an_application_import(tmp_path, framework, provider, change): + before, after = _versions(framework, "direct", change) + after[provider] = "class BaseModel:\n pass\n" + result = _comparison(tmp_path, before, after) + _unread(result) + assert any("dependency" in gap["reason"] and "supplies" in gap["reason"] + for gap in result["head"]["coverage_gaps"]) + + +@pytest.mark.parametrize("framework,ordinary", [ + ("adk", "from pydantic import Field\ndata = Field(default=None)\n"), + ("adk", "from pydantic.fields import Field\ndata = Field(default=None)\n"), + ("adk", "from typing_extensions import Any\n"), + ("sdk", "from typing_extensions import Any\n"), + ("adk", "from google.adk.workflow import BaseNode\n"), + ("sdk", "from agents.agent import AgentBase\n"), + ("sdk", "from typing import Generic\n"), + ("sdk", "from google.adk.agents import BaseAgent\nBaseAgent.__init__ = replacement\n"), + ("adk", "from agents.agent import AgentBase\nAgentBase.__setattr__ = replacement\n"), +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_unrelated_reads_and_other_framework_patches_preserve_changes(tmp_path, framework, ordinary, change): + before, after = _versions(framework, "direct", change) + after["app.py"] = "from shim import replacement\n" + ordinary + after["app.py"] + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert [(row["agent"], row["tool"], row["change"]) for row in result["rows"]] == [ + ("a" if framework == "sdk" else "Built", "read" if change == "changed" else "write" if change == "added" else "read", change) + ] + + +@pytest.mark.parametrize("framework,namespace,symbol", [ + ("sdk", "agents.tool", "inspect"), + ("sdk", "agents.function_schema", "inspect"), + ("sdk", "agents.agent", "inspect"), + ("adk", "google.adk.tools.function_tool", "inspect"), + ("sdk", "typing", "get_type_hints"), + ("adk", "typing", "get_type_hints"), + ("sdk", "agents.function_schema", "get_type_hints"), + ("adk", "google.adk.tools.function_tool", "get_type_hints"), + ("sdk", "pydantic", "Field"), + ("sdk", "pydantic.fields", "Field"), + ("sdk", "agents.function_schema", "Field"), + ("adk", "google.adk.utils.context_utils", "find_context_parameter"), + ("adk", "google.adk.tools.function_tool", "find_context_parameter"), + ("adk", "google.adk.tools._automatic_function_calling_util", "build_function_declaration"), + ("adk", "google.adk.tools.function_tool", "build_function_declaration"), + ("adk", "google.genai.types", "FunctionDeclaration"), + ("sdk", "griffe", "Docstring"), + ("sdk", "griffe._internal.models", "Docstring"), + ("sdk", "agents.function_schema", "Docstring"), +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +@pytest.mark.parametrize("bridge", [False, True]) +def test_schema_helper_carriers_cannot_establish_original_interfaces(tmp_path, framework, namespace, symbol, change, bridge): + before, after = _versions(framework, "direct", change) + # These shims modify the actual metadata mechanism without reflecting into + # arbitrary globals; each import must remain an identity obligation. + if symbol == "inspect": + code = "def replacement(reader):\n reader.signature = empty_signature\ndef empty_signature(*args, **kwargs):\n return None\n" + elif symbol == "Field": + code = "def replacement(reader):\n reader.__kwdefaults__['json_schema_extra'] = {'type': 'string'}\n" + elif symbol == "find_context_parameter": + code = "def replacement(reader):\n reader.__wrapped__.__code__ = excluded.__code__\ndef excluded(*args, **kwargs):\n return 'value'\n" + elif symbol == "FunctionDeclaration": + code = "def replacement(reader):\n reader.model_validate = substitute\ndef substitute(*args, **kwargs):\n return None\n" + elif symbol == "Docstring": + code = "def replacement(reader):\n reader.parse = empty_doc\ndef empty_doc(*args, **kwargs):\n return []\n" + else: + code = "def replacement(reader):\n reader.__code__ = strings_only.__code__\ndef strings_only(*args, **kwargs):\n return {'value': str}\n" + after["shim.py"] = code + after["bridge.py"] = f"from {namespace} import {symbol} as Carrier\n" + imported = "from bridge import Carrier\n" if bridge else f"from {namespace} import {symbol} as Carrier\n" + after["app.py"] = imported + "from shim import replacement\nreplacement(Carrier)\n" + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + +@pytest.mark.parametrize("framework,namespace,symbol,mutation", [ + ("sdk", "typing_extensions", "get_origin", "origin"), + ("adk", "typing_extensions", "get_args", "args"), + ("sdk", "typing_extensions", "get_type_hints", "hints"), + ("adk", "typing_extensions", "get_type_hints", "hints"), + ("sdk", "typing_extensions", "typing", "typing"), + ("adk", "typing_extensions", "typing", "typing"), + ("sdk", "typing_extensions", "Generic", "generic"), + *[(framework, namespace, "BaseModel", "model") + for framework in ("sdk", "adk") + for namespace in ("pydantic.type_adapter", "pydantic.root_model")], + *[("adk", namespace, "types", "schema") for namespace in ( + "google.genai._automatic_function_calling_util", "google.genai._transformers", "google.genai._extra_utils", + )], + *[("adk", namespace, "_common", "model") for namespace in ( + "google.genai._transformers", "google.genai._extra_utils", + )], +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +@pytest.mark.parametrize("bridge", [False, True]) +def test_canonical_dependency_aliases_cannot_hide_schema_mutation(tmp_path, framework, namespace, symbol, mutation, change, bridge): + before, after = _versions(framework, "direct", change) + if mutation == "args": + for files in (before, after): + files["tools.py"] = files["tools.py"].replace("value: int", "value: list[int]") + code = { + "origin": "def replacement(reader):\n from agents.run_context import RunContextWrapper\n reader.__code__ = false_origin.__code__\n reader.__defaults__ = (RunContextWrapper,)\ndef false_origin(annotation, context):\n return context\n", + "args": "def replacement(reader):\n reader.__code__ = string_args.__code__\ndef string_args(*args, **kwargs):\n return (str,)\n", + "hints": "def replacement(reader):\n reader.__code__ = string_hints.__code__\ndef string_hints(*args, **kwargs):\n return {'value': str}\n", + "typing": "def replacement(reader):\n reader.get_type_hints.__code__ = string_hints.__code__\ndef string_hints(*args, **kwargs):\n return {'value': str}\n", + "generic": "def replacement(reader):\n reader.__new__ = substitute\ndef substitute(*args, **kwargs):\n return None\n", + "model": "def replacement(reader):\n reader.model_json_schema = string_schema\ndef string_schema(*args, **kwargs):\n return {'type': 'object', 'properties': {'value': {'type': 'string'}}, 'required': ['value']}\n", + "schema": "def replacement(reader):\n saved = reader.Schema.__setattr__\n def string_type(self, name, value):\n if name == 'type':\n value = 'STRING'\n return saved(self, name, value)\n reader.Schema.__setattr__ = string_type\n", + }[mutation] + # _common exports its actual Pydantic BaseModel, not a new class sink. + if symbol == "_common": + code = code.replace("reader.model_json_schema", "reader.BaseModel.model_json_schema") + after["shim.py"] = code + after["bridge.py"] = f"from {namespace} import {symbol} as Carrier\n" + imported = "from bridge import Carrier\n" if bridge else f"from {namespace} import {symbol} as Carrier\n" + after["app.py"] = imported + "from shim import replacement\nreplacement(Carrier)\n" + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + +def _scoped_versions(framework, change): + before = _files(framework, "direct", "clean", factory=False) + after = _files(framework, "direct", "clean", + "[read, write]" if change == "added" else "[]" if change == "removed" else "[read]", + "return 2" if change == "changed" else "return 1", factory=False) + versions = [] + for files in (before, after): + scoped = {"pkg/__init__.py": "", "pkg/app/__init__.py": ""} + for path, source in files.items(): + for name in ("tools", "shim", "factory", "builders", "patcher", "bridge", "foreign"): + source = source.replace(f"from {name} import", f"from .{name} import") + scoped[f"pkg/app/{path}"] = source + versions.append(scoped) + return versions + + +STDLIB_ABC_CARRIERS = [ + ("collections", "_collections_abc", "ABCMeta"), ("collections", "abc", None), + ("collections.abc", "Mapping", None), ("contextlib", "abc", "ABCMeta"), + ("numbers", "ABCMeta", None), ("io", "abc", "ABCMeta"), ("dataclasses", "abc", "ABCMeta"), + ("_pyio", "abc", "ABCMeta"), ("fractions", "numbers", "ABCMeta"), + ("_pydecimal", "_numbers", "ABCMeta"), ("os", "abc", "ABCMeta"), + ("locale", "_collections_abc", "ABCMeta"), ("weakref", "_collections_abc", "ABCMeta"), + ("selectors", "ABCMeta", None), ("shelve", "collections", "_collections_abc.ABCMeta"), + ("traceback", "collections", "_collections_abc.ABCMeta"), ("configparser", "MutableMapping", None), + ("pathlib", "Sequence", None), ("random", "_Sequence", None), ("tracemalloc", "Iterable", None), +] diff --git a/tests/test_constructor_dependency_values.py b/tests/test_constructor_dependency_values.py new file mode 100644 index 000000000..0c79651da --- /dev/null +++ b/tests/test_constructor_dependency_values.py @@ -0,0 +1,113 @@ +"""Field metadata and annotation value ownership.""" + +from __future__ import annotations + +import pytest + +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_constructor_dependency_ownership import ( + _comparison, + _unread, + _versions, +) + + +@pytest.mark.parametrize("usage", [ + "saved = Field\n", + "from shim import replacement\nreplacement(Field)\n", + "def callback():\n return None\ndata = Field(default_factory=callback)\n", + "from shim import replacement\ndata = Field(default=replacement)\n", + "options = {'default': None}\ndata = Field(**options)\n", +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_adk_field_value_exception_does_not_authorize_retention_or_callbacks(tmp_path, usage, change): + before, after = _versions("adk", "direct", change) + after["app.py"] = "from pydantic import Field\n" + usage + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("usage", [ + "from foreign_consumer import consume\nconsume(data)\n", + "from foreign_consumer import consume\nconsume([data])\n", + "metadata = data.metadata\n", + "klass = data.__class__\n", + "copied = data.copy()\n", + "data.count(None)\n", + "data.index(None)\n", + "repr(data)\n", + "len(data)\n", + "str(data)\n", + "alias = data\n", + "def inspect_data():\n return data\nfrom foreign_consumer import consume\nconsume(inspect_data())\n", +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_field_result_cannot_export_shared_metadata_or_borrow_container_reads(tmp_path, usage, change): + before, after = _versions("adk", "direct", change) + after["app.py"] = "from pydantic import Field\ndata = Field(default=None)\n" + usage + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_field_result_exported_by_an_imported_module_is_unread(tmp_path, change): + before, after = _versions("adk", "direct", change) + after["fields.py"] = "from pydantic import Field\ndata = Field(default=None)\n" + after["bridge.py"] = "from fields import data\n" + after["app.py"] = "import bridge\n" + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("usage", [ + "Field(default=None)\n", + "data = Field(default=None)\ndata\n", + "data = Field(default=None)\nempty = data is None\n", + "data = Field(default=None)\ndef inspect_data():\n return data\n", +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_field_result_with_proven_inert_uses_preserves_changes(tmp_path, usage, change): + before, after = _versions("adk", "direct", change) + after["app.py"] = "from pydantic import Field\n" + usage + after["app.py"] + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "compared" + assert not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == change + + + +@pytest.mark.parametrize("prefix", [ + "from foreign_consumer import replacement\nField = replacement\n", + "def Field(**kwargs):\n return None\n", + "Field = None\n", +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_field_call_exception_requires_one_canonical_identity(tmp_path, prefix, change): + before, after = _versions("adk", "direct", change) + after["app.py"] = "from pydantic import Field\n" + prefix + "data = Field(default=None)\n" + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_field_factory_call_retains_a_named_helper_limit(tmp_path, change): + before, after = _versions("adk", "direct", change) + after["app.py"] = "from pydantic import Field\ndef create_field():\n return Field(default=None)\ndata = create_field()\n" + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("deferred", [False, True]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_typing_extension_annotations_keep_an_explicit_namespace_boundary(tmp_path, framework, deferred, change): + before, after = _versions(framework, "direct", change) + prefix = "from __future__ import annotations\n" if deferred else "" + after["app.py"] = prefix + "from typing_extensions import Any\ndef separate(value: Any):\n return None\n" + after["app.py"] + result = _comparison(tmp_path, before, after) + if deferred: + assert result["comparison_status"] == "compared" + assert not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == change + else: + _unread(result) diff --git a/tests/test_constructor_engine_fixture.py b/tests/test_constructor_engine_fixture.py new file mode 100644 index 000000000..2e86d6844 --- /dev/null +++ b/tests/test_constructor_engine_fixture.py @@ -0,0 +1,176 @@ +"""The ownership fixture must preserve descriptor and alias boundaries.""" + +import pytest + +from agents_shipgate.cli import application_diff +from agents_shipgate.core import verification_identity +from agents_shipgate.core.static_inputs import ( + StaticInputSnapshot, + activate_static_input_snapshot, + reset_static_input_snapshot, +) +from tests.constructor_engine_fixture import _frozen_application_engine + + +@pytest.fixture(scope="module") +def actual_engine(): + return verification_identity.build_engine_requirement(plugins_enabled=False) + + +def _builder(monkeypatch, actual_engine, transform=None): + calls = [] + + def build(*, plugins_enabled): + calls.append(plugins_enabled) + result = actual_engine.model_copy(deep=True) + return transform(result, len(calls), plugins_enabled) if transform else result + + monkeypatch.setattr(verification_identity, "build_engine_requirement", build) + monkeypatch.setattr(application_diff, "build_engine_requirement", build) + return build, calls + + +def test_real_descriptor_boundaries_and_defensive_copies(monkeypatch, actual_engine): + build, calls = _builder(monkeypatch, actual_engine) + with _frozen_application_engine() as frozen: + assert application_diff.build_engine_requirement is frozen + assert verification_identity.build_engine_requirement is build + first = frozen(plugins_enabled=False) + second = frozen(plugins_enabled=False) + assert first == second == actual_engine + assert first is not second and first is not actual_engine + first.__dict__["version"] = "mutated copy" + assert frozen(plugins_enabled=False) == actual_engine + assert calls == [False] + assert calls == [False, False] + assert application_diff.build_engine_requirement is build + + +@pytest.mark.parametrize("mode", [True, None, 0, "false"]) +def test_other_modes_delegate_to_the_original_builder(monkeypatch, actual_engine, mode): + build, calls = _builder(monkeypatch, actual_engine) + with _frozen_application_engine() as frozen: + assert frozen(plugins_enabled=mode) == actual_engine + assert len(calls) == 2 and calls[1] is mode + assert len(calls) == 3 and calls[-1] is False + assert application_diff.build_engine_requirement is build + + +@pytest.mark.parametrize("field", list(verification_identity.VerificationEngineRequirement.model_fields)) +def test_every_descriptor_field_drift_is_rejected(monkeypatch, actual_engine, field): + def drift(result, number, mode): + return result.model_copy(update={field: "changed"}) if number > 1 else result + + build, calls = _builder(monkeypatch, actual_engine, drift) + with pytest.raises(AssertionError, match="Engine inputs changed"): + with _frozen_application_engine(): + pass + assert calls == [False, False] + assert application_diff.build_engine_requirement is build + + +def test_setup_failure_does_not_replace_the_alias(monkeypatch, actual_engine): + def fail(result, number, mode): + raise RuntimeError("setup failed") + + build, calls = _builder(monkeypatch, actual_engine, fail) + with pytest.raises(RuntimeError, match="setup failed"): + with _frozen_application_engine(): + pytest.fail("setup failure cannot yield") + assert calls == [False] + assert application_diff.build_engine_requirement is build + + +def test_body_failure_remains_visible_and_restores_the_alias(monkeypatch, actual_engine): + build, calls = _builder(monkeypatch, actual_engine) + with pytest.raises(ValueError, match="body failed"): + with _frozen_application_engine(): + raise ValueError("body failed") + assert calls == [False, False] + assert application_diff.build_engine_requirement is build + + +def test_teardown_builder_failure_preserves_body_context_and_restores(monkeypatch, actual_engine): + def fail(result, number, mode): + if number > 1: + raise RuntimeError("teardown failed") + return result + + build, calls = _builder(monkeypatch, actual_engine, fail) + with pytest.raises(RuntimeError, match="teardown failed") as error: + with _frozen_application_engine(): + raise ValueError("body failed") + assert isinstance(error.value.__context__, ValueError) + assert str(error.value.__context__) == "body failed" + assert calls == [False, False] + assert application_diff.build_engine_requirement is build + + +def test_an_existing_patch_is_refused_without_overwriting(monkeypatch, actual_engine): + _build, calls = _builder(monkeypatch, actual_engine) + def alien(**kwargs): + return actual_engine + + monkeypatch.setattr(application_diff, "build_engine_requirement", alien) + with pytest.raises(AssertionError, match="already replaced"): + with _frozen_application_engine(): + pytest.fail("alien patch cannot yield") + assert not calls + assert application_diff.build_engine_requirement is alien + + +def test_nested_reuse_is_refused_without_disturbing_the_owner(monkeypatch, actual_engine): + build, calls = _builder(monkeypatch, actual_engine) + with _frozen_application_engine() as frozen: + with pytest.raises(AssertionError, match="already replaced"): + with _frozen_application_engine(): + pytest.fail("nested reuse cannot yield") + assert application_diff.build_engine_requirement is frozen + assert frozen(plugins_enabled=False) == actual_engine + assert calls == [False, False] + assert application_diff.build_engine_requirement is build + + +@pytest.mark.parametrize("owner", ["application", "core"]) +def test_builder_replacement_during_the_context_is_rejected(monkeypatch, actual_engine, owner): + build, calls = _builder(monkeypatch, actual_engine) + + def alien(**kwargs): + return actual_engine + + target = application_diff if owner == "application" else verification_identity + with pytest.raises(AssertionError, match=f"{owner.title()} engine builder changed"): + with _frozen_application_engine(): + monkeypatch.setattr(target, "build_engine_requirement", alien) + assert calls == [False] + assert application_diff.build_engine_requirement is build + assert verification_identity.build_engine_requirement is (alien if owner == "core" else build) + + +@pytest.mark.parametrize("active_at_entry", [False, True]) +def test_active_snapshot_uses_fresh_metadata(monkeypatch, actual_engine, tmp_path, active_at_entry): + build, calls = _builder(monkeypatch, actual_engine) + snapshot = StaticInputSnapshot(tmp_path) + token = activate_static_input_snapshot(snapshot) if active_at_entry else None + try: + with _frozen_application_engine() as selected: + if active_at_entry: + assert selected is build + assert application_diff.build_engine_requirement is build + else: + token = activate_static_input_snapshot(snapshot) + try: + assert selected(plugins_enabled=False) == actual_engine + assert selected(plugins_enabled=False) == actual_engine + assert len(calls) == (2 if active_at_entry else 3) + finally: + reset_static_input_snapshot(token) + token = None + if not active_at_entry: + assert selected(plugins_enabled=False) == actual_engine + assert len(calls) == 3 + finally: + if token is not None: + reset_static_input_snapshot(token) + assert len(calls) == (2 if active_at_entry else 4) + assert application_diff.build_engine_requirement is build diff --git a/tests/test_constructor_foreign_dependencies.py b/tests/test_constructor_foreign_dependencies.py new file mode 100644 index 000000000..96d579fbb --- /dev/null +++ b/tests/test_constructor_foreign_dependencies.py @@ -0,0 +1,184 @@ +"""Foreign dependency providers and their actual callers.""" + +from __future__ import annotations + +import pytest + +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_constructor_dependency_ownership import ( + _comparison, + _files, + _unread, + _versions, +) + + +@pytest.mark.parametrize("framework,namespace,symbol,slot", [ + ("sdk", "agents.agent_output", "BaseModel", "model_json_schema"), + ("sdk", "agents.items", "BaseModel", "model_json_schema"), + ("adk", "google.adk.tools.long_running_tool", "FunctionTool", "__init__"), + ("sdk", "google.adk.tools.base_tool", "BaseModel", "model_json_schema"), + ("adk", "agents.agent_output", "BaseModel", "__init__"), + ("adk", "agents.tool", "inspect", "signature"), + ("sdk", "google.adk.tools.function_tool", "get_type_hints", "__code__"), +]) +@pytest.mark.parametrize("bridge", [False, True]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_framework_and_cross_family_carriers_keep_shared_dependencies_owned(tmp_path, framework, namespace, symbol, slot, bridge, change): + before, after = _versions(framework, "direct", change) + after["bridge.py"] = f"from {namespace} import {symbol} as Carrier\n" + imported = "from bridge import Carrier\n" if bridge else f"from {namespace} import {symbol} as Carrier\n" + after["shim.py"] = f"def replacement(reader):\n reader.{slot} = changed\ndef changed(*args, **kwargs):\n return None\n" + after["app.py"] = imported + "from shim import replacement\nreplacement(Carrier)\n" + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework,namespace,symbol,slot", [ + ("adk", "agents", "AgentBase", "__setattr__"), + ("adk", "agents.agent", "AgentBase", "__new__"), + ("sdk", "google.adk.agents", "BaseAgent", "__init__"), + ("sdk", "google.adk.agents.base_agent", "BaseAgent", "__new__"), +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_exact_independent_foreign_class_slot_writes_preserve_changes(tmp_path, framework, namespace, symbol, slot, change): + before, after = _versions(framework, "direct", change) + after["app.py"] = f"from {namespace} import {symbol} as Carrier\nfrom shim import replacement\nCarrier.{slot} = replacement\n" + after["app.py"] + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "compared" + assert not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == change + + + +@pytest.mark.parametrize("framework,namespace,symbol,slot", [ + ("adk", "agents.agent", "AgentBase", "__new__"), + ("sdk", "google.adk.agents.base_agent", "BaseAgent", "__init__"), +]) +@pytest.mark.parametrize("usage", [ + "del Carrier.SLOT\n", + "Carrier.SLOT += replacement\n", + "Carrier.SLOT.__code__ = replacement\n", + "saved = Carrier.SLOT\n", + "from foreign_consumer import consume\nconsume(Carrier)\n", + "saved = Carrier\nsaved.SLOT = replacement\n", + "Carrier.SLOT = Carrier.other = replacement\n", + "metadata = Carrier.__dict__\n", + "Carrier()\n", +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_independent_foreign_slot_exception_never_grants_other_class_uses(tmp_path, framework, namespace, symbol, slot, usage, change): + before, after = _versions(framework, "direct", change) + after["app.py"] = f"from {namespace} import {symbol} as Carrier\nfrom shim import replacement\n" + usage.replace("SLOT", slot) + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework,namespace,symbol,slot,provider", [ + ("adk", "agents.agent", "AgentBase", "__new__", "griffe.py"), + ("adk", "agents.agent", "AgentBase", "__new__", "griffe/__init__.py"), + ("sdk", "google.adk.agents.base_agent", "BaseAgent", "__init__", "google/genai/__init__.py"), +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_foreign_slot_write_requires_implicit_foreign_dependency_providers(tmp_path, framework, namespace, symbol, slot, provider, change): + before, after = _versions(framework, "direct", change) + after["app.py"] = f"from {namespace} import {symbol} as Carrier\nfrom shim import replacement\nCarrier.{slot} = replacement\n" + after["app.py"] + after[provider] = "# A local provider is not the trusted foreign dependency.\n" + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("scope", ["conditional", "local", "mixed", "rebound"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_foreign_slot_exception_requires_a_stable_module_import(tmp_path, scope, change): + before, after = _versions("adk", "direct", change) + imported = "from agents.agent import AgentBase as Carrier\n" + patch = "Carrier.__new__ = replacement\n" + code = { + "conditional": "if enabled:\n " + imported + patch, + "local": "def patch():\n " + imported + " " + patch + "patch()\n", + "mixed": imported + "if enabled:\n from google.adk.agents import BaseAgent as Carrier\n" + patch, + "rebound": imported + "Carrier = replacement\n" + patch, + }[scope] + after["app.py"] = "from shim import replacement\n" + code + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("imported", ["google", "google.cloud"]) +@pytest.mark.parametrize("provider", [False, True]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_foreign_class_projection_from_an_ancestor_import_is_unread(tmp_path, imported, provider, change): + before, after = _versions("sdk", "direct", change) + after["app.py"] = f"import {imported}\nfrom shim import replacement\ngoogle.adk.agents.BaseAgent.__init__ = replacement\n" + after["app.py"] + if provider: + after["google/genai/__init__.py"] = "# The projected framework's implicit dependency is local.\n" + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("imported,receiver", [("import google.adk", "google.adk"), ("from google import adk", "adk")]) +@pytest.mark.parametrize("provider", [False, True]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_explicit_foreign_namespace_import_proves_its_implicit_providers(tmp_path, imported, receiver, provider, change): + before, after = _versions("sdk", "direct", change) + after["app.py"] = imported + f"\nfrom shim import replacement\n{receiver}.agents.BaseAgent.__init__ = replacement\n" + after["app.py"] + if provider: + after["google/genai/__init__.py"] = "# The foreign dependency is not external.\n" + result = _comparison(tmp_path, before, after) + if provider: + _unread(result) + else: + assert result["comparison_status"] == "compared" + assert not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == change + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("route", ["attribute", "opaque", "reexport", "namespace"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_private_class_getters_never_grant_receiving_authority(tmp_path, framework, route, change): + before, after = _versions(framework, "direct", change) + code = { + "attribute": "from pydantic._internal._import_utils import import_cached_base_model as load\nload().__init__ = replacement\n", + "opaque": "from pydantic._internal._import_utils import import_cached_base_model as load\nmodel = load()\nreplacement(model)\n", + "reexport": "from pydantic._internal._model_construction import import_cached_base_model as load\nload().__init__ = replacement\n", + "namespace": "import pydantic._internal._import_utils as internals\ninternals.import_cached_base_model().__init__ = replacement\n", + }[route] + after["app.py"] = "from shim import replacement\n" + code + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework,foreign,provider", [ + (framework, False, provider) for framework in ("sdk", "adk") + for provider in ("pydantic.py", "typing.py", "typing_extensions.py", "_collections_abc.py") +] + [("sdk", False, "griffe.py"), ("adk", False, "google/genai/__init__.py"), + ("adk", False, "mcp.py"), ("adk", False, "json.py"), ("adk", False, "yaml.py"), + ("sdk", True, "json.py"), ("sdk", True, "yaml.py"), + ("adk", True, "griffe.py"), ("sdk", True, "google/genai/__init__.py")]) +@pytest.mark.parametrize("context", ["builder", "caller", "imported"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_implicit_providers_are_proved_in_every_caller_and_import_context(tmp_path, framework, foreign, provider, context, change): + # ADK wrappers returned through helpers have a separate named limit. Plain + # functions establish the receiving Agent before we introduce a provider. + layout = "direct" if context == "imported" else context + before = _files(framework, layout, "clean", factory=False) + after = _files(framework, layout, "clean", + "[read, write]" if change == "added" else "[]" if change == "removed" else "[read]", + "return 2" if change == "changed" else "return 1", factory=False) + for files in (before, after): + files["apps/main.py"] = files.pop("app.py") + if foreign: + namespace, symbol, slot = (("agents.agent", "AgentBase", "__new__") if framework == "adk" + else ("google.adk.agents.base_agent", "BaseAgent", "__init__")) + after["apps/main.py"] = f"from {namespace} import {symbol} as Carrier\nfrom shim import replacement\nCarrier.{slot} = replacement\n" + after["apps/main.py"] + if context == "imported": + for files in (before, after): + files["helpers/reader.py"] = "def separate():\n return None\n" + files["apps/main.py"] = "from helpers.reader import separate\n" + files["apps/main.py"] + after[f"helpers/{provider}"] = "# A provider alongside an imported helper is unread.\n" + else: + after[f"apps/{provider}"] = "# A provider alongside the actual caller is unread.\n" + _unread(_comparison(tmp_path, before, after)) diff --git a/tests/test_constructor_import_refusal_context.py b/tests/test_constructor_import_refusal_context.py new file mode 100644 index 000000000..f308160e3 --- /dev/null +++ b/tests/test_constructor_import_refusal_context.py @@ -0,0 +1,158 @@ +"""Early constructor refusals remain negative and keep exact result context.""" + +import ast +from types import SimpleNamespace + +import pytest + +import agents_shipgate.inputs.list_expressions as expression_module +from agents_shipgate.inputs.list_expressions import _constructor_imports_unchanged +from agents_shipgate.inputs.python_imports import UNREADABLE_MODULE, _Stop +from tests.test_builder_calls import namespace_workspace as namespace_workspace +from tests.test_source_slot_refusal_context import _lists + + +def test_genuine_import_lookup_stop_keeps_its_original_negative_decision(namespace_workspace, monkeypatch): + lists = _lists(namespace_workspace, "from agents import Agent\na = Agent(name='App', tools=[])\n") + view = lists.entry + view.builder_calls = lists.calls + view.module_agent_reads = lambda module, call, keyword: False + call = view.tree.body[-1].value + reader = lists._resolver + original = reader._imported_paths + attempts = 0 + + def unread(module, statement): + nonlocal attempts + attempts += 1 + original(module, statement) + raise _Stop(UNREADABLE_MODULE, "selected captured import changed") + + monkeypatch.setattr(reader, "_imported_paths", unread) + refused = [] + assert not _constructor_imports_unchanged( + view, view, call, call, checked_modules=frozenset({view.module.path}), + qualified="agents.Agent", family="agents", refused_context=refused, + ) + assert attempts == 1 + assert len(refused) == 1 and UNREADABLE_MODULE in refused[0] + assert "selected captured import changed" in refused[0] + + +def test_cached_negative_constructor_read_uses_only_its_same_key_context(namespace_workspace, monkeypatch): + lists = _lists(namespace_workspace, "from agents import Agent\na = Agent(name='App', tools=[])\n") + view = lists.entry + view.builder_calls = lists.calls + view.module_agent_reads = lambda module, call, keyword: False + call = view.tree.body[-1].value + original = expression_module._constructor_imports_unchanged + attempts = 0 + + def declined(*args, **kwargs): + nonlocal attempts + attempts += 1 + assert original(*args, **kwargs) + kwargs["refused_context"].append("constructor imports: selected model declined") + return False + + monkeypatch.setattr(expression_module, "_constructor_imports_unchanged", declined) + first = [] + assert not lists._reads_agent(view, call, "tools", refused_context=first) + assert attempts == 1 and first == ["constructor imports: selected model declined"] + key = next(iter(view.constructor_reads)) + view.constructor_read_contexts[("another key",)] = ("unrelated stale context",) + again = [] + assert not lists._reads_agent(view, call, "tools", refused_context=again) + assert attempts == 1 and again == first + # The positive cache state must not publish its old negative sidecar. + view.constructor_reads[key] = True + positive = [] + assert lists._reads_agent(view, call, "tools", refused_context=positive) + assert positive == [] and attempts == 1 + + +def test_direct_constructor_result_context_is_bound_and_cleared_on_success_or_exception(namespace_workspace, monkeypatch): + lists = _lists(namespace_workspace, "from agents import Agent\na = Agent(name='App', tools=[])\n") + call = lists.entry.tree.body[-1].value + invocation = SimpleNamespace(key=("selected",)) + other_invocation = SimpleNamespace(key=("other",)) + result = False + attempts = 0 + + def read(view, current, keyword, *, refused_context=None): + nonlocal attempts + attempts += 1 + if result == "exception": + # A nested result must not survive an outer failure. + lists._constructor_change_context = (call, invocation.key, ("nested stale context",)) + raise RuntimeError("selected proof stopped") + if not result and refused_context is not None: + refused_context.append("constructor imports: selected negative result") + return result + + monkeypatch.setattr(lists, "_reads_agent", read) + assert lists.constructor_changed(call, invocation) + assert attempts == 1 + expected = ("constructor imports: selected negative result",) + assert lists.constructor_refusal_context(call, invocation) == expected + assert lists.constructor_refusal_context(call, other_invocation) == () + assert lists.constructor_refusal_context(ast.parse("a = Agent(tools=[])").body[0].value, invocation) == () + assert lists._invocation is None + result = True + assert not lists.constructor_changed(call, invocation) + assert lists.constructor_refusal_context(call, invocation) == () + result = "exception" + with pytest.raises(RuntimeError, match="selected proof stopped"): + lists.constructor_changed(call, invocation) + assert lists.constructor_refusal_context(call, invocation) == () + assert lists._invocation is None + assert not lists.constructor_changed(call, None) + assert lists.constructor_refusal_context(call, invocation) == () + + +def test_namespace_owner_context_names_the_actual_expression_and_result_key(namespace_workspace, monkeypatch): + lists = _lists(namespace_workspace, "from agents import Agent\na = Agent(name='App', tools=[])\n") + module = lists.entry.module + expression = module.tree.body[-1].value + reader = lists._resolver + reader._constructor_container_owners[id(expression)] = (module, expression) + attempts = 0 + + def changed(view, current): + nonlocal attempts + attempts += 1 + assert view.tree is module.tree and current is expression + return True + + monkeypatch.setattr(lists, "_factory_result_changed", changed) + assert lists.constructor_namespace_changed() + result_key = lists._constructor_namespace_result[0] + assert lists._constructor_namespace_refusal == ( + result_key, (f"constructor namespace owner container in {module.ref}:{expression.lineno} refused",), + ) + assert attempts == 1 + assert lists.constructor_namespace_changed() + assert attempts == 1 + + +def test_cached_constructor_issue_is_read_without_query_and_only_for_the_exact_call(namespace_workspace, monkeypatch): + lists = _lists(namespace_workspace, "from agents import Agent\na = Agent(name='App', tools=[])\n") + module = lists.entry.module + call = module.tree.body[-1].value + builder = lists.calls + key = (id(module.tree), id(call)) + builder._constructors[key] = "selected native constructor refused" + + def query(*args, **kwargs): + raise AssertionError("cached context must not rerun constructor proof") + + monkeypatch.setattr(builder, "constructor_issue", query) + assert builder.cached_constructor_issue(module, call) == "selected native constructor refused" + assert builder.cached_constructor_issue(module, ast.parse("Agent(tools=[])").body[0].value) is None + lists.entry.builder_calls = builder + lists._module_agent_reads = lambda module, call, keyword: False + lists._agent_reads = lambda call, keyword: False + refused = [] + assert not lists._reads_agent(lists.entry, call, "tools", refused_context=refused) + assert refused == [f"constructor read in {module.ref}: source consumer predicate did not recognize this construction", + f"constructor read in {module.ref}: cached constructor issue: selected native constructor refused"] diff --git a/tests/test_constructor_reference_budget.py b/tests/test_constructor_reference_budget.py new file mode 100644 index 000000000..a495f2dc0 --- /dev/null +++ b/tests/test_constructor_reference_budget.py @@ -0,0 +1,60 @@ +"""Unresolved attribute fallback is bounded and always remains unread.""" + +from __future__ import annotations + +import ast + +import pytest + +from agents_shipgate.inputs.python_imports import ( + MAX_STEPS, + RESOLUTION_LIMIT, + ImportResolver, + _external_constructor_use, + _Stop, +) + + +def _reader(root, text): + path = root / "app.py" + path.write_text(text) + reader = ImportResolver(root) + module = reader.entry(path, ast.parse(text), text) + return reader, module + + +@pytest.mark.parametrize("depth", [40, 80, 320]) +def test_unresolved_prefix_work_stops_with_named_obligation(tmp_path, monkeypatch, depth): + text = "opaque." + ".".join(f"field{i}" for i in range(depth)) + "\nfrom agents import Agent\n" + reader, module = _reader(tmp_path, text) + original = reader._constructor_reference + calls = 0 + + def counted(*args, **kwargs): + nonlocal calls + calls += 1 + return original(*args, **kwargs) + + monkeypatch.setattr(reader, "_constructor_reference", counted) + with pytest.raises(_Stop) as caught: + _external_constructor_use(reader, module, "agents", {module.path}, allow_owner_routes=False) + assert caught.value.reason == RESOLUTION_LIMIT + assert "app.py:1" in caught.value.detail and "unresolved attribute path" in caught.value.detail + assert calls <= MAX_STEPS + + +def test_known_terminal_projection_keeps_its_existing_semantic_route(tmp_path, monkeypatch): + text = "from agents import Agent\nAgent." + ".".join(f"field{i}" for i in range(320)) + "\n" + reader, module = _reader(tmp_path, text) + original = reader._constructor_reference + calls = 0 + + def counted(*args, **kwargs): + nonlocal calls + calls += 1 + return original(*args, **kwargs) + + monkeypatch.setattr(reader, "_constructor_reference", counted) + issue = _external_constructor_use(reader, module, "agents", {module.path}, allow_owner_routes=False) + assert issue is None # Preserve the existing bare-expression boundary. + assert calls <= 2 diff --git a/tests/test_constructor_structure_lineage.py b/tests/test_constructor_structure_lineage.py new file mode 100644 index 000000000..e2125307a --- /dev/null +++ b/tests/test_constructor_structure_lineage.py @@ -0,0 +1,108 @@ +"""Private namespace readers reuse actual syntax without sharing proofs.""" + +import ast + +import pytest + +from agents_shipgate.inputs.python_imports import ( + REBOUND_NAME, + ImportResolver, + _CapturedImportSearchResolver, + _external_constructor_use, + _module, + _RepositoryConstructorResolver, + _Stop, +) +from tests.test_builder_calls import namespace_workspace as namespace_workspace + + +def _source(root): + text = "from agents import Agent\na = Agent(name='App', tools=[])\n" + path = root / "app.py" + path.write_text(text) + reader = ImportResolver(root) + module = reader.entry(path, ast.parse(text), text) + return reader, module + + +@pytest.mark.parametrize("kind", ["captured", "repository"]) +def test_separate_readers_share_only_retained_actual_ast_roles(namespace_workspace, monkeypatch, kind): + source, module = _source(namespace_workspace) + if kind == "captured": + original = module + + def build(): + reader = _CapturedImportSearchResolver(source, [module]) + return reader, module + else: + original = source._layout_module(module.ref) + assert original is not None + + def build(): + reader = _RepositoryConstructorResolver(source) + return reader, reader.module(reader.scope_root / module.ref) + + scope = source._constructor_scope(original) + walk = ast.walk + walks = 0 + + def counted(tree): + nonlocal walks + if tree is original.tree: + walks += 1 + return walk(tree) + + monkeypatch.setattr(ast, "walk", counted) + summary = source._constructor_syntax(original) + assert walks == 1 + first, wrapper = build() + second, other_wrapper = build() + # Wrapper construction still indexes its own module metadata. Count only + # the subsequent scope/syntax queries this sharing change promises to reuse. + before_queries = walks + for reader, current in [(first, wrapper), (second, other_wrapper)]: + assert current.tree is original.tree + assert reader._constructor_scope(current) is scope + assert reader._constructor_syntax(current) is summary + assert reader._constructor_scopes is not source._constructor_scopes + assert reader._constructor_syntax_trees is not source._constructor_syntax_trees + assert reader._constructor_namespace_owners is not source._constructor_namespace_owners + assert reader._constructor_member_sinks is not source._constructor_member_sinks + assert walks == before_queries + # Same path and bytes with a separately parsed tree is not the retained wrapper. + clone = _module(wrapper.path, wrapper.ref, ast.parse(wrapper.text), wrapper.text) + assert first._constructor_syntax(clone) is not summary + assert first._constructor_scope(clone) is not scope + + +@pytest.mark.parametrize("kind", ["captured", "repository"]) +def test_shared_syntax_never_substitutes_fresh_provider_outcomes(namespace_workspace, monkeypatch, kind): + source, module = _source(namespace_workspace) + if kind == "captured": + reader = _CapturedImportSearchResolver(source, [module]) + current = module + else: + reader = _RepositoryConstructorResolver(source) + current = reader.module(reader.scope_root / module.ref) + source_summary = reader._constructor_syntax(current) + original = reader._constructor_reference + reject = False + reads = 0 + + def counted(home, node, scopes): + nonlocal reads + reads += 1 + result = original(home, node, scopes) + if reject and isinstance(node, ast.Name) and node.id == "Agent": + return {**result, "local_constructor_provider": "fresh wrapper provider refusal"} + return result + + monkeypatch.setattr(reader, "_constructor_reference", counted) + assert _external_constructor_use(reader, current, "agents", {current.path}, allow_owner_routes=False) is None + before = reads + reject = True + with pytest.raises(_Stop) as caught: + _external_constructor_use(reader, current, "agents", {current.path}, allow_owner_routes=False) + assert reads > before and caught.value.reason == REBOUND_NAME + assert caught.value.detail == "fresh wrapper provider refusal" + assert reader._constructor_syntax(current) is source_summary diff --git a/tests/test_constructor_syntax_reuse.py b/tests/test_constructor_syntax_reuse.py new file mode 100644 index 000000000..553b91305 --- /dev/null +++ b/tests/test_constructor_syntax_reuse.py @@ -0,0 +1,73 @@ +"""Structural reuse never substitutes an ownership or identity outcome.""" + +import ast + +import pytest + +from agents_shipgate.inputs.python_imports import ( + REBOUND_NAME, + ImportResolver, + _external_constructor_use, + _Stop, +) + + +def _module(reader, path, text): + path.write_text(text) + return reader.entry(path, ast.parse(text), text) + + +def test_actual_syntax_build_is_once_and_preserves_order(tmp_path, monkeypatch): + reader = ImportResolver(tmp_path) + text = "from agents import Agent\nimport foreign\nvalue = foreign.a.b\na = Agent(name='App', tools=[])\n" + module = _module(reader, tmp_path / "one.py", text) + reader._constructor_scope(module) + original = ast.walk + raw = tuple(original(module.tree)) + builds = 0 + + def counted(tree): + nonlocal builds + if tree is module.tree: + builds += 1 + return original(tree) + + monkeypatch.setattr(ast, "walk", counted) + summary = reader._constructor_syntax(module) + for _ in range(16): + assert reader._constructor_syntax(module) is summary + assert builds == 1 and summary.nodes == raw + assert summary.imports == tuple(item for item in raw if isinstance(item, ast.Import | ast.ImportFrom)) + assert all(any(item is raw_node for raw_node in raw) for item in summary.references) + assert any(isinstance(item, ast.Attribute) and item.attr == "b" for item in summary.references) + assert not any(isinstance(item, ast.Attribute) and item.attr == "a" for item in summary.references) + other = _module(reader, tmp_path / "two.py", text) + assert reader._constructor_syntax(other) is not summary + assert reader._constructor_syntax(other).nodes[0] is other.tree + assert not any(item is other_node for item in summary.references for other_node in reader._constructor_syntax(other).references) + + +def test_reused_syntax_rechecks_actual_semantic_outcomes(tmp_path, monkeypatch): + reader = ImportResolver(tmp_path) + text = "from agents import Agent\na = Agent(name='App', tools=[])\n" + module = _module(reader, tmp_path / "app.py", text) + original = reader._constructor_reference + calls = 0 + reject = False + + def counted(home, node, scopes): + nonlocal calls + calls += 1 + result = original(home, node, scopes) + if reject and isinstance(node, ast.Name) and node.id == "Agent": + return {**result, "local_constructor_provider": "fresh provider refusal"} + return result + + monkeypatch.setattr(reader, "_constructor_reference", counted) + assert _external_constructor_use(reader, module, "agents", {module.path}, allow_owner_routes=False) is None + previous = calls + reject = True + with pytest.raises(_Stop) as caught: + _external_constructor_use(reader, module, "agents", {module.path}, allow_owner_routes=False) + assert calls > previous and caught.value.reason == REBOUND_NAME + assert caught.value.detail == "fresh provider refusal" diff --git a/tests/test_constructor_typing_dependencies.py b/tests/test_constructor_typing_dependencies.py new file mode 100644 index 000000000..0628243e2 --- /dev/null +++ b/tests/test_constructor_typing_dependencies.py @@ -0,0 +1,219 @@ +"""Typing and ABC carriers preserve their constructor boundaries.""" + +from __future__ import annotations + +import pytest + +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_constructor_dependency_ownership import ( + STDLIB_ABC_CARRIERS, + _commit, + _compare, + _comparison, + _files, + _git, + _scoped_versions, + _unread, + _versions, +) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("layout", ["builder", "caller"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_external_dependencies_preserve_nested_caller_controls(tmp_path, framework, layout, change): + before = _files(framework, layout, "clean", factory=False) + after = _files(framework, layout, "clean", + "[read, write]" if change == "added" else "[]" if change == "removed" else "[read]", + "return 2" if change == "changed" else "return 1", factory=False) + for files in (before, after): + files["apps/main.py"] = files.pop("app.py") + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == change + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("namespace,symbol,patch", [ + ("typing", "Literal", "reader._getitem = changed"), + ("typing", "_LiteralGenericAlias", "reader.__init__.__code__ = changed.__code__"), + ("_collections_abc", "ABCMeta", "reader.__call__ = changed"), +]) +@pytest.mark.parametrize("bridge", [False, True]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_shared_typing_helpers_and_abc_aliases_cannot_escape_ownership(tmp_path, framework, namespace, symbol, patch, bridge, change): + before, after = _versions(framework, "direct", change) + after["bridge.py"] = f"from {namespace} import {symbol} as Carrier\n" + after["shim.py"] = f"def replacement(reader):\n {patch}\ndef changed(*args, **kwargs):\n return int\n" + imported = "from bridge import Carrier\n" if bridge else f"from {namespace} import {symbol} as Carrier\n" + after["app.py"] = imported + "from shim import replacement\nreplacement(Carrier)\n" + after["app.py"] + # The SDK annotations exercise the actual eager schema reader while the + # ADK case owns a concrete FunctionTool handle and its shared metaclass. + if framework == "sdk" and symbol in {"Literal", "_LiteralGenericAlias"}: + annotation = "Literal['read', 'write']" if symbol == "Literal" else "List[float]" + for files in (before, after): + files["tools.py"] = "from typing import Literal, List\n" + files["tools.py"].replace("value: int", f"value: {annotation}") + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("annotation", ["Any", "List[float]", "Literal['read', 'write']", "Optional[int]", "Annotated[int, 'value']", "Callable[[int], str]"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_canonical_public_typing_values_keep_their_annotation_role(tmp_path, framework, annotation, change): + before, after = _versions(framework, "direct", change) + for files in (before, after): + files["tools.py"] = "from typing import Any, List, Literal, Optional, Annotated, Callable\n" + files["tools.py"].replace("value: int", f"value: {annotation}") + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == change + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("usage", [ + "saved = Any\n", "from foreign_consumer import consume\nconsume(Any)\n", + "metadata = Any.__dict__\n", "Any()\n", "saved = Literal['read', 'write']\n", + "from foreign_consumer import consume\ndef separate(value: Any):\n return None\nconsume(separate)\n", +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_typing_annotation_exception_never_authorizes_other_value_roles(tmp_path, framework, usage, change): + before, after = _versions(framework, "direct", change) + after["app.py"] = "from typing import Any, Literal\n" + usage + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +@pytest.mark.parametrize("route", ["direct", "bridge", "transitive", "namespace", "conditional", "star", "ancestor", "unused"]) +def test_above_scope_dependencies_retain_a_named_constructor_limit(tmp_path, framework, change, route): + before, after = _scoped_versions(framework, change) + carrier = "from pydantic import BaseModel\ndef replacement(reader):\n reader.model_json_schema = changed\ndef changed(*args, **kwargs):\n return None\nreplacement(BaseModel)\n" + if route == "direct": + after["pkg/__init__.py"] = carrier + elif route in {"bridge", "transitive", "namespace"}: + after["pkg/carrier.py"] = carrier + after["pkg/bridge.py"] = "from .carrier import replacement\n" + after["pkg/__init__.py"] = ("from .carrier import replacement\n" if route == "bridge" else + "from .bridge import replacement\n" if route == "transitive" else + "from . import carrier\n") + else: + after["pkg/__init__.py"] = { + "conditional": "if enabled:\n from typing import get_origin\n", + "star": "from typing import *\n", + "ancestor": "import google.cloud\n", + "unused": "from _collections_abc import ABCMeta\n", + }[route] + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", "pkg/app") + _unread(result) + assert any("above the read scope" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("initialization", ["", "value = 1\n", "import google.cloud as cloud\n", "from .helper import separate\n"]) +def test_plain_above_scope_initialization_preserves_changes(tmp_path, framework, initialization): + before, after = _scoped_versions(framework, "added") + after["pkg/__init__.py"] = initialization + after["pkg/helper.py"] = "def separate():\n return None\n" + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", "pkg/app") + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == "added" + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("namespace,symbol", [("abc", "abstractmethod"), ("_collections_abc", "Mapping"), ("_collections_abc", "Iterable")]) +@pytest.mark.parametrize("bridge", [False, True]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_abc_function_globals_and_class_metaclass_carriers_remain_owned(tmp_path, framework, namespace, symbol, bridge, change): + before, after = _versions(framework, "direct", change) + after["bridge.py"] = f"from {namespace} import {symbol} as Carrier\n" + imported = "from bridge import Carrier\n" if bridge else f"from {namespace} import {symbol} as Carrier\n" + after["app.py"] = imported + "from foreign_consumer import consume\nconsume(Carrier)\n" + after["app.py"] + # abstractmethod carries abc's globals; Mapping and Iterable are concrete + # ABCMeta instances. An unread consumer cannot establish their ownership. + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("namespace,symbol,member", STDLIB_ABC_CARRIERS) +@pytest.mark.parametrize("bridge", [False, True]) +def test_primary_stdlib_abc_reexports_never_prove_unchanged_construction(tmp_path, framework, namespace, symbol, member, bridge): + before, after = _versions(framework, "direct", "added") + after["bridge.py"] = f"from {namespace} import {symbol} as Carrier\n" + imported = "from bridge import Carrier\n" if bridge else f"from {namespace} import {symbol} as Carrier\n" + usage = f"Carrier.{member}.__new__ = replacement\n" if member else "from foreign_consumer import consume\nconsume(Carrier)\n" + after["app.py"] = imported + "from shim import replacement\n" + usage + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("change", ["changed", "removed"]) +@pytest.mark.parametrize("namespace,symbol", [("collections", "_collections_abc"), ("contextlib", "abc"), ("os", "abc")]) +def test_primary_stdlib_reexports_also_withhold_changes_and_removals(tmp_path, framework, change, namespace, symbol): + before, after = _versions(framework, "direct", change) + after["app.py"] = f"from {namespace} import {symbol} as Carrier\nfrom shim import replacement\nCarrier.ABCMeta.__call__ = replacement\n" + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("namespace,symbol", [("io", "IOBase"), ("os", "PathLike"), ("weakref", "WeakKeyDictionary"), + ("contextlib", "suppress"), ("contextlib", "ExitStack"), + ("selectors", "DefaultSelector"), ("collections", "UserDict")]) +@pytest.mark.parametrize("usage", ["from foreign_consumer import consume\nconsume(Carrier)\n", "Carrier()\n"]) +def test_actual_inherited_abc_classes_gain_no_receiving_or_getter_authority(tmp_path, framework, namespace, symbol, usage): + before, after = _versions(framework, "direct", "added") + after["app.py"] = f"from {namespace} import {symbol} as Carrier\n" + usage + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +@pytest.mark.parametrize("spelling", ["module", "from", "bridge", "local"]) +def test_module_type_class_gains_no_receiving_or_getter_authority(tmp_path, framework, change, spelling): + before, after = _versions(framework, "direct", change) + uses = { + "module": "import types\ntypes.ModuleType('scratch')\n", + "from": "from types import ModuleType as Carrier\nCarrier('scratch')\n", + "bridge": "from bridge import Carrier\nCarrier('scratch')\n", + "local": "def touch():\n from types import ModuleType as Carrier\n Carrier('scratch')\ntouch()\n", + } + after["bridge.py"] = "from types import ModuleType as Carrier\n" + after["app.py"] = uses[spelling] + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_module_type_dependency_leaves_ordinary_types_calls_readable(tmp_path, framework, change): + before, after = _versions(framework, "direct", change) + after["app.py"] = "import types\ntypes.SimpleNamespace(value=1)\n" + after["app.py"] + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "compared" + assert not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == change + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("provider", ["types.py", "types/__init__.py"]) +@pytest.mark.parametrize("bridge", [False, True]) +def test_module_type_identity_requires_the_actual_import_provider(tmp_path, framework, provider, bridge): + before, after = _versions(framework, "direct", "added") + after[provider] = "class ModuleType:\n pass\n" + after["bridge.py"] = "from types import ModuleType as Carrier\n" + imported = "from bridge import Carrier\n" if bridge else "from types import ModuleType as Carrier\n" + after["app.py"] = imported + "Carrier()\n" + after["app.py"] + _unread(_comparison(tmp_path, before, after)) diff --git a/tests/test_constructor_unread_imports.py b/tests/test_constructor_unread_imports.py new file mode 100644 index 000000000..f52515869 --- /dev/null +++ b/tests/test_constructor_unread_imports.py @@ -0,0 +1,396 @@ +"""Unread dependency imports and above-scope snapshot ownership.""" + +from __future__ import annotations + +import pytest + +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_constructor_dependency_ownership import ( + _commit, + _compare, + _comparison, + _git, + _scoped_versions, + _unread, + _versions, +) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("imported", ["from os import getenv as Carrier", "from dataclasses import field as Carrier", + "from collections import Counter as Carrier", "from alternate_exports import ABCMeta as Carrier"]) +@pytest.mark.parametrize("route", ["opaque", "alias", "metadata", "mutation", "namespace"]) +def test_unread_imported_handle_retention_has_no_canonical_identity(tmp_path, framework, imported, route): + before, after = _versions(framework, "direct", "added") + after["bridge.py"] = imported + "\n" + usage = { + "opaque": "from foreign_consumer import consume\nconsume(Carrier)\n", + "alias": "saved = Carrier\n", + "metadata": "metadata = Carrier.metadata\n", + "mutation": "Carrier.changed = replacement\n", + "namespace": "import bridge\nfrom foreign_consumer import consume\nconsume(bridge)\n", + }[route] + after["app.py"] = ("from bridge import Carrier\n" if route != "namespace" else "") + "from shim import replacement\n" + usage + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("usage", [ + "import os\nvalue = os.getenv('KEY')\n", "import os\nvalue = os.path.join('a', 'b')\n", + "import io\nvalue = io.StringIO('value').getvalue()\n", "from pathlib import Path\nvalue = Path('data')\n", + "from collections import Counter, deque, defaultdict\nvalue = Counter()\nqueue = deque()\nlookup = defaultdict()\n", + "import dataclasses\n", "from alternate_exports import unused\n", + "import builtins\n", "import builtins as primitives\n", + "from builtins import getattr as lookup\n", "import inspect\n", + "import inspect as inspector\n", "from inspect import currentframe as capture\n", +]) +def test_ordinary_external_calls_and_unused_imports_keep_their_boundary(tmp_path, framework, usage): + before, after = _versions(framework, "direct", "added") + after["app.py"] = usage + after["app.py"] + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == "added" + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("usage", [ + "import builtins\nconsume(builtins)\n", + "from builtins import getattr as lookup\nconsume(lookup)\n", + "import inspect\nconsume(inspect)\n", + "from inspect import currentframe as capture\ncapture()\n", + "import importlib\n", +]) +def test_used_reflection_handles_and_dynamic_imports_remain_unread(tmp_path, framework, usage): + before, after = _versions(framework, "direct", "added") + after["app.py"] = "from foreign_consumer import consume\n" + usage + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("provider", ["inspect.py", "inspect/__init__.py"]) +def test_unused_inspect_import_does_not_hide_a_local_provider(tmp_path, framework, provider): + before, after = _versions(framework, "direct", "added") + namespace = "agents" if framework == "sdk" else "google.adk.agents" + after[provider] = f"from {namespace} import Agent\nfrom shim import replacement\nAgent.__init__ = replacement\n" + after["app.py"] = "import inspect\n" + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("provider", ["abc.py", "abc/__init__.py", "os.py", "io.py"]) +def test_preloaded_provider_controls_do_not_borrow_sibling_files(tmp_path, framework, provider): + before, after = _versions(framework, "direct", "added") + after[provider] = "# The canonical startup module is already loaded.\n" + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == "added" + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("patch", [ + "from os import getenv as Carrier\nSaved = Carrier\nCarrier = None\nconsume(Saved)\n", + "from alternate_exports import ABCMeta as Carrier\nSaved = Carrier\nCarrier = None\nconsume(Saved)\n", + "if enabled:\n from os import getenv as Carrier\nconsume(Carrier)\n", + "from os import getenv as Carrier\nfrom dataclasses import field as Carrier\nconsume(Carrier)\n", + "import os as Carrier\nimport alternate_exports as Carrier\nconsume(Carrier.getenv)\n", + "def helper():\n from os import getenv as Carrier\n Saved = Carrier\n Carrier = None\n consume(Saved)\nhelper()\n", + "def helper():\n if enabled:\n from os import getenv as Carrier\n consume(Carrier)\nhelper()\n", + "def helper():\n from os import getenv as Carrier\n from dataclasses import field as Carrier\n consume(Carrier)\nhelper()\n", +]) +def test_unread_import_candidates_survive_rebinding_and_mixed_lexical_bindings(tmp_path, framework, patch): + before, after = _versions(framework, "direct", "added") + after["app.py"] = "from foreign_consumer import consume\n" + patch + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("change", ["changed", "removed"]) +def test_saved_unread_handle_also_withholds_changes_and_removals(tmp_path, framework, change): + before, after = _versions(framework, "direct", change) + after["app.py"] = ("from os import getenv as Carrier\nSaved = Carrier\nCarrier = None\n" + "from foreign_consumer import consume\nconsume(Saved)\n" + after["app.py"]) + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("patch", [ + "from os import getenv as Carrier\ndef helper(Carrier):\n consume(Carrier)\nhelper(None)\n", + "from os import getenv as Carrier\ndef helper():\n Carrier = None\n consume(Carrier)\nhelper()\n", + "from os import getenv as Carrier\ndef outer():\n Carrier = None\n def helper():\n nonlocal Carrier\n consume(Carrier)\n helper()\nouter()\n", + "from os import getenv as Carrier\nclass Ordinary:\n Carrier = None\n def helper(self, value: Carrier):\n return None\n", + "from local_values import Carrier\nfrom foreign_exports import unused\nconsume(Carrier)\n", + "import local_values as Carrier, foreign_exports\nconsume(Carrier.value)\n", +]) +def test_unread_candidate_does_not_replace_actual_local_or_data_bindings(tmp_path, framework, patch): + before, after = _versions(framework, "direct", "added") + after["local_values.py"] = "Carrier = None\nvalue = None\n" + after["app.py"] = "from foreign_consumer import consume\n" + patch + after["app.py"] + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == "added" + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("patch", [ + "from os import getenv as Carrier\nconsume(Carrier)\n", + "from os import getenv as Carrier\nSaved = Carrier\nCarrier = None\nconsume(Saved)\n", + "if enabled:\n from os import getenv as Carrier\nconsume(Carrier)\n", + "from os import getenv as Carrier\nfrom dataclasses import field as Carrier\nconsume(Carrier)\n", + "from os import getenv as Carrier\nmetadata = Carrier.metadata\n", + "from os import getenv as Carrier\nCarrier.changed = None\n", + "from . import carrier\nconsume(carrier)\n", + "from .carrier import helper\nconsume(helper)\n", +]) +def test_above_scope_unread_imported_handles_keep_snapshot_ownership(tmp_path, framework, patch): + before, after = _scoped_versions(framework, "added") + after["pkg/carrier.py"] = "from os import getenv as Carrier\ndef helper():\n return None\n" + after["pkg/__init__.py"] = "from foreign_consumer import consume\n" + patch + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", "pkg/app") + _unread(result) + assert any("above the read scope" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("route", ["constructor", "builder"]) +def test_above_scope_constructor_owner_cannot_borrow_the_scope_list_reader(tmp_path, framework, route): + before, after = _scoped_versions(framework, "added") + imported = "from agents import Agent as Ctor\n" if framework == "sdk" else "from google.adk.agents import Agent as Ctor\n" + after["pkg/app/exports.py"] = imported + "def build(tools):\n return Ctor(name='Above', tools=tools)\n" + receiving = "Ctor" if route == "constructor" else "build" + invocation = "Ctor(name='Above', tools=[])" if route == "constructor" else "build([])" + after["pkg/__init__.py"] = (f"from .app.exports import {receiving}\n" + "from foreign_consumer import consume\n" + f"value = {invocation}\nconsume(value)\n") + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", "pkg/app") + _unread(result) + assert any("above the read scope" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("patch", [ + "from os import getenv as Carrier\nvalue = Carrier('KEY')\n", + "from os.path import join\nvalue = join('a', 'b')\n", + "from alternate_exports import unused\n", + "from os import getenv as Carrier\ndef helper(Carrier):\n consume(Carrier)\nhelper(None)\n", + "from os import getenv as Carrier\ndef helper():\n Carrier = None\n consume(Carrier)\nhelper()\n", +]) +def test_above_scope_ordinary_calls_and_lexical_data_keep_their_boundary(tmp_path, framework, patch): + before, after = _scoped_versions(framework, "added") + after["pkg/__init__.py"] = "from foreign_consumer import consume\n" + patch + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", "pkg/app") + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == "added" + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_above_scope_all_actual_import_candidates_retain_constructor_ownership(tmp_path, framework, change): + before, after = _scoped_versions(framework, change) + after["pkg/__init__.py"] = "import helper\n" + after["helper.py"] = "value = None\n" + after["pkg/helper.py"] = ("from abc import ABCMeta\nfrom foreign_consumer import replacement\n" + "ABCMeta.__call__ = replacement\n") + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + _unread(_compare(tmp_path, base, head, "--scope", "pkg/app")) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +def test_above_scope_listed_unread_sibling_cannot_be_dropped_for_a_clean_provider(tmp_path, framework): + before, after = _scoped_versions(framework, "added") + after["pkg/__init__.py"] = "import helper\n" + after["helper.py"] = "value = None\n" + # Above-scope snapshot reads have their own 4 MiB bound. Existence comes + # from ls-tree, not from whether this candidate's contents fit that bound. + after["pkg/helper.py"] = "#" + "x" * (4 * 1024 * 1024) + "\n" + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + _unread(_compare(tmp_path, base, head, "--scope", "pkg/app")) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("above", [False, True]) +@pytest.mark.parametrize("imported,provider", [ + ("from os.path import join\nvalue = join('a', 'b')\n", "os.py"), + ("from io import StringIO\nvalue = StringIO('value')\n", "io.py"), +]) +def test_startup_import_closure_does_not_read_impossible_sibling_providers(tmp_path, framework, above, imported, provider): + before, after = _scoped_versions(framework, "added") if above else _versions(framework, "direct", "added") + after[provider] = ("this is not valid Python!\n" if above else + "from abc import ABCMeta\nfrom foreign_consumer import replacement\nABCMeta.__call__ = replacement\n") + source = "pkg/__init__.py" if above else "app.py" + after[source] = imported + after[source] + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", "pkg/app" if above else ".") + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == "added" + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("retained", [False, True]) +def test_above_scope_src_provider_preserves_actual_data_and_namespace_roles(tmp_path, framework, retained): + before, after = _scoped_versions(framework, "added") + after["src/helper.py"] = ("from os import getenv as Carrier\n" if retained else "") + "value = None\n" + after["pkg/__init__.py"] = ("import helper\nfrom foreign_consumer import consume\n" + + ("consume(helper)\n" if retained else "consume(helper.value)\n")) + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", "pkg/app") + if retained: + _unread(result) + else: + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == "added" + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +def test_above_scope_linked_alternate_runner_cannot_borrow_a_clean_provider(tmp_path, framework): + before, after = _scoped_versions(framework, "added") + after["pkg/__init__.py"] = "import helper\n" + after["helper.py"] = "value = None\n" + after["pkg/mutator.py"] = ("from abc import ABCMeta\nfrom foreign_consumer import replacement\n" + "ABCMeta.__call__ = replacement\n") + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, before) + _commit(tmp_path, after) + (tmp_path / "pkg/helper.py").symlink_to("mutator.py") + _git(tmp_path, "add", "pkg/helper.py") + _git(tmp_path, "-c", "user.name=Test", "-c", "user.email=test@example.com", + "-c", "commit.gpgsign=false", "commit", "-qm", "linked alternate provider") + head = _git(tmp_path, "rev-parse", "HEAD") + _unread(_compare(tmp_path, base, head, "--scope", "pkg/app")) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +def test_newly_discovered_above_runner_keeps_a_missing_relative_import_limit(tmp_path, framework): + before, after = _scoped_versions(framework, "added") + after["pkg/__init__.py"] = "import helper\n" + after["helper.py"] = "value = None\n" + after["pkg/helper.py"] = "from .missing import value\n" + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + _unread(_compare(tmp_path, base, head, "--scope", "pkg/app")) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_expanded_above_closure_hands_every_in_scope_runner_to_original_census(tmp_path, framework, change): + before, after = _scoped_versions(framework, change) + after["pkg/__init__.py"] = "import app.helper\n" + after["app/__init__.py"] = "" + after["app/helper.py"] = "value = None\n" + after["pkg/app/helper.py"] = ("from abc import ABCMeta\nfrom foreign_consumer import replacement\n" + "ABCMeta.__call__ = replacement\n") + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + _unread(_compare(tmp_path, base, head, "--scope", "pkg/app")) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +def test_expanded_in_scope_ordinary_runner_has_its_own_original_census(tmp_path, framework): + before, after = _scoped_versions(framework, "added") + after["pkg/__init__.py"] = "import app.helper\n" + after["app/__init__.py"] = "" + after["app/helper.py"] = "value = None\n" + after["pkg/app/helper.py"] = "value = None\n" + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", "pkg/app") + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == "added" + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +def test_expanded_above_package_search_path_patch_keeps_a_named_limit(tmp_path, framework): + before, after = _scoped_versions(framework, "added") + after["pkg/__init__.py"] = "import helper\n" + after["helper.py"] = "value = None\n" + after["pkg/helper/__init__.py"] = "__path__.insert(0, 'opaque')\nfrom . import patches\n" + after["pkg/helper/patches.py"] = "value = None\n" + after["opaque/patches.py"] = ("from abc import ABCMeta\nfrom foreign_consumer import replacement\n" + "ABCMeta.__call__ = replacement\n") + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + _unread(_compare(tmp_path, base, head, "--scope", "pkg/app")) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("namespace,symbol", [("contextlib", "contextmanager"), ("dataclasses", "field"), + ("alternate_exports", "Carrier")]) +@pytest.mark.parametrize("route", ["direct", "namespace", "saved_rebound", "module_handle", "mutation", "projected_mutation"]) +def test_namespace_portions_do_not_verify_an_unread_external_imported_handle(tmp_path, framework, namespace, symbol, route): + before, after = _versions(framework, "direct", "added") + after[f"{namespace}/unrelated.py"] = "value = None\n" + usage = { + "direct": f"from {namespace} import {symbol} as Carrier\nconsume(Carrier)\n", + "namespace": f"import {namespace} as Carrier\nconsume(Carrier.{symbol})\n", + "saved_rebound": f"from {namespace} import {symbol} as Carrier\nSaved = Carrier\nCarrier = None\nconsume(Saved)\n", + "module_handle": f"import {namespace} as Carrier\nconsume(Carrier)\n", + "mutation": f"from {namespace} import {symbol} as Carrier\nCarrier.changed = None\n", + "projected_mutation": f"import {namespace} as Carrier\nCarrier.{symbol}.changed = None\n", + }[route] + after["app.py"] = "from foreign_consumer import consume\n" + usage + after["app.py"] + _unread(_comparison(tmp_path, before, after)) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("usage", [ + "from local_ns.data import value\nconsume(value)\n", + "from local_ns import data\nconsume(data.value)\n", + "import local_ns.data\nconsume(local_ns.data.value)\n", + "import local_ns\nconsume(local_ns.data.value)\n", +]) +def test_known_namespace_child_literal_preserves_actual_local_data(tmp_path, framework, usage): + before, after = _versions(framework, "direct", "added") + after["local_ns/data.py"] = "value = None\n" + after["app.py"] = "from foreign_consumer import consume\n" + usage + after["app.py"] + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == "added" + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("imported", ["from os import getenv as Carrier", "import os as Carrier"]) +@pytest.mark.parametrize("rebinding", ["Carrier = None", "del Carrier"]) +def test_unused_local_import_rebinding_does_not_mutate_the_external_object(tmp_path, framework, imported, rebinding): + before, after = _versions(framework, "direct", "added") + after["app.py"] = imported + "\n" + rebinding + "\n" + after["app.py"] + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert len(result["rows"]) == 1 and result["rows"][0]["change"] == "added" diff --git a/tests/test_declaration_scaffold.py b/tests/test_declaration_scaffold.py index 5e55f284a..5304cc830 100644 --- a/tests/test_declaration_scaffold.py +++ b/tests/test_declaration_scaffold.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import get_args +import pytest import yaml from agents_shipgate.cli.scan.declarations import build_declaration_scaffold @@ -844,6 +845,9 @@ def _cold_start_project(root: Path) -> Path: project = root / "cold-start" project.mkdir() listed = ",\n ".join(_COLD_START_TOOLS) + (project / "tools.py").write_text( + "from external_tool_package import " + ", ".join(_COLD_START_TOOLS) + "\n", encoding="utf-8", + ) (project / "agent.py").write_text( "from google.adk.agents import LlmAgent\n\n" f"from .tools import (\n {',\n '.join(_COLD_START_TOOLS)},\n)\n\n" @@ -877,7 +881,8 @@ def _scan_cold_start(project: Path, reports: Path): return report -def test_cold_start_walk_scaffolds_both_layers_in_two_iterations(tmp_path) -> None: +@pytest.mark.parametrize("provided_dependency", [False, True], ids=["unread-tool-objects", "readable-tool-source"]) +def test_cold_start_walk_scaffolds_both_layers_in_two_iterations(tmp_path, provided_dependency) -> None: """The whole of #361, walked: 5 hand-authored iterations become 2. Stage 1 (bare init) and stage 2 (inventory declared) both used to emit no @@ -927,7 +932,7 @@ def test_cold_start_walk_scaffolds_both_layers_in_two_iterations(tmp_path) -> No stage2 = _scan_cold_start(project, reports) assert stage2.release_decision is not None coverage = stage2.release_decision.evidence_coverage - assert coverage.binding_coverage.gap_count == 1 + assert coverage.binding_coverage.reason_counts == {"missing_binding_evidence": 1, "partial_binding_evidence": 1} assert len(stage2.tool_catalog) == len(_COLD_START_TOOLS) assert scaffold_path.is_file() # The repair is made, so the stage-1 inventory instruction is withdrawn @@ -957,18 +962,41 @@ def test_cold_start_walk_scaffolds_both_layers_in_two_iterations(tmp_path) -> No stage3 = _scan_cold_start(project, reports) assert stage3.release_decision is not None - assert stage3.release_decision.evidence_coverage.binding_coverage.gap_count == 0 + assert stage3.release_decision.evidence_coverage.binding_coverage.reason_counts == {"partial_binding_evidence": 1} assert len(stage3.binding_surface_facts.reachable_tool_ids) == len( _COLD_START_TOOLS ) - # --- and the route it advertises actually terminates --------------------- - # The prescribed repair used to be unreachable: the unresolved-import - # warnings stayed on the report after the inventory answered them, and - # `evidence_below_ie_threshold` gates on their raw count, so a repository - # that did exactly what it was told sat at `insufficient_evidence` forever - # with no non-warning gap left to act on (PR #401 review). - assert stage3.release_decision.evidence_coverage.source_warning_count == 0 + # The inventory retires unresolved-symbol warnings. Constructor ownership + # remains an independent obligation while the imported objects are unread. + assert stage3.release_decision.evidence_coverage.source_warning_count == 1 + assert all("constructor identity is not established" in warning for warning in stage3.source_warnings) + assert not _open_symbols(stage3) + + ownership = [gap for gap in stage3.release_decision.evidence_coverage.evidence_gaps + if gap.kind == "partial_binding_evidence"] + assert len(ownership) == len(_COLD_START_TOOLS) + 1 + for gap in ownership: + assert gap.next_action.kind == "provide_source" + assert gap.next_action.path == gap.source_ref + assert gap.next_action.path.endswith("agent.py:12") + assert not gap.next_action.accepted_values + assert gap.next_action.declaration_template is None + assert "binding declaration" in gap.next_action.expects + assert not any(gap.next_action.kind == "provide_complete_binding_graph" + for gap in stage3.release_decision.evidence_coverage.evidence_gaps) + + if provided_dependency: + # The reviewed inventory closes the name question. Readable plain + # function source separately closes the imported-object ownership + # question; regenerate the action scaffold against that evidence. + (project / "external_tool_package.py").write_text("".join( + f'def {name}():\n """Read the reviewed fixture record."""\n return None\n' + for name in _COLD_START_TOOLS + )) + provided = _scan_cold_start(project, reports) + assert provided.release_decision.evidence_coverage.binding_coverage.gap_count == 0 + assert not provided.source_warnings # The action blocks come from the scaffold itself, answered the way its # own comments instruct: pick a value from the printed vocabulary, and @@ -1000,19 +1028,21 @@ def test_cold_start_walk_scaffolds_both_layers_in_two_iterations(tmp_path) -> No assert final.release_decision is not None coverage = final.release_decision.evidence_coverage - # Every evidence layer is closed — including the policy one, which a - # uniform `read` would have left open. - assert coverage.binding_coverage.gap_count == 0 - assert coverage.semantic_coverage.gap_count == 0 + # Declarations close their layers; unread objects still withhold ownership. + assert coverage.binding_coverage.gap_count == (0 if provided_dependency else 1) + assert coverage.semantic_coverage.gap_count == (0 if provided_dependency else len(_COLD_START_TOOLS)) assert coverage.policy_gap_count == 0 - assert coverage.source_warning_count == 0 - assert coverage.evidence_gaps == [] - - # And the verdict is now a judgement about the declared surface rather - # than an abstention about the evidence: `blocked`, on a finding about the - # external-communication tool the walk just declared. + assert coverage.source_warning_count == (0 if provided_dependency else 1) + if provided_dependency: + assert coverage.evidence_gaps == [] + else: + assert {gap.kind for gap in coverage.evidence_gaps} == {"partial_binding_evidence", "source_warning"} + + # The policy blocker takes precedence on both routes. It does not retire + # the unread-object evidence gap or make that binding pass eligible. assert final.release_decision.decision == "blocked" assert final.release_decision.blockers + assert coverage.binding_coverage.pass_eligible is provided_dependency def test_binding_scaffold_is_withheld_rather_than_truncated(tmp_path) -> None: @@ -1278,6 +1308,9 @@ def _adk_project( project = root / name project.mkdir() + (project / "tools.py").write_text( + "from external_tool_package import " + ", ".join(symbols) + "\n", encoding="utf-8", + ) (project / "agent.py").write_text( "from google.adk.agents import LlmAgent\n\n" "from .tools import (\n" @@ -1348,7 +1381,8 @@ def test_a_split_toolset_inventory_is_not_prescribed_forever(tmp_path) -> None: # The symbol is nowhere in the inventory, and the repair is still complete. assert _open_symbols(report) == set() assert report.release_decision is not None - assert report.release_decision.evidence_coverage.source_warning_count == 0 + assert report.release_decision.evidence_coverage.source_warning_count == 1 + assert all("constructor identity is not established" in warning for warning in report.source_warnings) assert not (tmp_path / "reports" / "suggested-inventory.json").exists() @@ -1386,7 +1420,9 @@ def test_a_same_named_tool_elsewhere_does_not_complete_this_source(tmp_path) -> # Still owed: nothing declared anything about the ADK source. assert _open_symbols(report) == {"search"} assert report.release_decision is not None - assert report.release_decision.evidence_coverage.source_warning_count == 1 + assert report.release_decision.evidence_coverage.source_warning_count == 2 + assert sum("references unresolved tool" in warning for warning in report.source_warnings) == 1 + assert sum("constructor identity is not established" in warning for warning in report.source_warnings) == 1 def test_withdrawal_needs_a_declared_surface_not_just_a_bound_file(tmp_path) -> None: diff --git a/tests/test_direct_operator_constructor_ownership.py b/tests/test_direct_operator_constructor_ownership.py new file mode 100644 index 000000000..a6462dc7e --- /dev/null +++ b/tests/test_direct_operator_constructor_ownership.py @@ -0,0 +1,22 @@ +"""Inline operator syntax must retain the full source-function ownership proof.""" + +import pytest + +from tests.test_builder_bindings import ( + _assert_namespace_mutation_result, + _namespace_mutation_observations, +) +from tests.test_builder_calls import namespace_workspace as namespace_workspace + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("extra_use", ["", "fake.Agent(name='Used', tools=SHARED)\n", + "fake.Agent.__defaults__ = (framework.Agent,)\n"]) +def test_inline_operator_same_function_write_keeps_call_and_metadata_obligations( + namespace_workspace, framework, extra_use +): + built, warnings = _namespace_mutation_observations( + namespace_workspace, framework, + "import operator\noperator.ior(fake.__dict__, {'Agent': fake.Agent})\n" + extra_use, + ) + _assert_namespace_mutation_result(built, warnings, bool(extra_use)) diff --git a/tests/test_distribution_surface_parity.py b/tests/test_distribution_surface_parity.py index d7a67394b..6cb8bdfc8 100644 --- a/tests/test_distribution_surface_parity.py +++ b/tests/test_distribution_surface_parity.py @@ -585,6 +585,7 @@ class ParityGap: "assets": "images", "benchmark": "the accuracy corpus; an input to measurement, not a published answer", "ci_sharding.py": "test-run mechanics", + "ci_timing.py": "test-run timing observation; no runtime engine answer or distributed command", "conftest.py": "test-run mechanics", "constraints": "hash-locked dependency pins", "hatch_build.py": "build-only source provenance producer; no runtime engine answer, covered by tests/test_wheel_candidate_build.py", diff --git a/tests/test_eager_binding_ownership.py b/tests/test_eager_binding_ownership.py new file mode 100644 index 000000000..bc4af4c47 --- /dev/null +++ b/tests/test_eager_binding_ownership.py @@ -0,0 +1,241 @@ +"""Hidden Python bindings must not preserve a constructor or builtin proof.""" + +from __future__ import annotations + +import ast + +import pytest + +from agents_shipgate.inputs.list_expressions import evaluation_site +from agents_shipgate.inputs.python_imports import ScopeIndex, _module_bindings +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_framework_constructor_bindings import _files +from tests.test_imported_tool_bindings import _commit, _compare, _git + +MODULE_BINDERS = [ + "[(target := object) for item in (0,)]", + "{(target := object) for item in (0,)}", + "{item: (target := object) for item in (0,)}", + "((target := object) for item in (0,))", + "[[(target := object) for inner in (0,)] for outer in (0,)]", + "def initialize(value=(target := object)):\n pass", + "def initialize(*, value=(target := object)):\n pass", + "def initialize[T](value=(target := object)):\n pass", + "value = lambda argument=(target := object): argument", + "value = lambda argument=[(target := object) for item in (0,)]: argument", + "@(target := object)\ndef initialize():\n pass", + "def initialize(value: (target := object)):\n pass", + "def initialize() -> (target := object):\n pass", + "class Initialize((target := object)):\n pass", + "class Initialize(metaclass=(target := type)):\n pass", + "@(target := object)\nclass Initialize:\n pass", + "@(target := object)\nclass Initialize[T]:\n pass", + "def initialize():\n global target\n [(target := object) for item in (0,)]", +] + + +@pytest.mark.parametrize("source", MODULE_BINDERS) +def test_eager_or_comprehension_store_is_an_uncertain_module_binding(source): + tree = ast.parse(source) + compile(tree, "", "exec", dont_inherit=True) # Validate scope syntax, without execution. + bindings, _ = _module_bindings(tree) + assert "target" in bindings + assert all(not binding.top_level for binding in bindings["target"]) + assert any(isinstance(binding.node, ast.Name | ast.Global) for binding in bindings["target"]) + + +LEXICAL_BINDERS = [ + "[target for target in values]", + "[[target for target in values] for item in values]", + "def local():\n [(target := object) for item in (0,)]", + "def local():\n def initialize(value=(target := object)):\n pass", + "def local():\n value = lambda argument=(target := object): argument", + "value = lambda: (target := object)", + "value = lambda: [(target := object) for item in (0,)]", + "class Local:\n target = object", + "class Local:\n def initialize(value=(target := object)):\n pass", + "def outer():\n [(target := object) for item in (0,)]\n def inner():\n nonlocal target\n target = None", + "def outer():\n def initialize(value=(target := object)):\n pass\n def inner():\n nonlocal target\n [(target := None) for item in (0,)]", +] + + +@pytest.mark.parametrize("source", LEXICAL_BINDERS) +def test_lexical_cells_and_temporary_comprehension_targets_do_not_bind_module(source): + tree = ast.parse(source) + compile(tree, "", "exec", dont_inherit=True) + assert "target" not in _module_bindings(tree)[0] + + +@pytest.mark.parametrize("binding", [ + "[(target := object) for item in (0,)]", + "def initialize(value=(target := object)):\n pass", + "value = lambda argument=(target := object): argument", + "class Initialize((target := object)):\n pass", + "def initialize(value: (target := object)):\n pass", +]) +@pytest.mark.parametrize("nonlocal_use", [False, True]) +def test_hidden_lexical_cells_are_visible_to_their_actual_reader(binding, nonlocal_use): + body = "\n".join(" " + line for line in binding.splitlines()) + use = (" def reader():\n nonlocal target\n observe(target)\n" + if nonlocal_use else " observe(target)\n") + tree = ast.parse("def outer():\n" + body + "\n" + use) + compile(tree, "", "exec", dont_inherit=True) + scopes = ScopeIndex(tree) + probe = next(node.args[0] for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "observe") + bindings = scopes.enclosing_bindings(evaluation_site(scopes, probe), "target") + assert len(bindings) == 1 + assert isinstance(bindings[0], ast.Name) and isinstance(bindings[0].ctx, ast.Store) + assert "target" not in _module_bindings(tree)[0] + + +def test_lambda_body_walrus_stays_in_its_lambda_frame(): + tree = ast.parse("value = lambda: ((target := object), observe(target))") + compile(tree, "", "exec", dont_inherit=True) + scopes = ScopeIndex(tree) + probe = next(node.args[0] for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "observe") + assert len(scopes.enclosing_bindings(probe, "target")) == 1 + assert "target" not in _module_bindings(tree)[0] + + +@pytest.mark.parametrize("source,expected", [ + ("def build[Agent](tools):\n return Agent(tools=tools)", "TypeVar"), + ("class Builder[Agent]:\n def build(self, tools):\n return Agent(tools=tools)", "TypeVar"), + ("class Builder:\n Agent = replacement\n def build(self, tools):\n return Agent(tools=tools)", None), +]) +def test_generic_annotation_cells_are_distinct_from_ordinary_class_locals(source, expected): + tree = ast.parse("from framework import Agent\n" + source) + compile(tree, "", "exec", dont_inherit=True) + scopes = ScopeIndex(tree) + use = next(node.func for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "Agent") + bindings = scopes.enclosing_bindings(use, "Agent") + assert [type(binding).__name__ for binding in bindings] == ([] if expected is None else [expected]) + assert len(_module_bindings(tree)[0]["Agent"]) == 1 + + +SHADOWS = [ + "[(TARGET := replacement) for item in (0,)]\n", + "def initialize(value=(TARGET := replacement)):\n pass\n", + "[[(TARGET := replacement) for inner in (0,)] for outer in (0,)]\n", +] + + +def _surface(framework, name, selected="[read]", body="return 1", shadow="", *, + replacement_used=False, mutating_replacement=True): + files = _files(framework, "direct", "clean", selected, body, factory=False) + namespace = "agents" if framework == "sdk" else "google.adk.agents" + use = "print(TOOLS)\n" if name == "print" else "" + tools = "list(TOOLS)" if name == "list" else "TOOLS" + files["app.py"] = (f"from {namespace} import Agent\nfrom tools import read, write\n" + + ("from shim import replacement\n" if shadow or replacement_used else "") + + f"TOOLS = {selected}\n" + + shadow.replace("TARGET", name) + use + f"a = Agent(name='Built', tools={tools})\n") + if name == "print": + files["shim.py"] = ("def replacement(values):\n values.clear()\n" if mutating_replacement + else "def replacement(values):\n return None\n") + elif name == "list": + files["shim.py"] = "def replacement(values):\n return []\n" + return files + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("name", ["Agent", "print", "list"]) +@pytest.mark.parametrize("shadow", SHADOWS) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_hidden_module_substitution_withholds_added_changed_and_removed_bindings(tmp_path, framework, name, shadow, change): + before = _surface(framework, name) + after = _surface(framework, name, + "[read, write]" if change == "added" else "[]" if change == "removed" else "[read]", + "return 2" if change == "changed" else "return 1", shadow) + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert not result["base"]["coverage_gaps"] + assert result["comparison_status"] == "partial" + assert result["head"]["coverage_gaps"] + assert all(row["change"] == "not_established" for row in result["rows"]) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("name", ["Agent", "print", "list"]) +@pytest.mark.parametrize("local", [ + "[TARGET for TARGET in (None,)]\n", + "def helper():\n [(TARGET := replacement) for item in (0,)]\n", + "def helper():\n def initialize(value=(TARGET := replacement)):\n pass\n", + "class Helper:\n def initialize(value=(TARGET := replacement)):\n pass\n", + "def helper():\n [(TARGET := replacement) for item in (0,)]\n def inner():\n nonlocal TARGET\n TARGET = None\n", +]) +def test_lexical_substitutions_keep_the_actual_module_constructor_and_builtin(tmp_path, framework, name, local): + # These controls isolate lexical shadowing. An unrelated imported list + # mutator already withholds ownership under the previous reader's census. + before = _surface(framework, name, replacement_used=True, mutating_replacement=False) + after = _surface(framework, name, "[read, write]", replacement_used=True, mutating_replacement=False) + for files in (before, after): + files["app.py"] += local.replace("TARGET", name) + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "compared" + assert not result["base"]["coverage_gaps"] and not result["head"]["coverage_gaps"] + assert [(row["agent"], row["tool"], row["change"]) for row in result["rows"]] == [ + ("a" if framework == "sdk" else "Built", "write", "added") + ] + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("kind", [ + "generic_builder", "generic_method", "generic_annotation", "type_alias", + "function_bound_compare", "function_bound_print", "class_bound_compare", "function_constraints", + "postponed_argument", "postponed_return", "postponed_module", "postponed_class", + "local_variable", "local_attribute", "local_subscript", +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_type_parameter_and_deferred_constructor_scopes_withhold_changes(tmp_path, framework, kind, change): + before = _surface(framework, "Agent") + after = _surface(framework, "Agent", + "[read, write]" if change == "added" else "[]" if change == "removed" else "[read]", + "return 2" if change == "changed" else "return 1") + # SDK observations use the receiving variable where there is one. Keep + # that identity equal to an unassigned constructor's literal name. + before["app.py"] = before["app.py"].replace("a = Agent(", "Built = Agent(") + call = "Agent(name='Built', tools=TOOLS)" + route = { + "generic_builder": "def build[Agent](tools):\n return Agent(name='Built', tools=tools)\nBuilt = build(TOOLS)\n", + "generic_method": "class Builder[Agent]:\n def build(self, tools):\n return Agent(name='Built', tools=tools)\nBuilt = Builder().build(TOOLS)\n", + "generic_annotation": f"def initialize[Agent](value: {call}):\n pass\n", + "type_alias": f"type Held = {call}\n", + "function_bound_compare": f"def initialize[T: ({call} is None)]():\n pass\n", + "function_bound_print": f"def initialize[T: print({call})]():\n pass\n", + "class_bound_compare": f"class Initialize[T: ({call} is None)]:\n pass\n", + "function_constraints": f"def initialize[T: (print({call}), int)]():\n pass\n", + "postponed_argument": f"def initialize(value: ({call} is None)):\n pass\n", + "postponed_return": f"def initialize() -> ({call} is None):\n pass\n", + "postponed_module": f"Built: ({call} is None)\n", + "postponed_class": f"class Initialize:\n Built: ({call} is None)\n", + "local_variable": f"def initialize():\n Built: ({call} is None)\n", + "local_attribute": f"def initialize(owner):\n owner.Built: ({call} is None)\n", + "local_subscript": f"def initialize(owner):\n owner['Built']: ({call} is None)\n", + }[kind] + after["app.py"] = after["app.py"].replace("a = " + call + "\n", route) + if kind.startswith("postponed_"): + for files in (before, after): + files["app.py"] = "from __future__ import annotations\n" + files["app.py"] + compile(after["app.py"], "", "exec", dont_inherit=True) + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert not result["base"]["coverage_gaps"] + assert result["comparison_status"] == "partial" + assert result["head"]["coverage_gaps"] + assert all(row["change"] == "not_established" for row in result["rows"]) + if kind not in {"generic_builder", "generic_method"}: + boundary = ( + "generic annotation scope" if kind == "generic_annotation" else + "deferred type-alias scope" if kind == "type_alias" else + "postponed annotation" if kind.startswith("postponed_") else + "unevaluated function-local annotation" if kind.startswith("local_") else + "deferred type-parameter scope" + ) + assert any(boundary in gap["reason"] for gap in result["head"]["coverage_gaps"]) diff --git a/tests/test_evidence_gap_ranking.py b/tests/test_evidence_gap_ranking.py index cc94634ed..3867667a1 100644 --- a/tests/test_evidence_gap_ranking.py +++ b/tests/test_evidence_gap_ranking.py @@ -164,6 +164,9 @@ def _write_adk_project(tmp_path: Path, *, bind_tool_identity: bool) -> Path: project = tmp_path / "adk-imported-tools" project.mkdir() imports = ",\n ".join(sorted(_ADK_SYMBOLS)) + (project / "tools.py").write_text( + "from external_tool_package import " + ", ".join(sorted(_ADK_SYMBOLS)) + "\n", encoding="utf-8", + ) listed = ",\n ".join(_ADK_SYMBOLS) (project / "agent.py").write_text( f""" @@ -437,9 +440,9 @@ def test_imported_symbols_get_one_source_row_naming_the_inventory_repair( inventory wiring from the docs. The repair exists and is mechanical, so the decision engine states it. - Exactly ONE row states it. The six warnings are one mechanism restated per - symbol (``core.source_warnings``); attaching an action to each would put - raw loader prose back into the headline, which is what grouping removed. + One row states the inventory repair. The six unresolved-symbol warnings + are one mechanism; the imported-object constructor limit is a separate + obligation and keeps its own binding repair and warning. """ decision = imported_symbols_report.release_decision @@ -448,8 +451,15 @@ def test_imported_symbols_get_one_source_row_naming_the_inventory_repair( gaps = decision.evidence_coverage.evidence_gaps addressable = [gap for gap in gaps if gap.next_action.path] - assert len(addressable) == 1 - (repair,) = addressable + assert len(addressable) == 2 + [repair] = [gap for gap in addressable if gap.next_action.kind == "declare_tool_inventory"] + [ownership] = [gap for gap in addressable if gap.kind == "partial_binding_evidence"] + assert ownership.next_action.kind == "provide_source" + assert ownership.next_action.path == ownership.source_ref + assert ownership.next_action.path.endswith("agent.py:12") + assert not ownership.next_action.accepted_values + assert ownership.next_action.declaration_template is None + assert "constructor identity is not established" in ownership.why assert repair.kind == "incomplete_surface" assert repair.next_action.kind == "declare_tool_inventory" assert repair.next_action.path == "shipgate.yaml#google_adk.tool_inventories" @@ -470,12 +480,14 @@ def test_imported_symbols_get_one_source_row_naming_the_inventory_repair( # The restatements stay inert: no path, no command, nothing to open. warnings = [gap for gap in gaps if gap.kind == "source_warning"] - assert len(warnings) == 6 + assert len(warnings) == 7 + assert sum("references unresolved tool" in gap.subject for gap in warnings) == 6 + assert sum("constructor identity is not established" in gap.subject for gap in warnings) == 1 assert not any(gap.next_action.path or gap.next_action.command for gap in warnings) assert {gap.next_action.kind for gap in warnings} == {"review_warning"} assert decision.reason.startswith("Insufficient evidence: ") - assert "shipgate.yaml#google_adk.tool_inventories" in decision.reason + assert ownership.next_action.path in decision.reason def test_reason_keeps_the_threshold_wording_when_no_gap_is_addressable(): @@ -1225,19 +1237,22 @@ def test_render_time_grouping_does_not_move_the_count_that_gates( assert decision is not None evidence = decision.evidence_coverage - assert len(imported_symbols_report.source_warnings) == 6 - assert evidence.source_warning_count == 6 - assert len(group_source_warnings(imported_symbols_report.source_warnings)) == 1 - # The threshold still sees six, so the verdict is unchanged. + assert len(imported_symbols_report.source_warnings) == 7 + assert evidence.source_warning_count == 7 + groups = group_source_warnings(imported_symbols_report.source_warnings) + assert sorted(len(group.warnings) for group in groups) == [1, 6] + assert sum("constructor identity is not established" in group.message for group in groups) == 1 + # The distinct constructor limit must not join the unresolved-symbol group. assert evidence_below_ie_threshold(evidence, tool_count=0) assert decision.decision == "insufficient_evidence" warning_bullets = _source_warning_bullets( render_markdown_report(imported_symbols_report) ) - assert len(warning_bullets) == 1 - assert "(6 warnings)" in warning_bullets[0] - assert "tool symbols" in warning_bullets[0] + assert len(warning_bullets) == 2 + [symbols] = [line for line in warning_bullets if "tool symbols" in line] + assert "(6 warnings)" in symbols + assert any("constructor identity is not established" in line for line in warning_bullets) def _source_warning_bullets(markdown: str) -> list[str]: @@ -1266,7 +1281,8 @@ def test_cli_summary_prints_both_the_raw_count_and_the_mechanism_count( _print_cli_summary(imported_symbols_report, "advisory", 0, verbose=True) console = capsys.readouterr().out - assert "Source warnings: 6 (1 distinct mechanism)" in console + assert "Source warnings: 7 (2 distinct mechanisms)" in console + assert "constructor identity is not established" in console assert console.count("references unresolved tool") == 0 assert "(6 warnings)" in console @@ -1278,8 +1294,9 @@ def test_fix_task_groups_source_warnings_before_capping(imported_symbols_report) warning_remedies = [ line for line in remedies if line.startswith("Resolve source warning: ") ] - assert len(warning_remedies) == 1 - assert "6 tool symbols" in warning_remedies[0] + assert len(warning_remedies) == 2 + assert sum("6 tool symbols" in line for line in warning_remedies) == 1 + assert sum("constructor identity is not established" in line for line in warning_remedies) == 1 # --- the wrong lever gets a correction signal -------------------------------- @@ -1474,8 +1491,9 @@ def test_fix_task_still_treats_review_only_warnings_as_prose( warning_lines = [ line for line in remedies if line.startswith("Resolve source warning: ") ] - assert len(warning_lines) == 1 - assert "6 tool symbols" in warning_lines[0] + assert len(warning_lines) == 2 + assert sum("6 tool symbols" in line for line in warning_lines) == 1 + assert sum("constructor identity is not established" in line for line in warning_lines) == 1 # --- the published contract matches the implementation ----------------------- diff --git a/tests/test_external_helper_constructor_ownership.py b/tests/test_external_helper_constructor_ownership.py new file mode 100644 index 000000000..3e1483692 --- /dev/null +++ b/tests/test_external_helper_constructor_ownership.py @@ -0,0 +1,84 @@ +"""A source helper preserves only the existing ordinary external call boundary.""" + +import pytest + +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_constructor_dependency_ownership import _comparison, _versions + + +def _with_helper(framework, header, function, extra=None): + before, after = _versions(framework, "direct", "added") + before["support.py"] = "def send(value):\n return None\n" + after["support.py"] = header + function + after["tools.py"] = "from support import send\n" + after["tools.py"].replace("def write(value: int):\n return None", "def write(value: int):\n return send(value)") + assert "return send(value)" in after["tools.py"] + after.update(extra or {}) + return before, after + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("header,expression", [ + ("import requests\n", 'requests.get("https://example.test", params={"value": value})'), + ("import requests as http\n", 'http.get("https://example.test").json()'), + ("from requests import get as fetch\n", 'fetch("https://example.test").json()'), + ("", 'fetch("https://example.test").json()'), + ("import requests\n", 'requests.get("https://example.test").json().copy()'), +]) +def test_stable_absolute_external_call_results_keep_the_inline_boundary(tmp_path, framework, header, expression): + local_import = " from requests import get as fetch\n" if not header else "" + function = "def send(value):\n" + local_import + " return " + expression + "\n" + result = _comparison(tmp_path, *_with_helper(framework, header, function)) + assert result["comparison_status"] == "compared" + assert not result["head"]["coverage_gaps"] + assert [(row["tool"], row["change"]) for row in result["rows"]] == [("write", "added")] + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("header,function,extra", [ + ("import requests\n", "def send(value):\n return requests.get\n", {}), + ("import requests\n", "def send(value):\n return requests.response\n", {}), + ("import requests\n", "def send(value):\n response = requests.get(value)\n return response\n", {}), + ("import requests\n", "def send(value):\n return requests.get(value)()\n", {}), + ("import requests\n", "def send(value):\n return requests.get(value).payload.json()\n", {}), + ("import requests\n", "def send(value):\n return requests['get'](value)\n", {}), + ("import requests\n", "def send(value):\n return getattr(requests, 'get')(value)\n", {}), + ("import requests\n", "def send(requests):\n return requests(requests)\n", {}), + ("import requests\n", "def send(value):\n requests = value\n return requests(value)\n", {}), + ("if choice:\n import requests\n", "def send(value):\n return requests.get(value)\n", {}), + ("import requests\nfrom vendor import requests\n", "def send(value):\n return requests.get(value)\n", {}), + ("from .vendor import fetch\n", "def send(value):\n return fetch(value)\n", {}), + ("import requests\n", "def send(value):\n return missing(value)\n", {}), + ("import requests\nclass Receiver:\n pass\n", "def send(value):\n return Receiver()\n", {}), + ("import pydantic\n", "def send(value):\n return pydantic.create_model('Held')\n", {}), + ("import requests\n", "def send(value):\n return requests.get(value)\n", {"requests.py": "from vendor import get\n"}), + ("import requests\nfrom abc import ABCMeta\n", "def send(value):\n return requests.get(ABCMeta)\n", {}), + ("import requests\n", "async def send(value):\n return requests.get(value)\n", {}), + ("import requests\n", "def send(value):\n yield requests.get(value)\n", {}), + ("import requests\n", "def send(value):\n return requests.get(value).a().b().c().d()\n", {}), + ("import requests.client\n", "def send(value):\n return requests.client.get(value)\n", {"requests/__init__.py": "# An actual local regular provider.\n"}), +]) +def test_helper_call_parity_grants_no_other_handle_or_receiver_role(tmp_path, framework, header, function, extra): + result = _comparison(tmp_path, *_with_helper(framework, header, function, extra)) + assert result["comparison_status"] == "partial" + assert result["head"]["coverage_gaps"] + assert all(row["change"] == "not_established" for row in result["rows"]) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("route", ["helper", "inline"]) +@pytest.mark.parametrize("expression", [ + "requests.get(value).__class__()", + "requests.get(value).__getattribute__('__class__')", + "requests.get(value).__getattr__('__class__')", + "requests.get(value).json().__class__()", + "requests.get(requests.get(value).__class__).json()", +]) +def test_call_rooted_reflection_cannot_borrow_ordinary_call_parity(tmp_path, framework, route, expression): + before, after = _with_helper(framework, "import requests\n", "def send(value):\n return " + expression + "\n") + if route == "inline": + after["tools.py"] = "import requests\n" + after["tools.py"].replace("return send(value)", "return " + expression) + after["support.py"] = before["support.py"] + result = _comparison(tmp_path, before, after) + assert result["comparison_status"] == "partial" + assert result["head"]["coverage_gaps"] + assert all(row["change"] == "not_established" for row in result["rows"]) diff --git a/tests/test_framework_constructor_bindings.py b/tests/test_framework_constructor_bindings.py new file mode 100644 index 000000000..4aae2564d --- /dev/null +++ b/tests/test_framework_constructor_bindings.py @@ -0,0 +1,139 @@ +"""Changed constructors cannot establish added, changed or removed bindings.""" + +from __future__ import annotations + +import pytest + +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_imported_tool_bindings import _adk, _commit, _compare, _git, _sdk + + +def _files(framework, layout, patch, selected="[read]", body="return 1", *, factory=True): + namespace = "agents" if framework == "sdk" else "google.adk.agents" + decorator = "from agents import function_tool\n@function_tool\n" if framework == "sdk" else "" + files = { + "tools.py": decorator + f"def read():\n {body}\n" + + ("@function_tool\n" if framework == "sdk" else "") + "def write():\n return None\n", + "factory.py": f"from tools import read, write\ndef make_tools():\n return {selected}\n", + "shim.py": "def replacement(*args, **kwargs):\n return kwargs\n", + } + header = f"import {namespace} as fw\nfrom {namespace} import Agent\nfrom shim import replacement\n" + constructor = "fw.Agent" if patch != "root_rebind" else "Agent" + patches = { + "clean": "", "root_rebind": "Agent = replacement\n", + "namespace": "fw.Agent = replacement\n", "init": "fw.Agent.__init__ = replacement\n", + "new": "Agent.__new__ = replacement\n", "metadata": "Agent.__pydantic_validator__ = replacement\n", + "alias": "A = Agent\nB = A\nB.__init__ = replacement\n", + "dotted_alias": "A = fw.Agent\nA.__init__ = replacement\n", + "opaque": "replacement(Agent)\n", "namespace_opaque": "replacement(fw)\n", + "reflection": "getattr(Agent, runtime_name()).__code__ = replacement\n", + "setattr": "setattr(fw, 'Agent', replacement)\n", + "table": f"import sys\nsys.modules['{namespace}'] = replacement\n", + "imported": "import patcher\n", "transitive": "import bridge\n", + "unrelated": "import foreign\nforeign.Widget.__init__ = replacement\n", + } + files["foreign.py"] = "class Widget:\n pass\n" + files["patcher.py"] = f"from {namespace} import Agent as A\nfrom shim import replacement\nA.__init__ = replacement\n" + files["bridge.py"] = "import patcher\n" + if patch in {"reexport", "reexport_metadata", "star_reexport", "bridge_opaque", "helper_opaque", "conditional_reexport", "rebound_reexport", "saved_rebound"}: + files["bridge.py"] = f"from {namespace} import Agent as Carrier\ndef helper():\n return None\n" + files["patcher.py"] = { + "reexport": "from bridge import Carrier\nCarrier.__init__ = replacement\n", + "reexport_metadata": "from bridge import Carrier\nCarrier.__pydantic_validator__ = replacement\n", + "star_reexport": "from bridge import *\nCarrier.__new__ = replacement\n", + "bridge_opaque": "import bridge\nreplacement(bridge)\n", + "helper_opaque": "from bridge import helper\nreplacement(helper)\n", + "conditional_reexport": "from bridge import Carrier\nCarrier.__init__ = replacement\n", + "rebound_reexport": "from bridge import Carrier\nCarrier.__init__ = replacement\n", + "saved_rebound": f"from {namespace} import Agent\nA = Agent\nAgent = object\nA.__init__ = replacement\n", + }[patch] + if patch == "conditional_reexport": + files["bridge.py"] = f"if flag:\n from {namespace} import Agent as Carrier\n" + elif patch == "rebound_reexport": + files["bridge.py"] += "Carrier = object\n" + patches[patch] = "import patcher\n" + elif patch in {"namespace_reexport", "namespace_alias"}: + files["bridge.py"] = f"import {namespace} as api\n" + files["patcher.py"] = ("import bridge\nbridge.api.Agent = replacement\n" if patch == "namespace_reexport" + else "from bridge import api as target\ntarget.Agent = replacement\n") + patches[patch] = "import patcher\n" + elif patch in {"exec", "exec_alias", "builtins", "dynamic", "globals", "vars"}: + files["patcher.py"] = header + { + "exec": "exec(payload)\n", "exec_alias": "from builtins import exec as run\nrun(payload)\n", + "builtins": "__builtins__['exec'](payload)\n", + "dynamic": f"import importlib\nother = importlib.import_module('{namespace}')\nother.Agent = replacement\n", + "globals": "globals()['Agent'].__init__ = replacement\n", + "vars": "vars(Agent)['__init__'] = replacement\n", + }[patch] + patches[patch] = "import patcher\n" + tools = "make_tools()" if factory else selected + call_header = "from factory import make_tools\n" if factory else "from tools import read, write\n" + if layout == "direct": + files["app.py"] = header + patches[patch] + call_header + f"a = {constructor}(name='Built', tools={tools})\n" + else: + files["builders.py"] = header + (patches[patch] if layout == "builder" else "") + f"def build(tools):\n return {constructor}(name='Built', tools=tools)\n" + files["app.py"] = header + (patches[patch] if layout == "caller" else "") + "from builders import build\n" + call_header + f"a = build({tools})\n" + return files + + +def _run(root, framework, layout, patch, change, *, factory=True): + _git(root, "init", "-q", "-b", "main") + before = _files(framework, layout, "clean", factory=factory) + after = _files(framework, layout, patch, "[read, write]" if change == "added" else "[]" if change == "removed" else "[read]", "return 2" if change == "changed" else "return 1", factory=factory) + base, head = _commit(root, before), _commit(root, after) + result = _compare(root, base, head, "--scope", ".") + source = "app.py" if layout == "direct" else "builders.py" + if framework == "sdk": + loaded = _sdk(root, source) + observations, warnings = loaded.binding_observations, loaded.warnings + else: + loaded, artifacts = _adk(root, source) + observations = [item for part in loaded for item in part.binding_observations] + warnings = artifacts.warnings + return result, observations, warnings + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("layout,patch", [(layout, patch) for layout in ("direct", "builder", "caller") + for patch in ("root_rebind", "namespace", "init", "new", "metadata", "alias", "dotted_alias", "opaque", "namespace_opaque", "reflection", "setattr", "table", "imported", "transitive") + if (layout, patch) != ("caller", "root_rebind")]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +@pytest.mark.parametrize("factory", [False, True]) +def test_constructor_changes_are_binding_uncertainty(tmp_path, framework, layout, patch, change, factory): + result, observations, warnings = _run(tmp_path, framework, layout, patch, change, factory=factory) + assert warnings and observations + assert all(not item.tools_complete and not item.handoffs_complete for item in observations) + assert any("constructor" in warning for warning in warnings) + assert result["comparison_status"] != "compared" + assert all(row["change"] == "not_established" for row in result["rows"]) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("layout,patch", [(layout, patch) for layout in ("direct", "builder", "caller") for patch in ("clean", "unrelated")] + + [("caller", "root_rebind")]) +def test_clean_constructors_still_establish_factory_additions(tmp_path, framework, layout, patch): + result, observations, warnings = _run(tmp_path, framework, layout, patch, "added") + assert warnings == [] and observations and all(item.tools_complete for item in observations) + assert result["comparison_status"] == "compared" + assert any(row["tool"] == "write" and row["change"] == "added" for row in result["rows"]) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +def test_unread_constructors_retain_named_handoff_candidates(tmp_path, framework): + namespace = "agents" if framework == "sdk" else "google.adk.agents" + source = (f"from {namespace} import Agent\n" + "Agent.__init__ = object\n" + "worker = Agent(name='Worker', tools=[])\n" + "root = Agent(name='Root', " + + ("handoffs" if framework == "sdk" else "sub_agents") + "=[worker])\n") + (tmp_path / "agent.py").write_text(source) + if framework == "sdk": + observations = _sdk(tmp_path, "agent.py").binding_observations + root = next(item for item in observations if item.agent == "root") + assert root.handoff_names == ["worker"] and not root.handoffs_complete + assert root.constructor_issues + else: + _, artifacts = _adk(tmp_path, "agent.py") + root = next(item for item in artifacts.sub_agents if item.get("agent_name") == "Root") + assert root["sub_agents"] == ["Worker"] and root["sub_agent_count"] == 1 + assert root["unread"] diff --git a/tests/test_framework_constructor_imports.py b/tests/test_framework_constructor_imports.py new file mode 100644 index 000000000..35abd9993 --- /dev/null +++ b/tests/test_framework_constructor_imports.py @@ -0,0 +1,23 @@ +"""Imported constructor patches retain binding uncertainty.""" + +from __future__ import annotations + +import pytest + +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_framework_constructor_bindings import ( + _run, +) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("layout", ["direct", "caller"]) +@pytest.mark.parametrize("factory", [False, True]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +@pytest.mark.parametrize("patch", ["reexport", "reexport_metadata", "star_reexport", "namespace_reexport", "namespace_alias", "bridge_opaque", "helper_opaque", "exec", "exec_alias", "builtins", "dynamic", "globals", "vars", "conditional_reexport", "rebound_reexport", "saved_rebound"]) +def test_imported_constructor_patch_cannot_establish_a_binding(tmp_path, framework, layout, factory, patch, change): + result, observations, warnings = _run(tmp_path, framework, layout, patch, change, factory=factory) + assert warnings and observations + assert all(not item.tools_complete and not item.handoffs_complete for item in observations) + assert all(row["change"] == "not_established" for row in result["rows"]) + assert result["comparison_status"] != "compared" diff --git a/tests/test_framework_constructor_retention.py b/tests/test_framework_constructor_retention.py new file mode 100644 index 000000000..474334830 --- /dev/null +++ b/tests/test_framework_constructor_retention.py @@ -0,0 +1,826 @@ +"""Constructor namespaces remain owned across retaining callbacks and values.""" + +from __future__ import annotations + +import pytest + +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_framework_constructor_bindings import ( + _adk, + _commit, + _compare, + _files, + _git, + _sdk, +) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("carrier", ["coroutine", "generator", "generator_expression", "lambda", "frame", "frame_alias"]) +def test_retaining_helpers_cannot_escape_a_frame_or_callable(tmp_path, framework, carrier): + before = _files(framework, "direct", "clean", factory=False) + after = _files(framework, "direct", "imported", selected="[]", factory=False) + namespace = "agents" if framework == "sdk" else "google.adk.agents" + body = { + "coroutine": "async def helper():\n return 0\n", "generator": "def helper():\n yield 0\n", + "generator_expression": "def helper():\n return (item for item in ())\n", + "lambda": "def helper():\n return lambda: None\n", + "frame": "import sys\ndef helper():\n return sys._getframe()\n", + "frame_alias": "from inspect import currentframe as capture\ndef helper():\n return capture()\n", + }[carrier] + after["bridge.py"] = f"from {namespace} import Agent\n" + body + after["patcher.py"] = "from bridge import helper\nfrom shim import replacement\nreplacement(helper())\n" + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] != "compared" + assert all(row["change"] == "not_established" for row in result["rows"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("escape", ["", "consume(a)", "consume(a.tools[0])", "alias = a.tools"]) +def test_local_tool_registration_keeps_constructor_namespace_ownership(tmp_path, framework, escape): + from tests.test_imported_tool_bindings import _write + namespace = "agents" if framework == "sdk" else "google.adk.agents" + decorator = "from agents import function_tool\n@function_tool\n" if framework == "sdk" else "" + _write(tmp_path, {"app.py": f"from {namespace} import Agent\n" + decorator + "def read():\n return 1\na = Agent(name='Built', tools=[read])\n" + escape + "\n"}) + if framework == "sdk": + loaded = _sdk(tmp_path, "app.py") + observations, warnings = loaded.binding_observations, loaded.warnings + else: + loaded, artifacts = _adk(tmp_path, "app.py") + observations, warnings = [item for source in loaded for item in source.binding_observations], artifacts.warnings + assert observations + assert all(item.tools_complete == (not escape) for item in observations) + assert bool(warnings) == bool(escape) + + + +@pytest.mark.parametrize("provider", ["agents.py", "agents/__init__.py"]) +def test_non_git_local_sdk_provider_is_not_the_external_constructor(tmp_path, provider): + from tests.test_imported_tool_bindings import _write + _write(tmp_path, {"app.py": "from agents import Agent, function_tool\n@function_tool\ndef read():\n return 1\na = Agent(name='Built', tools=[read])\n", + provider: "class Agent:\n pass\ndef function_tool(fn):\n return fn\n"}, project=False) + loaded = _sdk(tmp_path, "app.py") + assert loaded.warnings and loaded.binding_observations + assert all(not item.tools_complete for item in loaded.binding_observations) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("escape", ["consume(other)", "other.__class__.__init__ = replacement", "type(other).__init__ = replacement"]) +@pytest.mark.parametrize("empty", [True, False]) +def test_every_agent_instance_retains_the_constructor_class(tmp_path, framework, escape, empty): + namespace = "agents" if framework == "sdk" else "google.adk.agents" + before = _files(framework, "direct", "clean", factory=False) + before["app.py"] = f"from {namespace} import Agent\nfrom tools import read\nother = Agent(name='Other', tools=[])\na = Agent(name='Built', tools=[read])\n" + after = dict(before) + after["shim.py"] = "from tools import read\ndef replacement(self, *args, **kwargs):\n self.tools = [read]\ndef consume(value):\n value.__class__.__init__ = replacement\n" + after["app.py"] = (f"from {namespace} import Agent\nfrom tools import read\nfrom shim import consume, replacement\n" + f"other = Agent(name='Other', tools={'[]' if empty else '[read]'})\n{escape}\na = Agent(name='Built', tools=[])\n") + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] != "compared" + assert all(row["change"] == "not_established" for row in result["rows"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +def test_unrelated_empty_handle_escape_invalidates_a_safe_parent_child(tmp_path, framework): + from tests.test_imported_tool_bindings import _write + namespace = "agents" if framework == "sdk" else "google.adk.agents" + field = "handoffs" if framework == "sdk" else "sub_agents" + _write(tmp_path, {"tools.py": ("from agents import function_tool\n@function_tool\n" if framework == "sdk" else "") + "def read():\n return 1\n", + "app.py": f"from {namespace} import Agent\nfrom tools import read\nchild = Agent(name='Child', tools=[read])\nroot = Agent(name='Root', {field}=[child])\nother = Agent(name='Other', tools=[])\nconsume(other)\n"}) + if framework == "sdk": + loaded = _sdk(tmp_path, "app.py") + observations = loaded.binding_observations + else: + loaded, _ = _adk(tmp_path, "app.py") + observations = [item for part in loaded for item in part.binding_observations] + assert observations and all(not item.tools_complete for item in observations) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("carrier", ["lambda: None", "(value for value in ())"]) +@pytest.mark.parametrize("inline", [False, True]) +def test_anonymous_namespace_carriers_never_establish_bindings(tmp_path, framework, carrier, inline): + before = _files(framework, "direct", "clean", factory=False) + after = _files(framework, "direct", "clean", selected="[read, write]", factory=False) + escape = f"replacement({carrier})\n" if inline else f"proxy = {carrier}\nreplacement(proxy)\n" + after["app.py"] = after["app.py"].replace("a = ", escape + "a = ") + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] != "compared" + assert all(row["change"] == "not_established" for row in result["rows"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("escape, complete", [("a.tools.append(local)", True), ("a.tools = [local]", True), + ("a.tools.append(local)\nconsume(a.tools[0])", False)]) +def test_an_empty_agent_can_receive_only_owned_namespace_carriers(tmp_path, framework, escape, complete): + from tests.test_imported_tool_bindings import _write + namespace = "agents" if framework == "sdk" else "google.adk.agents" + decorator = "from agents import function_tool\n@function_tool\n" if framework == "sdk" else "" + _write(tmp_path, {"app.py": f"from {namespace} import Agent\n" + decorator + + "def local():\n return 1\na = Agent(name='Empty', tools=[])\n" + escape + + "\nb = Agent(name='Other', tools=[local])\n"}) + if framework == "sdk": + observations = _sdk(tmp_path, "app.py").binding_observations + else: + loaded, _ = _adk(tmp_path, "app.py") + observations = [item for source in loaded for item in source.binding_observations] + peer = next(item for item in observations if item.agent == ("b" if framework == "sdk" else "Other")) + assert peer.tool_names == ["local"] + assert peer.tools_complete is complete + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("header", ["def invoke(Agent=Agent):", "def invoke(*, Agent=Agent):", + "class Invoke:\n def invoke(Agent=Agent):"]) +def test_eager_defaults_cannot_borrow_the_parameter_scope(tmp_path, framework, header): + before = _files(framework, "direct", "clean", factory=False) + after = _files(framework, "direct", "clean", selected="[read, write]", factory=False) + indent = " " if header.startswith("class") else " " + injection = header + "\n" + indent + "replacement(Agent)\n" + ("Invoke.invoke()\n" if header.startswith("class") else "invoke()\n") + after["app.py"] = after["app.py"].replace("a = ", injection + "a = ") + after["shim.py"] = "def changed(self, *args, **kwargs):\n self.tools = []\ndef replacement(value):\n value.__init__ = changed\n" + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] != "compared" + assert all(row["change"] == "not_established" for row in result["rows"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("injection", ["def invoke(Agent: replacement(Agent)):\n pass\n", + "values = [0 for Agent in replacement(Agent)]\n"]) +def test_eager_annotations_and_first_iterables_use_the_outer_scope(tmp_path, framework, injection): + before = _files(framework, "direct", "clean", factory=False) + after = _files(framework, "direct", "clean", selected="[read, write]", factory=False) + after["app.py"] = after["app.py"].replace("a = ", injection + "a = ") + after["shim.py"] = "def changed(self, *args, **kwargs):\n self.tools = []\ndef replacement(value):\n value.__init__ = changed\n return []\n" + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert all(row["change"] == "not_established" for row in result["rows"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("carrier", [ + "if flag:\n def carrier():\n pass\nelse:\n def carrier():\n pass\nreplacement(carrier)\n", + "def carrier():\n pass\nold = carrier\ncarrier = 0\nreplacement(old)\n", + "def invoke():\n def carrier():\n pass\n old = carrier\n carrier = 0\n replacement(old)\ninvoke()\n", + "class Carrier:\n def method(self):\n pass\nold = Carrier\nCarrier = 0\nreplacement(old)\n", +]) +def test_ambiguous_and_saved_local_namespace_holders_stay_unread(tmp_path, framework, carrier): + before = _files(framework, "direct", "clean", factory=False) + after = _files(framework, "direct", "clean", selected="[read, write]", factory=False) + after["app.py"] = after["app.py"].replace("a = ", carrier + "a = ") + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert all(row["change"] == "not_established" for row in result["rows"]) + + + +@pytest.mark.parametrize("provider", ["google.py", "google/__init__.py", "google/adk.py", "google/adk/__init__.py", + "google/adk/agents.py", "google/adk/agents/__init__.py"]) +def test_non_git_local_adk_import_ancestors_cannot_prove_an_external_class(tmp_path, provider): + from tests.test_imported_tool_bindings import _write + _write(tmp_path, {"app.py": "from google.adk.agents import Agent\ndef read():\n return 1\na = Agent(name='Built', tools=[read])\n", + provider: "class Agent:\n pass\n"}, project=False) + loaded, artifacts = _adk(tmp_path, "app.py") + observations = [item for source in loaded for item in source.binding_observations] + assert observations and artifacts.warnings and all(not item.tools_complete for item in observations) + + + +@pytest.mark.parametrize("framework,variant", [("sdk", "opaque"), ("adk", "opaque"), + ("sdk", "namespace_patch"), ("sdk", "bridge_patch")]) +def test_implicit_decorator_callbacks_cannot_escape_the_constructor_namespace(tmp_path, framework, variant): + before = _files(framework, "direct", "clean", factory=False) + after = _files(framework, "direct", "clean", selected="[read, write]", factory=False) + after["shim.py"] = "retained = []\ndef decorate(fn):\n retained.append(fn)\n return fn\n" + if variant == "opaque": + injection = "from shim import decorate\n@decorate\ndef carrier():\n pass\n" + elif variant == "namespace_patch": + injection = "from shim import decorate\nfw.function_tool = decorate\n@fw.function_tool\ndef carrier():\n pass\n" + else: + after["bridge.py"] = "import agents\nfrom shim import decorate\nagents.function_tool = decorate\n" + injection = "import bridge\n@fw.function_tool\ndef carrier():\n pass\n" + after["app.py"] = after["app.py"].replace("a = ", injection + "a = ") + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] != "compared" + assert all(row["change"] == "not_established" for row in result["rows"]) + + + +@pytest.mark.parametrize("decorator", ["function_tool", "function_tool(name_override='read')"]) +def test_the_unchanged_sdk_decorator_keeps_local_registration_complete(tmp_path, decorator): + from tests.test_imported_tool_bindings import _write + _write(tmp_path, {"app.py": "from agents import Agent, function_tool\n@" + decorator + + "\ndef read():\n return 1\na = Agent(name='Built', tools=[read])\n"}) + loaded = _sdk(tmp_path, "app.py") + assert not loaded.warnings and loaded.binding_observations + assert all(item.tools_complete for item in loaded.binding_observations) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("escape", ["consume(a.model_fields['tools'])", "consume(a.model_fields.copy())", + "consume(a.model_fields or {})", "consume(a.unknown[0])"]) +def test_indexed_and_copied_agent_metadata_is_not_a_scalar_read(tmp_path, framework, escape): + before = _files(framework, "direct", "clean", factory=False) + after = _files(framework, "direct", "clean", selected="[read, write]", factory=False) + after["app.py"] += escape + "\n" + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] != "compared" + assert all(row["change"] == "not_established" for row in result["rows"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +def test_literal_default_on_a_fresh_factory_projection_keeps_namespace_ownership(tmp_path, framework): + from tests.test_imported_tool_bindings import _write + namespace = "agents" if framework == "sdk" else "google.adk.agents" + decorator = "from agents import function_tool\n@function_tool\n" if framework == "sdk" else "" + _write(tmp_path, {"app.py": f"from {namespace} import Agent\n" + decorator + + "def read():\n return 1\ndef make_tools():\n return {'selected': []}\n" + + "a = Agent(name='Built', tools=make_tools().get('missing', [read]))\n"}) + if framework == "sdk": + loaded = _sdk(tmp_path, "app.py") + observations, warnings = loaded.binding_observations, loaded.warnings + else: + loaded, artifacts = _adk(tmp_path, "app.py") + observations = [item for source in loaded for item in source.binding_observations] + warnings = artifacts.warnings + assert not warnings and observations and all(item.tools_complete for item in observations) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("header", ["@hook\nclass Carrier:", "class Carrier(metaclass=hook):", "class Carrier(hook):"]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_class_construction_callbacks_cannot_borrow_a_clean_constructor(tmp_path, framework, header, change): + before = _files(framework, "direct", "clean", factory=False) + after = _files(framework, "direct", "clean", selected="[read, write]" if change == "added" else "[]" if change == "removed" else "[read]", + body="return 2" if change == "changed" else "return 1", factory=False) + injection = "from external_package import hook\n" + header + "\n def helper(self):\n return 0\n" + after["app.py"] = after["app.py"].replace("a = ", injection + "a = ") + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] != "compared" + assert all(row["change"] == "not_established" for row in result["rows"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("header", ["class Carrier:", "class Carrier(object):"]) +def test_plain_unescaped_classes_do_not_create_implicit_callbacks(tmp_path, framework, header): + from tests.test_imported_tool_bindings import _write + files = _files(framework, "direct", "clean", factory=False) + files["app.py"] = files["app.py"].replace("a = ", header + "\n def helper(self):\n return 0\na = ") + _write(tmp_path, files) + if framework == "sdk": + loaded = _sdk(tmp_path, "app.py") + observations, warnings = loaded.binding_observations, loaded.warnings + else: + loaded, artifacts = _adk(tmp_path, "app.py") + observations = [item for source in loaded for item in source.binding_observations] + warnings = artifacts.warnings + assert not warnings and observations and all(item.tools_complete for item in observations) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("value", ["descriptor", "Descriptor()"]) +@pytest.mark.parametrize("change", ["added", "removed"]) +def test_implicit_descriptor_callbacks_cannot_escape_the_constructor_namespace(tmp_path, framework, value, change): + before = _files(framework, "direct", "clean", factory=False) + after = _files(framework, "direct", "clean", selected="[read, write]" if change == "added" else "[]", factory=False) + injection = ("from external_package import descriptor, Descriptor\nclass Carrier:\n" + + f" hook = {value}\n def helper(self):\n return 0\n") + after["app.py"] = after["app.py"].replace("a = ", injection + "a = ") + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] != "compared" + assert all(row["change"] == "not_established" for row in result["rows"]) + + + +@pytest.mark.parametrize("carrier", [ + "@hook\ndef helper():\n return 0\n", + "@hook\nclass Carrier:\n def helper(self):\n return 0\n", + "class Carrier(metaclass=hook):\n def helper(self):\n return 0\n", + "class Carrier(hook):\n def helper(self):\n return 0\n", + "class Carrier:\n descriptor = hook\n def helper(self):\n return 0\n", +]) +@pytest.mark.parametrize("change", ["added", "changed", "removed"]) +def test_sdk_decorator_only_modules_are_in_the_namespace_census(tmp_path, carrier, change): + before = _files("sdk", "direct", "clean", factory=False) + after = _files("sdk", "direct", "clean", selected="[read, write]" if change == "added" else "[]" if change == "removed" else "[read]", + body="return 2" if change == "changed" else "return 1", factory=False) + after["tools.py"] = after["tools.py"].replace("@function_tool\n", "from external_package import hook\n" + carrier + "@function_tool\n", 1) + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] != "compared" + assert all(row["change"] == "not_established" for row in result["rows"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("repeat", ["identical", "namespace", "conditional", "different", "rebound", "patched", "opaque", "try"]) +def test_repeated_imports_require_one_unmodified_lexical_framework_identity(tmp_path, framework, repeat): + from tests.test_imported_tool_bindings import _write + namespace = "agents" if framework == "sdk" else "google.adk.agents" + files = _files(framework, "direct", "clean", factory=False) + insertion = { + "identical": f"from {namespace} import Agent\n", + "namespace": f"import {namespace} as fw\n", + "conditional": f"if flag:\n import {namespace} as fw\n", + "different": ("from openai_agents import Agent\n" if framework == "sdk" else "from google.adk.agents import LlmAgent as Agent\n"), + "rebound": "fw = replacement\n", + "patched": f"import {namespace} as fw\nfw.Agent.__init__ = replacement\n", + "opaque": f"import {namespace} as fw\nreplacement(fw)\n", + "try": f"try:\n import {namespace} as fw\nexcept ImportError:\n pass\n", + }[repeat] + files["app.py"] = files["app.py"].replace("a = ", insertion + "a = ") + if repeat == "different": + files["app.py"] = files["app.py"].replace("fw.Agent(name=", "Agent(name=") + _write(tmp_path, files) + if framework == "sdk": + loaded = _sdk(tmp_path, "app.py") + observations, warnings = loaded.binding_observations, loaded.warnings + else: + loaded, artifacts = _adk(tmp_path, "app.py") + observations = [item for source in loaded for item in source.binding_observations] + warnings = artifacts.warnings + complete = repeat in {"identical", "namespace"} + assert observations and all(item.tools_complete == complete for item in observations) + assert bool(warnings) == (not complete) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("keyword_only", [False, True]) +@pytest.mark.parametrize("escape", ["", "consume(tools)", "tools.append(write)", "tools = []\n consume(tools)"]) +def test_shared_defaults_need_every_lexical_parameter_use_to_retain_ownership(tmp_path, framework, keyword_only, escape): + from tests.test_builder_bindings import _files as builder_files + from tests.test_builder_bindings import _read + from tests.test_imported_tool_bindings import _write + files = builder_files(framework, "a = build()\nb = build()\n") + files["builders.py"] = files["builders.py"].replace("def build(tools, model=None, handoffs=None):", + "from tools import read, write\ndef build(" + ("*, " if keyword_only else "") + "tools=[read], model=None, handoffs=None):") + if escape: + files["builders.py"] = files["builders.py"].replace(" return Agent", " " + escape + "\n return Agent") + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert observations and all(item.tools_complete == (not escape) for item in observations) + assert bool(warnings) == bool(escape) + + + +@pytest.mark.parametrize("escape", ["metadata = build.__defaults__", "other = build", "consume(a)"]) +def test_sdk_shared_default_metadata_and_result_escapes_remain_unread(tmp_path, escape): + from tests.test_builder_bindings import _files as builder_files + from tests.test_builder_bindings import _read + from tests.test_imported_tool_bindings import _write + files = builder_files("sdk", "a = build()\n" + escape + "\n") + files["builders.py"] = files["builders.py"].replace("def build(tools,", "from tools import read\ndef build(tools=[read],") + _write(tmp_path, files) + observations, warnings = _read(tmp_path, "sdk") + assert warnings and observations and all(not item.tools_complete for item in observations) + + + +def test_an_sdk_decorated_callback_does_not_prove_ownership_of_its_default(tmp_path): + from tests.test_imported_tool_bindings import _write + files = _files("sdk", "direct", "clean", factory=False) + files["app.py"] = files["app.py"].replace("a = ", "from agents import function_tool\n@function_tool\ndef callback(tools=[read]):\n return 0\na = ") + _write(tmp_path, files) + observations = _sdk(tmp_path, "app.py").binding_observations + assert observations and all(not item.tools_complete for item in observations) + + + +@pytest.mark.parametrize("mutation, complete", [ + ("a.tools.append(write)", True), ("a.tools.clear()", True), ("a.tools.reverse()", True), + ("a.tools.insert(0, write)", True), ("a.tools.extend([write])", True), ("a.tools = [write]", True), + ("a.tools = replacement()\na.tools.append(write)", False), + ("a.tools.append(write)\nconsume(a)", False), ("consume(a.tools[0])", False), + ("consume(a.tools.pop())", False), ("a.tools.sort()", False), + ("alias = a.tools\nconsume(alias)", False), ("result = a.tools.append(write)", False), + ("a.tools.insert(runtime_position(), write)", False), +]) +def test_sdk_list_membership_edits_preserve_only_owned_tool_objects(tmp_path, mutation, complete): + from tests.test_imported_tool_bindings import _write + files = _files("sdk", "direct", "clean", factory=False) + files["app.py"] += mutation + "\nb = Agent(name='Other', tools=[read])\n" + _write(tmp_path, files) + observations = _sdk(tmp_path, "app.py").binding_observations + peer = next(item for item in observations if item.agent == "b") + assert peer.tools_complete is complete + + + +@pytest.mark.parametrize("imported", ["from registry import SHARED", "from bridge import SHARED", "import registry as R"]) +@pytest.mark.parametrize("escape", ["", "consume(SHARED[0])", "alias = SHARED\nconsume(alias)"]) +def test_sdk_imported_literal_member_identity_follows_exact_borrowers(tmp_path, imported, escape): + from tests.test_imported_tool_bindings import _write + files = _files("sdk", "direct", "clean", factory=False) + files["registry.py"] = "from tools import read\nSHARED = [read]\n" + files["bridge.py"] = "from registry import SHARED\n" + holder = "R.SHARED" if imported.startswith("import ") else "SHARED" + files["app.py"] = files["app.py"].replace("a = ", imported + "\na = ").replace("tools=[read]", "tools=" + holder) + files["consumer.py"] = imported + "\n" + escape.replace("SHARED", holder) + "\n" + _write(tmp_path, files) + observations = _sdk(tmp_path, "app.py").binding_observations + assert observations and all(item.tools_complete == (not escape) for item in observations) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("escape", [False, True]) +@pytest.mark.parametrize("change", ["added", "removed"]) +def test_imported_callable_ownership_keeps_both_builder_caller_orders(tmp_path, framework, reverse, escape, change): + namespace = "agents" if framework == "sdk" else "google.adk.agents" + files = _files(framework, "direct", "clean", factory=False) + if framework == "adk": + files["tools.py"] = f"from {namespace} import Agent\n" + files["tools.py"] + files["tools.py"] += "\nSHARED = [read]\n" + files["builders.py"] = f"from {namespace} import Agent\ndef build(tools):\n return Agent(name='Built', tools=tools)\n" + + def caller(with_write, escaped=False): + calls = ["a = build([*SHARED])", "b = build([*SHARED" + (", write" if with_write else "") + "])"] + if reverse: + calls.reverse() + return (f"from {namespace} import Agent\nfrom tools import SHARED, read, write\n" + + "from builders import build\nfrom external import consume\n" + "\n".join(calls) + + ("\nconsume(b)" if escaped else "") + "\n") + + _git(tmp_path, "init", "-q", "-b", "main") + files["app.py"] = caller(change == "removed") + base = _commit(tmp_path, files) + head = _commit(tmp_path, {"app.py": caller(change == "added", escape)}) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["rows"] + if escape: + assert all(row["change"] == "not_established" for row in result["rows"]) + assert any("constructor identity is not established" in gap["reason"] + for gap in result["head"]["coverage_gaps"]) + else: + assert any(row["agent"] == "Built" and row["tool"] == "write" and row["change"] == change + and (row["after"] or row["before"])["agent_source"] == "builders.py" for row in result["rows"]) + assert not any("constructor identity is not established" in gap["reason"] + for stage in ("base", "head") for gap in result[stage]["coverage_gaps"]) + + + +@pytest.mark.parametrize("field", [ + "before_agent_callback", "after_agent_callback", "before_model_callback", "after_model_callback", + "before_tool_callback", "after_tool_callback", +]) +@pytest.mark.parametrize("escape", ["", "consume(a)", "consume(a.before_tool_callback)"]) +def test_adk_named_callbacks_retain_only_the_actual_owned_agent(tmp_path, field, escape): + from tests.test_imported_tool_bindings import _write + files = _files("adk", "direct", "clean", factory=False) + files["app.py"] = files["app.py"].replace("a = ", "def guard(*args, **kwargs):\n return None\na = ") + files["app.py"] = files["app.py"].replace("tools=[read]", f"tools=[read], {field}=guard") + files["app.py"] += escape.replace("before_tool_callback", field) + "\n" + _write(tmp_path, files) + loaded, _ = _adk(tmp_path, "app.py") + observations = [item for source in loaded for item in source.binding_observations] + assert observations and all(item.tools_complete == (not escape) for item in observations) + + + +@pytest.mark.parametrize("escape", ["", "consume(read)", "read()", "consume(read.is_enabled)", "alias = read", "self_return"]) +@pytest.mark.parametrize("reexport", [False, True]) +def test_sdk_enabled_callback_needs_ownership_of_the_decorated_tool(tmp_path, escape, reexport): + from tests.test_imported_tool_bindings import _write + files = _files("sdk", "direct", "clean", factory=False) + files["tools.py"] = files["tools.py"].replace("@function_tool\n", "def guard(ctx, agent):\n return True\n@function_tool(is_enabled=guard)\n", 1) + if escape == "self_return": + files["tools.py"] = files["tools.py"].replace("return 1", "return read", 1) + else: + files["consumer.py"] = ("from bridge import read\n" if reexport else "from tools import read\n") + escape + "\n" + files["bridge.py"] = "from tools import read\n" + if reexport: + files["app.py"] = files["app.py"].replace("from tools import read, write", "from bridge import read\nfrom tools import write") + _write(tmp_path, files) + observations = _sdk(tmp_path, "app.py").binding_observations + assert observations and all(item.tools_complete == (not escape) for item in observations) + + + +@pytest.mark.parametrize("kind", ["clone", "dataclasses.replace", "copy.replace"]) +@pytest.mark.parametrize("use, complete", [ + ("", True), ("copied is None", True), + ("saved = copied", False), ("consume(copied)", False), + ("consume(copied.__class__)", False), ("copied.__class__ = replacement", False), + ("consume(copied.tools)", False), ("consume(copied.tools[0])", False), +]) +def test_sdk_copy_receiver_retains_its_constructor_only_through_an_owned_result( + tmp_path, kind, use, complete, +): + from tests.test_imported_tool_bindings import _write + files = _files("sdk", "direct", "clean", factory=False) + if kind == "clone": + construction = "a.clone(tools=[read])" + else: + package = kind.partition(".")[0] + files["app.py"] += f"from {package} import replace\n" + construction = "replace(a, tools=[read])" + files["app.py"] += f"copied = {construction}\n{use}\nb = Agent(name='Other', tools=[read])\n" + _write(tmp_path, files) + observations = _sdk(tmp_path, "app.py").binding_observations + peer = next(item for item in observations if item.agent == "b") + assert peer.tools_complete is complete + assert peer.tool_names == ["read"] + + +@pytest.mark.parametrize("constructor_root", ["agents", "openai_agents"]) +@pytest.mark.parametrize("tool_root", ["agents", "openai_agents"]) +@pytest.mark.parametrize("patch", [False, True]) +def test_existing_sdk_import_spellings_share_only_the_owned_constructor_role( + tmp_path, constructor_root, tool_root, patch, +): + from tests.test_imported_tool_bindings import _write + files = _files("sdk", "direct", "clean", factory=False) + files["app.py"] = files["app.py"].replace("from agents import Agent", f"from {constructor_root} import Agent") + files["tools.py"] = files["tools.py"].replace("from agents import function_tool", f"from {tool_root} import function_tool") + if patch: + other = "openai_agents" if constructor_root == "agents" else "agents" + files["app.py"] += f"from {other} import Agent as Other\nOther.__init__ = replacement\n" + _write(tmp_path, files) + observations = _sdk(tmp_path, "app.py").binding_observations + assert observations and all(item.tools_complete is not patch for item in observations) + assert all(item.tool_names == ["read"] for item in observations) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("use, complete", [ + ("", True), ("other = alias\nother.tools = [read]", True), + ("consume(alias)", False), ("alias.__class__ = replacement", False), + ("consume(alias.tools)", False), ("alias.tools = opaque()", False), + ("def capture():\n alias.tools = [read]", False), +]) +def test_direct_instance_alias_field_stores_do_not_export_the_constructor( + tmp_path, framework, use, complete, +): + from tests.test_imported_tool_bindings import _adk, _write + files = _files(framework, "direct", "clean", factory=False) + files["app.py"] += f"alias = a\nalias.tools = [read]\n{use}\nb = Agent(name='Other', tools=[read])\n" + _write(tmp_path, files) + if framework == "sdk": + observations = _sdk(tmp_path, "app.py").binding_observations + else: + loaded, _ = _adk(tmp_path, "app.py") + observations = [item for source in loaded for item in source.binding_observations] + peer = next(item for item in observations if item.agent in {"b", "Other"}) + assert peer.tools_complete is complete + assert peer.tool_names == ["read"] + + +def test_archived_constructor_census_does_not_bind_to_the_live_head_snapshot(tmp_path): + from agents_shipgate.core.static_inputs import ( + StaticInputSnapshot, + activate_static_input_snapshot, + reset_static_input_snapshot, + ) + from tests.test_imported_tool_bindings import _write + head, base = tmp_path / "head", tmp_path / "base" + head.mkdir() + files = _files("sdk", "direct", "clean", factory=False) + files["refund_agent/agent.py"] = files.pop("app.py") + _write(base, files) + snapshot = StaticInputSnapshot(head) + token = activate_static_input_snapshot(snapshot) + try: + loaded = _sdk(base, "refund_agent/agent.py") + assert loaded.warnings == [] + assert loaded.binding_observations and all(item.tools_complete for item in loaded.binding_observations) + assert snapshot.dependency_paths() == [] + snapshot.finish() + finally: + reset_static_input_snapshot(token) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +def test_capturing_a_global_instance_into_a_local_store_alias_is_unread(tmp_path, framework): + from tests.test_imported_tool_bindings import _adk, _write + files = _files(framework, "direct", "clean", factory=False) + files["app.py"] += ( + "def helper():\n alias = a\n alias.tools = [read]\n" + "helper()\nb = Agent(name='Other', tools=[read])\n" + ) + _write(tmp_path, files) + if framework == "sdk": + observations = _sdk(tmp_path, "app.py").binding_observations + else: + loaded, _ = _adk(tmp_path, "app.py") + observations = [item for source in loaded for item in source.binding_observations] + peer = next(item for item in observations if item.agent in {"b", "Other"}) + assert not peer.tools_complete and peer.tool_names == ["read"] + + + +@pytest.mark.parametrize("carrier", [ + "def helper():\n return 0\nconsume(helper)\n", + "class Carrier:\n def helper(self):\n return 0\nconsume(Carrier)\n", + "from external_package import hook\n@hook\ndef helper():\n return 0\n", + "from external_package import hook\nclass Carrier(metaclass=hook):\n def helper(self):\n return 0\n", + "def helper():\n return 0\n", + "import patcher\n", +]) +def test_reverse_borrower_namespaces_cannot_establish_a_shared_tool_removal(tmp_path, carrier): + files = _files("sdk", "direct", "clean", factory=False) + files["registry.py"] = "from tools import read\nSHARED = [read]\n" + files["app.py"] = files["app.py"].replace("a = ", "import registry as R\na = ").replace("tools=[read]", "tools=R.SHARED") + files["escape.py"] = "import registry as R\n" + carrier + files["patcher.py"] = "from agents import Agent\nAgent.__init__ = replacement\n" + if carrier.endswith("return 0\n"): + files["bridge.py"] = "from escape import helper\n" + files["consumer.py"] = "from bridge import helper\nconsume(helper)\n" + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, files) + head = _commit(tmp_path, {"registry.py": "from tools import read\nSHARED = []\n"}) + result = _compare(tmp_path, base, head, "--scope", ".") + rows = [row for row in result["rows"] if row["tool"] == "read"] + assert result["comparison_status"] != "compared" + assert all(row["change"] == "not_established" for row in rows) + assert any("constructor identity is not established" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("data", [ + "stack = {'count': 1}\nprint(stack)\n", + "def describe(stack):\n print(stack)\ndescribe({})\n", + "def describe(stack=None):\n print(stack)\ndescribe()\n", + "def describe():\n stack = {}\n print(stack)\ndescribe()\n", + "stack = {}\nstack = {'count': 2}\nprint(stack)\n", + "if enabled:\n stack = {}\nelse:\n stack = []\nprint(stack)\n", + "stack = {}\ndef describe():\n global stack\n print(stack)\ndescribe()\n", + "import traceback\ndef describe():\n vars = traceback.extract_stack()[-2][-3]\n print(vars)\ndescribe()\n", +]) +def test_lexical_data_names_do_not_become_frame_inspection(tmp_path, framework, data): + _git(tmp_path, "init", "-q", "-b", "main") + files = _files(framework, "direct", "clean", factory=False) + files["app.py"] = data + files["app.py"] + base = _commit(tmp_path, files) + files["tools.py"] = files["tools.py"].replace("return 1", "return 2") + head = _commit(tmp_path, files) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] + assert [(row["agent"], row["tool"], row["change"]) for row in result["rows"]] == [ + ("a" if framework == "sdk" else "Built", "read", "changed") + ] + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("inspection", [ + "import inspect\nfrom external import consume\nconsume(inspect)\n", + "import inspect as inspector\nfrom external import consume\nconsume(inspector)\n", + "import inspect\nstack = inspect.stack\nstack()\n", + "from inspect import stack as capture\ncapture()\n", + "import inspect\ninspect.stack()\n", + "saved = vars\nvars = {}\nsaved()\n", +]) +def test_frame_inspectors_and_saved_builtin_names_stay_unread(tmp_path, framework, inspection): + _git(tmp_path, "init", "-q", "-b", "main") + files = _files(framework, "direct", "clean", factory=False) + files["app.py"] = inspection + files["app.py"] + base = _commit(tmp_path, files) + files["tools.py"] = files["tools.py"].replace("return 1", "return 2") + head = _commit(tmp_path, files) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["rows"] and all(row["change"] == "not_established" for row in result["rows"]) + assert any("constructor identity is not established" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("use", ["copied = TOOLS.copy()\nconsume(copied)\n", + "copied = TOOLS.copy()\nconsume(copied[0])\n", + "print = consume\nprint(*TOOLS)\n", + "consume(*TOOLS)\n"]) +def test_copy_and_starred_reads_must_prove_every_receiver_and_result(tmp_path, framework, use): + _git(tmp_path, "init", "-q", "-b", "main") + files = _files(framework, "direct", "clean", factory=False) + files["app.py"] = files["app.py"].replace("a = ", "from external import consume\nTOOLS = [read]\n" + use + "a = ") + if framework == "adk": + files["tools.py"] = "from google.adk.agents import Agent\n" + files["tools.py"] + base = _commit(tmp_path, files) + files["tools.py"] = files["tools.py"].replace("return 1", "return 2") + head = _commit(tmp_path, files) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["rows"] and all(row["change"] == "not_established" for row in result["rows"]) + assert any("constructor identity is not established" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + + + +@pytest.mark.parametrize("family", ["agents", "openai_agents"]) +@pytest.mark.parametrize("aliased", [False, True]) +def test_external_sdk_root_does_not_borrow_namespace_only_application_folder(tmp_path, family, aliased): + _git(tmp_path, "init", "-q", "-b", "main") + files = _files("sdk", "direct", "clean", factory=False) + files = {name: text.replace("from agents import", f"from {family} import") + .replace("import agents as fw", f"import {family} as fw") + for name, text in files.items()} + files[f"{family}/app.py"] = files.pop("app.py") + files[f"{family}/independent.py"] = f"import {family}" + (" as sdk" if aliased else "") + "\n" + base = _commit(tmp_path, files) + files["tools.py"] = files["tools.py"].replace("return 1", "return 2") + head = _commit(tmp_path, files) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] + assert [(row["agent"], row["tool"], row["change"]) for row in result["rows"]] == [("a", "read", "changed")] + + + +@pytest.mark.parametrize("aliased", [False, True]) +def test_supported_sdk_spellings_keep_each_imports_own_ownership(tmp_path, aliased): + _git(tmp_path, "init", "-q", "-b", "main") + files = _files("sdk", "direct", "clean", factory=False) + # Keep the original mixed fixture: fw.Agent is agents.Agent, while the + # tool decorators come from the separately retained SDK namespace. + files = {name: text.replace("from agents import", "from openai_agents import") + for name, text in files.items()} + files["openai_agents/app.py"] = files.pop("app.py") + files["openai_agents/independent.py"] = "import openai_agents" + (" as sdk" if aliased else "") + "\n" + base = _commit(tmp_path, files) + files["tools.py"] = files["tools.py"].replace("return 1", "return 2") + head = _commit(tmp_path, files) + result = _compare(tmp_path, base, head, "--scope", ".") + # Both SDK spellings are explicitly modeled; each actual import still + # receives its own provider and mutation checks. An unused namespace + # import does not manufacture an opaque decorator invocation. + assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] + assert [(row["agent"], row["tool"], row["change"]) for row in result["rows"]] == [ + ("a", "read", "changed"), + ] + + + +@pytest.mark.parametrize("borrower", [ + "from agent import score\nconsume(score)\n", + "from agent import score as wrapped\nconsume(wrapped)\n", + "from agent import score\nscore.func = other\n", + "from agent import score\nconsume(score.func.__globals__)\n", + "import bridge as library\nconsume(library.score)\n", + "from agent import score\n", # Imported handles remain a bounded limit even when unused. +]) +def test_self_wrapped_callable_imports_cannot_clear_actual_retained_handle(tmp_path, borrower): + _git(tmp_path, "init", "-q", "-b", "main") + source = ("from google.adk.agents import Agent\nfrom google.adk.tools import FunctionTool\n" + "async def score(q: str) -> str:\n return q\n" + "score = FunctionTool(func=score)\n" + "root_agent = Agent(name='app', tools=[score])\n") + files = {"agent.py": source, "consumer.py": borrower} + if "bridge" in borrower: + files["bridge.py"] = "from agent import score\n" + base = _commit(tmp_path, files) + head = _commit(tmp_path, {"agent.py": source.replace("return q\n", "return q.upper()\n")}) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "partial" + assert [(row["agent"], row["tool"], row["change"], row["candidate_change"]) + for row in result["rows"]] == [("app", "score", "not_established", "changed")] + assert result["rows"][0]["after"]["definition"]["source"] == "agent.py" + assert result["rows"][0]["after"]["definition"]["line"] == 3 + assert any("constructor identity is not established" in gap["reason"] + for gap in result["head"]["coverage_gaps"]) + assert result == _compare(tmp_path, base, head, "--scope", ".") + + + +def test_importing_a_different_modules_same_named_callable_does_not_export_wrapper(tmp_path): + _git(tmp_path, "init", "-q", "-b", "main") + source = ("from google.adk.agents import Agent\nfrom google.adk.tools import FunctionTool\n" + "async def score(q: str) -> str:\n return q\n" + "score = FunctionTool(func=score)\n" + "root_agent = Agent(name='app', tools=[score])\n") + base = _commit(tmp_path, {"agent.py": source, "elsewhere.py": "def score():\n return 0\n", + "consumer.py": "from elsewhere import score\nconsume(score)\n"}) + head = _commit(tmp_path, {"agent.py": source.replace("return q\n", "return q.upper()\n")}) + result = _compare(tmp_path, base, head, "--scope", ".") + assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] + assert [(row["agent"], row["tool"], row["change"]) for row in result["rows"]] == [("app", "score", "changed")] diff --git a/tests/test_framework_matrix.py b/tests/test_framework_matrix.py new file mode 100644 index 000000000..607561506 --- /dev/null +++ b/tests/test_framework_matrix.py @@ -0,0 +1,122 @@ +"""The per-PR run keeps every route on both readers, and the rest runs nightly (#964).""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +from tests.framework_matrix import ( + THINNED_FILES, + THINNED_VALUES, + covering_subset, + mark_nightly_framework_cases, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +class _Item: + """The parts of a collected parametrized test the policy reads.""" + + def __init__(self, function: str, params: dict, *, path: str = "tests/test_builder_bindings.py"): + self.callspec = SimpleNamespace(params=params) + self.originalname = function + self.name = f"{function}[{'-'.join(str(value) for value in params.values())}]" + self.path = REPO_ROOT / path + self.config = SimpleNamespace(rootpath=REPO_ROOT) + self.marks: list[str] = [] + + def add_marker(self, mark) -> None: # noqa: ANN001 + self.marks.append(mark.name) + + +def _matrix(function="test_x", routes=("a", "b", "c"), changes=("added", "removed"), **kwargs): + return [ + _Item(function, {"framework": framework, "route": route, "change": change}, **kwargs) + for framework in ("sdk", "adk") + for route in routes + for change in changes + ] + + +def test_the_sdk_keeps_a_covering_subset_and_adk_stays_whole() -> None: + items = _matrix() + marked = mark_nightly_framework_cases(items) + sdk = [item for item in items if item.callspec.params["framework"] == "sdk"] + adk = [item for item in items if item.callspec.params["framework"] == "adk"] + assert all(not item.marks for item in adk) + kept = [item for item in sdk if not item.marks] + assert marked == len(sdk) - len(kept) and marked > 0 + # Every route and every change value is still met on the SDK reader. + assert {item.callspec.params["route"] for item in kept} == {"a", "b", "c"} + assert {item.callspec.params["change"] for item in kept} == {"added", "removed"} + assert len(kept) == 3 < len(sdk) + assert all(item.marks in ([], ["slow"]) for item in sdk) + + +def test_a_function_with_one_case_per_value_loses_none() -> None: + items = [_Item("test_y", {"framework": "sdk", "patch": patch}) for patch in ("one", "two", "three")] + assert mark_nightly_framework_cases(items) == 0 + + +def test_both_import_spellings_of_the_sdk_stay_distinct_routes() -> None: + items = [ + _Item("test_z", {"family": family, "route": route}, path="tests/test_builder_calls.py") + for family in ("agents", "openai_agents", "google.adk") + for route in ("x", "y") + ] + mark_nightly_framework_cases(items) + kept = {item.callspec.params["family"] for item in items if not item.marks} + assert {"agents", "openai_agents", "google.adk"} == kept + assert all(not item.marks for item in items if item.callspec.params["family"] == "google.adk") + + +def test_only_the_named_files_and_framework_arguments_are_thinned() -> None: + other_file = _matrix(path="tests/test_scan.py") + no_framework = [_Item("test_w", {"route": value}) for value in "abcd"] + neither_value = _matrix(routes=("a",)) + for item in neither_value: + item.callspec.params["framework"] = "claude" if item.callspec.params["framework"] == "sdk" else "other" + assert mark_nightly_framework_cases([*other_file, *no_framework, *neither_value]) == 0 + + +def test_the_choice_is_deterministic_and_greedy() -> None: + first = covering_subset(_matrix(routes=("a", "b", "c", "d"), changes=("added", "changed", "removed"))[::2]) + again = covering_subset(_matrix(routes=("a", "b", "c", "d"), changes=("added", "changed", "removed"))[::2]) + assert [item.name for item in first] == [item.name for item in again] + + +def test_every_thinned_file_exists_and_the_values_are_the_sdk_spellings() -> None: + assert all((REPO_ROOT / path).is_file() for path in THINNED_FILES) + assert THINNED_VALUES == {"sdk", "agents", "openai_agents"} + + +def _collect(*args: str) -> list[str]: + env = dict(os.environ) + env.pop("SHIPGATE_TEST_SHARDS", None) + env.pop("SHIPGATE_TEST_SHARD", None) + result = subprocess.run( + [sys.executable, "-m", "pytest", "--collect-only", "-q", "-o", "addopts=", *args], + cwd=REPO_ROOT, capture_output=True, text=True, env=env, check=False, + ) + assert result.returncode == 0, result.stdout[-3000:] + result.stderr[-2000:] + return [line for line in result.stdout.splitlines() if re.match(r"tests/\S+::", line)] + + +def test_the_real_collection_moves_cases_to_slow_and_deletes_none() -> None: + path = "tests/test_framework_constructor_imports.py" + everything = _collect(path) + slow = _collect(path, "-m", "slow") + default = _collect(path, "-m", "not slow") + assert everything and slow and default + assert sorted([*slow, *default]) == sorted(everything) + assert all(case.endswith("-sdk]") for case in slow) + assert sum(case.endswith("-adk]") for case in default) == sum(case.endswith("-adk]") for case in everything) + # Every route is still met on the SDK reader in the default run. + routes = {re.search(r"\[(\w+)-", case).group(1) for case in everything if case.endswith("-sdk]")} + kept = {re.search(r"\[(\w+)-", case).group(1) for case in default if case.endswith("-sdk]")} + assert kept == routes diff --git a/tests/test_google_adk.py b/tests/test_google_adk.py index d66c63a77..c5465c778 100644 --- a/tests/test_google_adk.py +++ b/tests/test_google_adk.py @@ -1821,6 +1821,31 @@ def _scan_proven(tmp_path, project): return report +def _assert_named_surface_limit(report, project, reason, *, tool_names=None): + """Keep the extractor diagnostic and require the current release boundary. + + Constructor uncertainty now costs every candidate its binding evidence, + before interface confidence is considered by the release gate. + """ + artifacts = GoogleAdkArtifacts() + loaded = _load_python_path(project / "agent.py", project, "adk_agent", "agent.py", artifacts) + tools = [tool for source in loaded for tool in source.tools] + affected = [tool for tool in tools if tool_names is None or tool.name in tool_names] + assert affected and all(reason in tool.extraction.get("surface_gaps", []) for tool in affected) + if tool_names is not None: + assert {tool.name for tool in affected} == tool_names + gaps = report.release_decision.evidence_coverage.evidence_gaps + assert report.release_decision.decision != "passed" + if any("constructor identity is not established" in warning for warning in report.source_warnings): + assert any(gap.kind == "partial_binding_evidence" and "constructor identity" in gap.why for gap in gaps) + assert report.tool_catalog + assert all(tool["binding_assessment"]["status"] == "unknown" + and tool["binding_assessment"]["pass_eligible"] is False for tool in report.tool_catalog) + else: + confidence_gaps = [gap for gap in gaps if gap.kind == "low_confidence_tool"] + assert confidence_gaps and all(reason in gap.why for gap in confidence_gaps) + + def test_a_fully_static_adk_module_needs_no_inventory_to_reach_high_confidence( tmp_path, ): @@ -1983,18 +2008,7 @@ def test_one_unproven_construct_holds_the_whole_module_at_medium( catalog = {tool["name"]: tool for tool in report.tool_catalog} assert catalog["lookup_account"]["confidence"] == "medium" assert catalog["create_quote"]["confidence"] == "medium" - gaps = [ - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ] - assert {gap.subject.split(" ")[0] for gap in gaps} == { - "lookup_account", - "create_quote", - } - # The row has to name the construct responsible: one sentence repeated on - # every AST tool in every repository is the defect #393 reports. - assert all(reason in gap.why for gap in gaps) + _assert_named_surface_limit(report, project, reason) def test_a_dynamic_tools_expression_holds_the_module_at_medium(tmp_path): @@ -2053,7 +2067,8 @@ def test_a_function_whose_own_interface_is_unreadable_stays_medium( `**kwargs` is dropped altogether, and a decorator replaces the callable ADK introspects — each would put a guess into the report wearing a schema's clothes. Unlike the module-scoped reasons, this one is about one callable, - so its siblings keep their proof. + so its siblings keep their interface evidence. An unread decorator can + independently invalidate the module's constructor ownership evidence. """ decorator = ( @@ -2076,7 +2091,11 @@ def test_a_function_whose_own_interface_is_unreadable_stays_medium( catalog = {tool["name"]: tool for tool in report.tool_catalog} assert catalog["loose_tool"]["confidence"] == "medium" - assert catalog["lookup_account"]["confidence"] == "high" + assert catalog["lookup_account"]["confidence"] == ("medium" if reason == "decorated_tool_function" else "high") + if reason == "decorated_tool_function": + _assert_named_surface_limit(report, project, reason, tool_names={"loose_tool"}) + assert any("unread decorator" in warning for warning in report.source_warnings) + return gap = next( gap for gap in report.release_decision.evidence_coverage.evidence_gaps @@ -2239,12 +2258,7 @@ def test_a_definition_the_name_may_not_refer_to_is_never_proven( report = _scan_proven(tmp_path, project) assert {tool["confidence"] for tool in report.tool_catalog} == {"medium"} - gap = next( - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ) - assert "shadowed_tool_definition" in gap.why + _assert_named_surface_limit(report, project, "shadowed_tool_definition") def test_the_conventional_functiontool_wrapper_variable_is_still_proven(tmp_path): @@ -2360,12 +2374,7 @@ def test_an_inline_wrapper_this_module_cannot_resolve_is_recorded(tmp_path, wrap assert report.release_decision.decision != "passed" assert {tool["confidence"] for tool in report.tool_catalog} == {"medium"} - gap = next( - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ) - assert "unresolved_tool_wrapper" in gap.why + _assert_named_surface_limit(report, project, "unresolved_tool_wrapper") @pytest.mark.parametrize( @@ -2416,12 +2425,7 @@ def test_every_python_binding_form_costs_a_name_its_proof(tmp_path, shadow: str) report = _scan_proven(tmp_path, project) assert report.release_decision.decision != "passed" - gaps = [ - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ] - assert gaps and all("shadowed_tool_definition" in gap.why for gap in gaps) + _assert_named_surface_limit(report, project, "shadowed_tool_definition") def test_a_wrapper_variable_rebound_after_assignment_is_not_proven(tmp_path): @@ -2446,12 +2450,8 @@ def test_a_wrapper_variable_rebound_after_assignment_is_not_proven(tmp_path): report = _scan_proven(tmp_path, project) assert report.release_decision.decision != "passed" - gaps = [ - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ] - assert gaps and all("shadowed_tool_definition" in gap.why for gap in gaps) + _assert_named_surface_limit(report, project, "shadowed_tool_definition") + assert any("constructor identity is not established" in warning for warning in report.source_warnings) @pytest.mark.parametrize( @@ -2497,12 +2497,9 @@ def test_module_gaps_reach_tools_a_resolved_toolset_contributed( assert report.release_decision.decision != "passed" assert {tool["confidence"] for tool in report.tool_catalog} == {"medium"} - gap = next( - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ) - assert reason in gap.why + _assert_named_surface_limit(report, project, reason) + if body.startswith("\nfrom external"): + assert any("constructor identity is not established" in warning for warning in report.source_warnings) def test_a_resolved_toolset_in_a_proven_module_keeps_its_high_confidence(tmp_path): @@ -2552,12 +2549,7 @@ def test_reflective_access_to_tools_is_not_a_way_around_the_mutation_guard( report = _scan_proven(tmp_path, project) assert report.release_decision.decision != "passed" - gaps = [ - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ] - assert gaps and all("mutable_tool_binding" in gap.why for gap in gaps) + _assert_named_surface_limit(report, project, "mutable_tool_binding") def test_an_imported_name_rebound_to_an_agent_is_no_longer_a_package(tmp_path): @@ -2579,12 +2571,7 @@ def test_an_imported_name_rebound_to_an_agent_is_no_longer_a_package(tmp_path): report = _scan_proven(tmp_path, project) assert report.release_decision.decision != "passed" - gaps = [ - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ] - assert gaps and all("mutable_tool_binding" in gap.why for gap in gaps) + _assert_named_surface_limit(report, project, "mutable_tool_binding") @pytest.mark.parametrize( @@ -2603,8 +2590,9 @@ def test_an_imported_name_rebound_to_an_agent_is_no_longer_a_package(tmp_path): pytest.param("str", "set[str]", id="unrepresentable_return_generic"), ], ) +@pytest.mark.parametrize("provided_model", [False, True], ids=["unread-import", "readable-local-class"]) def test_an_annotation_the_emitter_cannot_represent_is_still_a_guess( - tmp_path, annotation: str, returns: str + tmp_path, annotation: str, returns: str, provided_model: bool ): """Annotation presence is not proof the emitted schema is faithful. @@ -2633,18 +2621,16 @@ def loose_tool(record_id: {annotation}) -> {returns}: ''', ) + if provided_model: + (project / "external.py").write_text("class SomeModel:\n pass\n") report = _scan_proven(tmp_path, project) catalog = {tool["name"]: tool for tool in report.tool_catalog} assert catalog["loose_tool"]["confidence"] == "medium" - assert catalog["lookup_account"]["confidence"] == "high" - gap = next( - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ) - assert gap.subject.startswith("loose_tool") - assert "unrepresentable_annotation" in gap.why + unread_annotation = not provided_model and "SomeModel" in (annotation + returns) + assert catalog["lookup_account"]["confidence"] == ("medium" if unread_annotation else "high") + _assert_named_surface_limit(report, project, "unrepresentable_annotation", tool_names={"loose_tool"}) + assert any("constructor identity is not established" in warning for warning in report.source_warnings) is unread_annotation @pytest.mark.parametrize( @@ -2845,12 +2831,7 @@ def test_a_rebound_framework_symbol_is_not_googles_constructor( report = _scan_proven(tmp_path, project) assert report.release_decision.decision != "passed" - gaps = [ - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ] - assert gaps and all("shadowed_framework_symbol" in gap.why for gap in gaps) + _assert_named_surface_limit(report, project, "shadowed_framework_symbol") @pytest.mark.parametrize( @@ -2929,8 +2910,9 @@ def loose_tool({signature}) -> dict: pytest.param("from domain import Seq as List", "List", id="typing_list_faked"), ], ) +@pytest.mark.parametrize("provided_class", [False, True], ids=["unread-import", "readable-local-class"]) def test_a_shadowed_annotation_name_is_not_the_type_it_looks_like( - tmp_path, shadow: str, annotation: str + tmp_path, shadow: str, annotation: str, provided_class: bool ): """`from domain import Account as str` makes ADK see `Account` at runtime. @@ -2951,18 +2933,16 @@ def loose_tool(value: {annotation}) -> dict: ''', ) + if provided_class: + (project / "domain.py").write_text("class Account:\n pass\nclass Bag:\n pass\nclass Rec:\n pass\nclass Seq:\n pass\n") report = _scan_proven(tmp_path, project) tool = next( tool for tool in report.tool_catalog if tool["name"] == "loose_tool" ) assert tool["confidence"] == "medium" - gap = next( - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ) - assert "unrepresentable_annotation" in gap.why + _assert_named_surface_limit(report, project, "unrepresentable_annotation", tool_names={"loose_tool"}) + assert any("constructor identity is not established" in warning for warning in report.source_warnings) is (not provided_class) def test_a_genuine_typing_alias_is_still_faithful(tmp_path): @@ -3026,12 +3006,7 @@ def test_indirect_reflective_mutation_is_still_a_mutation( report = _scan_proven(tmp_path, project) assert report.release_decision.decision != "passed" - gaps = [ - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ] - assert gaps and all("mutable_tool_binding" in gap.why for gap in gaps) + _assert_named_surface_limit(report, project, "mutable_tool_binding") def test_an_ordinary_dictionary_with_a_tools_key_is_not_a_mutation(tmp_path): @@ -3067,9 +3042,4 @@ def test_a_star_import_makes_every_name_in_the_module_unknowable(tmp_path): report = _scan_proven(tmp_path, project) assert report.release_decision.decision != "passed" - gaps = [ - gap - for gap in report.release_decision.evidence_coverage.evidence_gaps - if gap.kind == "low_confidence_tool" - ] - assert gaps and all("star_import_shadowing" in gap.why for gap in gaps) + _assert_named_surface_limit(report, project, "star_import_shadowing") diff --git a/tests/test_implicit_child_constructor_ownership.py b/tests/test_implicit_child_constructor_ownership.py new file mode 100644 index 000000000..8dba8729d --- /dev/null +++ b/tests/test_implicit_child_constructor_ownership.py @@ -0,0 +1,71 @@ +"""An initializer can repeat the import system's exact own-child binding.""" + +import ast + +import pytest + +from agents_shipgate.inputs.builder_calls import BuilderCalls +from agents_shipgate.inputs.python_imports import ImportResolver +from tests.test_imported_tool_bindings import _adk, _sdk, _write + + +def _observations(root, framework, initializer, extra=None): + namespace = "agents" if framework == "sdk" else "google.adk.agents" + _write(root, {"pkg/__init__.py": initializer, + "pkg/agent.py": f"from {namespace} import Agent\nfrom tools import read\nroot_agent = Agent(name='App', tools=[read])\n", + "tools.py": ("from agents import function_tool\n@function_tool\n" if framework == "sdk" else "") + "def read():\n return 1\n", + **(extra or {})}) + if framework == "sdk": + loaded = _sdk(root, "pkg/agent.py") + return loaded.binding_observations, loaded.warnings + loaded, artifacts = _adk(root, "pkg/agent.py") + return [item for source in loaded for item in source.binding_observations], artifacts.warnings + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("initializer", ["from . import agent\n", '"Package docs."\nfrom . import agent\n']) +def test_exact_unused_own_child_binding_remains_readable(tmp_path, framework, initializer): + observations, warnings = _observations(tmp_path, framework, initializer) + assert observations and all(item.tools_complete for item in observations) + assert all(item.tool_names == ["read"] for item in observations) + assert not warnings + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("initializer", ["from . import agent as retained\n", + "from . import agent\nconsume(agent)\n", "from . import agent\nagent = replacement\n"]) +def test_additional_initializer_retention_remains_unread(tmp_path, framework, initializer): + observations, warnings = _observations(tmp_path, framework, initializer) + assert observations and all(not item.tools_complete for item in observations) + assert warnings + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +def test_other_importers_still_keep_export_obligation(tmp_path, framework): + observations, warnings = _observations(tmp_path, framework, "from . import agent\n", + {"consumer.py": "from pkg import agent\nconsume(agent)\n"}) + assert observations and all(not item.tools_complete for item in observations) + assert warnings + + +@pytest.mark.parametrize("change", [None, "initializer", "child", "new_consumer"]) +def test_own_child_absence_is_fresh_and_currency_bound(tmp_path, change): + text = "from agents import Agent\nroot_agent = Agent(name='App', tools=[])\n" + _write(tmp_path, {"pkg/__init__.py": "from . import agent\n", "pkg/agent.py": text}) + reader = ImportResolver(tmp_path) + module = reader.entry(tmp_path / "pkg/agent.py", ast.parse(text), text) + calls = BuilderCalls(reader) + call = next(item for item in ast.walk(module.tree) if isinstance(item, ast.Call)) + assert calls.exported_elsewhere(module, call) is False + assert id(call) not in calls._exports + if change == "initializer": + (tmp_path / "pkg/__init__.py").write_text("from . import agent as proxy\n") + elif change == "child": + (tmp_path / "pkg/agent.py").write_text(text.replace("tools=[]", "tools=[unknown]")) + elif change == "new_consumer": + (tmp_path / "consumer.py").write_text("from pkg import agent\nconsume(agent)\n") + assert calls.exported_elsewhere(module, call) is (change is not None) + if change is not None: + assert calls.export_limit(call) + else: + assert id(call) not in calls._exports diff --git a/tests/test_import_search_ownership.py b/tests/test_import_search_ownership.py new file mode 100644 index 000000000..0c23e3889 --- /dev/null +++ b/tests/test_import_search_ownership.py @@ -0,0 +1,233 @@ +"""Search mutation is named uncertainty, never a complete tool surface.""" + +from __future__ import annotations + +import ast +from pathlib import Path +from types import MappingProxyType + +import pytest + +from agents_shipgate.inputs import python_imports as imports +from agents_shipgate.inputs.python_imports import OUTSIDE_SCOPE, ImportResolver, _Stop +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_imported_tool_bindings import _adk, _sdk + + +def _write(root, files): + for name, text in files.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def _observations(root, framework, prelude, extras=None): + namespace = "agents" if framework == "sdk" else "google.adk.agents" + decorator = "from agents import function_tool\n@function_tool\n" if framework == "sdk" else "" + _write(root, { + "app.py": prelude + f"from {namespace} import Agent\nfrom tools import read\n" + "root = Agent(name='Root', tools=[read])\n", + "tools.py": decorator + "def read():\n return 1\n", + **(extras or {}), + }) + if framework == "sdk": + loaded = _sdk(root, "app.py") + return loaded.binding_observations, loaded.warnings + loaded, artifacts = _adk(root, "app.py") + return [item for source in loaded for item in source.binding_observations], artifacts.warnings + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("prelude", [ + "import sys\nsys.path.insert(0, 'vendor')\n", + "import sys as runtime\nruntime.meta_path = []\n", + "import sys\nsys.path_hooks.append(hook)\n", + "import sys\nsys.path_importer_cache.clear()\n", + "import sys\npaths = sys.path\npaths[:] = ['vendor']\n", + "import site\nsite.addsitedir('vendor')\n", + "from site import addsitedir as add\nadd('vendor')\n", + "import site\nadd = site.addsitedir\nadd('vendor')\n", +]) +def test_search_changes_withhold_constructor_binding(tmp_path, framework, prelude): + observations, warnings = _observations(tmp_path, framework, prelude, { + "vendor/agents/__init__.py": "raise AssertionError('application source must never execute')\n", + }) + assert observations and warnings + assert all(not item.tools_complete and not item.handoffs_complete for item in observations) + assert any("import search" in warning or "import-search" in warning for warning in warnings) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("prelude,extras", [ + ("import bridge\n", {"bridge.py": "import patcher\n", "patcher.py": "import sys\nsys.path.insert(0, 'vendor')\n"}), + ("from bridge import search\nsearch.insert(0, 'vendor')\n", {"bridge.py": "import sys\nsearch = sys.path\n"}), + ("import bridge\nbridge.runtime.path.insert(0, 'vendor')\n", {"bridge.py": "import sys as runtime\n"}), + ("import bridge\nsaved = bridge\nopaque(saved)\n", {"bridge.py": "import holder\n", "holder.py": "import sys\n"}), +]) +def test_imported_search_carriers_are_unread(tmp_path, framework, prelude, extras): + observations, warnings = _observations(tmp_path, framework, prelude, extras) + assert observations and warnings + assert all(not item.tools_complete and not item.handoffs_complete for item in observations) + assert any("import search" in warning or "import-search" in warning for warning in warnings) + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("prelude", [ + "import sys\nimport site\n", + "def local(sys):\n sys.path.insert(0, 'vendor')\n", +]) +def test_non_search_reads_and_local_shadow_keep_bindings(tmp_path, framework, prelude): + observations, warnings = _observations(tmp_path, framework, prelude) + assert observations and not warnings + assert all(item.tools_complete and item.handoffs_complete for item in observations) + + +@pytest.mark.parametrize("prelude,extras", [ + ("import sys\nsys.path.insert(0, 'vendor')\n", {}), + ("import site\nsite.addsitedir('vendor')\n", {}), + ("import a\n", {"a.py": "import b\n", "b.py": "import sys\nsys.path.insert(0, 'vendor')\n"}), + ("import carrier\ncarrier.runtime.path.clear()\n", {"carrier.py": "import sys as runtime\n"}), +]) +def test_search_changes_do_not_establish_ordinary_named_tools(tmp_path, prelude, extras): + _write(tmp_path, {"app.py": prelude + "from tools import read\n", "tools.py": "def read():\n return 1\n", **extras}) + resolver = ImportResolver(tmp_path) + text = (tmp_path / "app.py").read_text() + module = resolver.entry(tmp_path / "app.py", ast.parse(text), text) + resolution = resolver.resolve(module, "read") + assert resolution.caveats or not resolution.resolved + assert "import search" in str(resolution.caveats) + str(resolution.detail) + + +@pytest.mark.parametrize("method", ["module", "_patch_scan"]) +@pytest.mark.parametrize("changed_text", [False, True]) +def test_captured_search_context_rejects_unaccounted_cached_source(tmp_path, method, changed_text): + _write(tmp_path, {"app.py": "import sys\n"}) + reader = ImportResolver(tmp_path) + path = tmp_path / "app.py" + module = reader.module(path) + reader._import_search_captured = MappingProxyType({path: module.text + "# moved\n"} if changed_text else {}) + with pytest.raises(_Stop) as caught: + getattr(reader, method)(path) + assert caught.value.reason == OUTSIDE_SCOPE + + +@pytest.mark.parametrize("text", [ + "import sys\nversion = sys.version\n", + "import sys\nsys.path\nsys.path[0]\nflag = 'vendor' in sys.path\n", +]) +def test_small_non_escaping_reads_are_search_classification_only(text): + assert imports._import_search_effects(ast.parse(text)) == {} + # This supplies no receiving/native role to the separate constructor proof. + + +@pytest.mark.parametrize("count", [80, 160]) +def test_repeated_alias_projection_has_bounded_structural_work(monkeypatch, count): + text = "import sys as runtime\n" * count + "runtime.version\n" * count + calls = 0 + original = imports._absolute_import_reference + + def counted(*args, **kwargs): + nonlocal calls + calls += 1 + return original(*args, **kwargs) + + monkeypatch.setattr(imports, "_absolute_import_reference", counted) + assert imports._import_search_effects(ast.parse(text)) == {} + assert calls <= 2 * count + + +@pytest.mark.parametrize("lookup", ["default", "source_extra_root"]) +def test_captured_search_uses_snapshot_namespace_without_disk_normalization(monkeypatch, lookup): + root = Path("/__shipgate_captured_search_snapshot__") + carrier = "helpers/carrier.py" if lookup == "source_extra_root" else "carrier.py" + texts = { + "app.py": "import carrier\ncarrier.runtime.path.insert(0, 'vendor')\n", + carrier: "import sys as runtime\n", + } + + class SnapshotResolver(ImportResolver): + def __post_init__(self): + pass + + def _listing(self, directory): + if not directory.is_relative_to(root): + return None + prefix = "" if directory == root else directory.relative_to(root).as_posix() + "/" + names = {name.removeprefix(prefix).split("/", 1)[0] for name in texts if name.startswith(prefix)} + return frozenset(names) if names else None + + def _kind(self, directory, name): + if name not in (self._listing(directory) or ()): + return None + return "file" if (directory / name).relative_to(root).as_posix() in texts else "dir" + + def _absolute_candidates(self, module, dotted): + if lookup == "source_extra_root" and dotted == "carrier": + return [imports._Container(directory=root / "helpers", module_path=root / carrier)] + return super()._absolute_candidates(module, dotted) + + source = SnapshotResolver(root) + source._layout = imports.RepositoryLayout("", lambda directory: source._listing(root / directory)) + modules = [imports._module(root / name, name, ast.parse(text), text) for name, text in texts.items()] + + def forbid_normalization(*args, **kwargs): + raise AssertionError("captured source namespace must not consult disk normalization") + + with monkeypatch.context() as patch: + patch.setattr(Path, "resolve", forbid_normalization) + captured = imports._CapturedImportSearchResolver(source, modules) + limits = captured._import_search_limit([root / "app.py"]) + assert limits and any("import search" in issue for issue in limits) + assert all(captured._modules[module.path] is module for module in modules) + + +def _resolver_over(root, files): + _write(root, files) + resolver = ImportResolver(root) + return resolver, resolver.entry(root / "app.py", ast.parse(files["app.py"]), files["app.py"]) + + +def _counting_context(resolver, monkeypatch): + calls = [] + original = ImportResolver._import_search_context + + def counted(self, paths): + calls.append(tuple(paths)) + return original(self, paths) + + monkeypatch.setattr(ImportResolver, "_import_search_context", counted) + return calls + + +def test_an_import_search_answer_is_computed_once_per_runner_list(tmp_path, monkeypatch): + resolver, module = _resolver_over(tmp_path, { + "app.py": "import sys\nsys.path.insert(0, 'vendor')\n", "tools.py": "def read():\n return 1\n", + }) + calls = _counting_context(resolver, monkeypatch) + first = resolver._import_search_limit([module.path]) + assert first and "import search" in first[0] + assert resolver._import_search_limit([module.path]) == first + assert len(calls) == 1 + # Another runner list is another question; the module's own refusal is reused. + other = resolver._import_search_limit([module.path, tmp_path / "tools.py"]) + assert len(calls) == 2 and other[-1] == first[-1] + + +def test_an_import_search_answer_is_not_remembered_once_a_read_bound_is_reached(tmp_path, monkeypatch): + resolver, module = _resolver_over(tmp_path, {"app.py": "import sys\nsys.path.insert(0, 'vendor')\n"}) + calls = _counting_context(resolver, monkeypatch) + resolver._parsed = imports.MAX_MODULES # The resolution budget is spent. + first = resolver._import_search_limit([module.path]) + assert resolver._import_search_limit([module.path]) == first + assert len(calls) == 2 + assert not resolver._import_search_limits and not resolver._import_search_issues + + +def test_a_stopped_import_search_is_raised_again_not_remembered(tmp_path, monkeypatch): + resolver, module = _resolver_over(tmp_path, {"app.py": "import sys\n"}) + broken = tmp_path / "broken.py" + broken.write_text("def (\n") + for _ in range(2): + with pytest.raises(_Stop): + resolver._import_search_limit([module.path, broken]) + assert not resolver._import_search_limits diff --git a/tests/test_imported_tool_bindings.py b/tests/test_imported_tool_bindings.py index 242fc2e2b..0a48f5477 100644 --- a/tests/test_imported_tool_bindings.py +++ b/tests/test_imported_tool_bindings.py @@ -13,6 +13,7 @@ import subprocess from pathlib import Path +import pytest from typer.testing import CliRunner from agents_shipgate.cli.main import app @@ -91,7 +92,7 @@ def purge_firm_memory(crd: str) -> dict: ''' -def _write(root: Path, files: dict[str, str | None]) -> None: +def _write(root: Path, files: dict[str, str | None], *, project: bool = True) -> None: for name, content in files.items(): path = root / name if content is None: @@ -99,6 +100,12 @@ def _write(root: Path, files: dict[str, str | None]) -> None: continue path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content) + # Static import ownership needs a stable project boundary. Write first so + # creation and deletion retain their existing filesystem behavior. + if project and root.is_dir() and not any( + (directory / ".git").exists() for directory in (root, *root.parents) + ): + _git(root, "init", "-q") def _attest(head: bool) -> dict[str, str | None]: @@ -147,7 +154,8 @@ def test_adk_head_identifies_imported_memory_and_scorer_tools(tmp_path): loaded, artifacts = _adk(root) assert artifacts is not None - assert artifacts.warnings == [] + assert any("constructor identity" in warning and "dynamic import machinery" in warning for warning in artifacts.warnings) + assert all(not item.tools_complete for source in loaded for item in source.binding_observations) assert artifacts.unresolved_references == [] assert _edges(loaded, artifacts) == [ ("attest_orchestrator", "append_evidence", "agent.py:16"), @@ -198,6 +206,7 @@ def test_adk_proven_import_chain_can_reach_a_proven_surface(tmp_path): loaded, artifacts = _adk(tmp_path) assert artifacts is not None and artifacts.warnings == [] + assert all(item.tools_complete for source in loaded for item in source.binding_observations) (tool,) = [tool for source in loaded for tool in source.tools] assert tool.extraction["surface_gaps"] == [] assert tool.extraction_confidence == "high" @@ -257,7 +266,9 @@ def test_adk_wrapper_built_in_the_imported_module_is_its_function(tmp_path): ) loaded, artifacts = _adk(tmp_path) - assert artifacts is not None and artifacts.warnings == [] + assert artifacts is not None + assert any("constructor identity is not established" in warning for warning in artifacts.warnings) + assert all(not item.tools_complete for source in loaded for item in source.binding_observations) (tool,) = [tool for source in loaded for tool in source.tools] assert (tool.name, tool.source_location) == ("approve", "approvals.py:4") assert tool.annotations["long_running"] is True @@ -528,17 +539,20 @@ def test_application_diff_shows_the_two_memory_tool_additions(tmp_path): head = _commit(tmp_path, _attest(head=True)) result = _compare(tmp_path, base, head, "--scope", "agents/attest_orchestrator") - assert result["comparison_status"] == "compared" + assert result["comparison_status"] == "partial" assert result["base"]["binding_count"] == 4 assert result["head"]["binding_count"] == 6 - rows = [(row["agent"], row["tool"], row["change"]) for row in result["rows"]] - # The four unchanged bindings, scorer.score_answer included, are not - # relabelled as additions. + assert all(row["change"] == "not_established" for row in result["rows"]) + rows = [(row["agent"], row["tool"], row["candidate_change"]) for row in result["rows"] if row["candidate_change"] == "added"] + # Dynamic package loading withholds authority. The four unchanged + # candidates are still not relabelled as possible additions. assert rows == [ ("attest_orchestrator", "recall_firm_memory", "added"), ("attest_orchestrator", "remember_firm_finding", "added"), ] - after = result["rows"][1]["after"] + after = next(row["after"] for row in result["rows"] if row["tool"] == "remember_firm_finding") + assert any("constructor identity" in gap["reason"] and "dynamic import machinery" in gap["reason"] + for gap in result["head"]["coverage_gaps"]) assert after["definition"]["source"] == "agents/attest_orchestrator/memory_bank.py" assert after["definition"]["line"] == 1 assert after["binding_location"] == "agents/attest_orchestrator/agent.py:21" @@ -571,7 +585,8 @@ def execute(code: str) -> str: assert result["rows"][0]["after"]["definition"]["source"] == "tools.py" -def test_application_diff_scopes_an_unresolved_import_to_its_agent(tmp_path): +@pytest.mark.parametrize("external", [False, True]) +def test_application_diff_keeps_unresolved_import_diagnostics_beside_constructor_limits(tmp_path, external): agent = ( "from google.adk.agents import Agent\n" "from vendor_tools import search\n\n\n" @@ -581,20 +596,34 @@ def test_application_diff_scopes_an_unresolved_import_to_its_agent(tmp_path): 'support = Agent(name="support", tools=TOOLS)\n' ) _git(tmp_path, "init", "-q", "-b", "main") - base = _commit(tmp_path, {"agent.py": agent.replace("TOOLS", "[lookup]")}) + files = {"agent.py": agent.replace("TOOLS", "[lookup]")} + if not external: + files["vendor_tools.py"] = "if available:\n def search(query: str) -> str:\n return query\n" + base = _commit(tmp_path, files) head = _commit(tmp_path, {"agent.py": agent.replace("TOOLS", "[lookup, escalate]")}) result = _compare(tmp_path, base, head) assert result["comparison_status"] == "partial" - (row,) = result["rows"] - # The other agent's addition is established, not swallowed by a file-wide gap. - assert (row["agent"], row["tool"], row["change"]) == ("support", "escalate", "added") - (gap,) = [gap for gap in result["head"]["coverage_gaps"] if gap["agent"] == "researcher"] + if external: + assert result["rows"] and all(row["change"] == "not_established" for row in result["rows"]) + (row,) = [row for row in result["rows"] if row["candidate_change"] == "added"] + assert (row["agent"], row["tool"]) == ("support", "escalate") + assert row["after"]["definition"]["source"] == "agent.py" + assert any(gap["agent"] == "support" and "unread external imported handle" in gap["reason"] + for gap in result["head"]["coverage_gaps"]) + else: + (row,) = result["rows"] + # The local conditional definition has no unread external handle; + # its unresolved binding remains scoped to the researcher. + assert (row["agent"], row["tool"], row["change"]) == ("support", "escalate", "added") + (gap,) = [gap for gap in result["head"]["coverage_gaps"] + if gap["agent"] == "researcher" and "references unresolved tool 'search'" in gap["reason"]] assert "references unresolved tool 'search'" in gap["reason"] assert "vendor_tools" in gap["reason"] -def test_application_diff_names_an_unresolved_wrapper_once(tmp_path): +@pytest.mark.parametrize("external", [False, True]) +def test_application_diff_names_an_unresolved_wrapper_once_beside_constructor_limits(tmp_path, external): agent = ( "from google.adk.agents import Agent\n" "from google.adk.tools import FunctionTool\n" @@ -604,13 +633,56 @@ def test_application_diff_names_an_unresolved_wrapper_once(tmp_path): 'support = Agent(name="support", tools=TOOLS)\n' ) _git(tmp_path, "init", "-q", "-b", "main") - base = _commit(tmp_path, {"agent.py": agent.replace("TOOLS", "[]")}) + files = {"agent.py": agent.replace("TOOLS", "[]")} + if not external: + files["vendor_tools.py"] = "if available:\n def search(query: str) -> str:\n return query\n" + base = _commit(tmp_path, files) head = _commit(tmp_path, {"agent.py": agent.replace("TOOLS", "[lookup]")}) result = _compare(tmp_path, base, head) assert [(row["agent"], row["tool"], row["change"]) for row in result["rows"]] == [ - ("support", "lookup", "added") + ("support", "lookup", "not_established" if external else "added") ] - (gap,) = [gap for gap in result["head"]["coverage_gaps"] if gap["agent"] == "researcher"] + if external: + assert result["rows"][0]["candidate_change"] == "added" + assert result["rows"][0]["after"]["definition"]["source"] == "agent.py" + assert any(gap["agent"] == "support" and "unread external imported handle" in gap["reason"] + for gap in result["head"]["coverage_gaps"]) + (gap,) = [gap for gap in result["head"]["coverage_gaps"] + if gap["agent"] == "researcher" and "wraps a tool whose function 'search'" in gap["reason"]] assert "wraps a tool whose function 'search'" in gap["reason"] assert gap["reason"].count("vendor_tools") == 1 + + +@pytest.mark.parametrize("wrapped", [False, True]) +@pytest.mark.parametrize("imported", [False, True]) +def test_external_tool_ownership_follows_actual_module_imports(tmp_path, wrapped, imported): + researcher = ( + "from google.adk.agents import Agent\n" + "from vendor_tools import search\n" + + ("from google.adk.tools import FunctionTool\n" if wrapped else "") + + 'researcher = Agent(name="researcher", tools=' + + ("[FunctionTool(func=search)]" if wrapped else "[search]") + ")\n" + ) + support = ( + ("import researcher\n" if imported else "") + + "from google.adk.agents import Agent\n" + + "def lookup(query: str) -> str:\n return query\n" + + 'support = Agent(name="support", tools=TOOLS)\n' + ) + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, {"researcher.py": researcher, "support.py": support.replace("TOOLS", "[]")}) + head = _commit(tmp_path, {"support.py": support.replace("TOOLS", "[lookup]")}) + result = _compare(tmp_path, base, head) + assert result["comparison_status"] == "partial" + (row,) = result["rows"] + assert (row["agent"], row["tool"], row["change"]) == ( + "support", "lookup", "not_established" if imported else "added" + ) + assert row["after"]["definition"]["source"] == "support.py" + support_gaps = [gap for gap in result["head"]["coverage_gaps"] if gap["agent"] == "support"] + if imported: + assert row["candidate_change"] == "added" + assert any("unread external imported handle" in gap["reason"] for gap in support_gaps) + else: + assert not support_gaps # An independently discovered file was not imported here. diff --git a/tests/test_imported_tool_review.py b/tests/test_imported_tool_review.py index 591b27538..e7e148ef9 100644 --- a/tests/test_imported_tool_review.py +++ b/tests/test_imported_tool_review.py @@ -10,7 +10,9 @@ from __future__ import annotations import ast +import hashlib import json +import sys import time import pytest @@ -29,6 +31,33 @@ def _rows(result): return [(r["agent"], r["tool"], r["change"]) for r in result["rows"]] +def _constructor_candidate(result, agent, tool, direction, *, repo, source, reason=None): + """Preserve readable interface identity without granting constructor wiring.""" + assert result["comparison_status"] == "partial" + assert all(row["change"] == "not_established" for row in result["rows"]) + row, = [item for item in result["rows"] if item["agent"] == agent and item["tool"] == tool] + assert row["candidate_change"] == direction + observed = row["before"] if direction == "removed" else row["after"] + assert observed is not None and observed["definition"]["source"] == source + tree = ast.parse((repo / source).read_text()) + definition, = [node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and node.name == tool] + if (definition.body and isinstance(definition.body[0], ast.Expr) + and isinstance(definition.body[0].value, ast.Constant) + and isinstance(definition.body[0].value.value, str)): + definition.body = definition.body[1:] + dumped = ast.dump(definition, include_attributes=False, + **({"show_empty": True} if sys.version_info >= (3, 13) else {})) + expected = hashlib.sha256(json.dumps(dumped, separators=(",", ":")).encode()).hexdigest() + assert observed["definition"]["line"] == definition.lineno + assert observed["definition"]["implementation_sha256"] == expected + reasons = [gap["reason"] for side in ("base", "head") for gap in result[side]["coverage_gaps"]] + assert any("constructor identity is not established" in value for value in reasons) + if reason is not None: + assert any(reason in value for value in reasons) + return row + + def test_sdk_two_definitions_under_one_name_bind_neither(repo): agent = "from agents import Agent\nimport billing, support\n\nagent = Agent(name='app', tools=TOOLS)\n" base = commit( @@ -297,8 +326,9 @@ def test_nonlocal_follows_the_outer_function_binding(repo): base = commit(repo, {"agent.py": source, "billing.py": BILLING_SDK, "support.py": SUPPORT_SDK}) head = commit(repo, {"support.py": SUPPORT_SDK.replace("'support'", "q.upper()")}) result = run(repo, base, head) - assert _rows(result) == [("agent", "lookup", "changed")] - assert result["rows"][0]["after"]["definition"]["source"] == "support.py" + _constructor_candidate(result, "agent", "lookup", "changed", repo=repo, source="support.py", + reason="'inner' (agent.py:8) is decorated, nested, or rebound") + assert _rows(result) == [("agent", "lookup", "not_established")] @pytest.mark.parametrize( @@ -660,8 +690,11 @@ def test_a_guessed_binding_is_never_an_established_addition_or_removal(repo, dir head = commit(repo, {"agent.py": with_lookup if direction == "added" else without}) result = run(repo, base, head) assert result["comparison_status"] == "partial" - assert _rows(result) == [("app", "lookup", "not_established")] - assert result["rows"][0]["candidate_change"] == direction + _constructor_candidate(result, "app", "lookup", direction, repo=repo, source="agent.py", + reason="'lookup' is bound more than once") + _constructor_candidate(result, "app", "other", "changed", repo=repo, source="agent.py", + reason="executable or reflective machinery") + assert _rows(result) == [("app", "lookup", "not_established"), ("app", "other", "not_established")] def test_a_guessed_name_keeps_the_agents_other_findings_in_scan(tmp_path): @@ -689,6 +722,14 @@ def test_a_guessed_name_keeps_the_agents_other_findings_in_scan(tmp_path): assert ("SHIP-SCHEMA-FREEFORM-OUTPUT", "delete_customer_account") in { (finding["check_id"], finding.get("tool_name")) for finding in report["findings"] } + finding = next(item for item in report["findings"] + if item["check_id"] == "SHIP-SCHEMA-FREEFORM-OUTPUT" + and item.get("tool_name") == "delete_customer_account") + assert finding["evidence"]["binding_status"] == "unknown" + assert finding["evidence"]["binding_established"] is False + assert finding["source"]["ref"] == "agent.py" + assert report["tool_inventory"] == [] + assert report["release_decision"]["decision"] != "passed" @pytest.mark.parametrize( @@ -824,7 +865,11 @@ def test_an_import_fallback_does_not_hide_the_agents_other_changes(repo, directi base = commit(repo, {"agent.py": without if direction == "added" else with_delete}) head = commit(repo, {"agent.py": with_delete if direction == "added" else without}) result = run(repo, base, head) - assert ("app", "delete_customer_account", direction) in _rows(result) + _constructor_candidate(result, "app", "delete_customer_account", direction, repo=repo, source="agent.py") + _constructor_candidate(result, "app", "search", "changed", repo=repo, source="agent.py", + reason="'search' is bound more than once") + assert _rows(result) == [("app", "delete_customer_account", "not_established"), + ("app", "search", "not_established")] @pytest.mark.parametrize( @@ -848,10 +893,28 @@ def test_sdk_list_passed_to_a_helper_is_dynamic_only_when_it_can_change(repo, he result = run(repo, base, head) if dynamic: assert result["comparison_status"] == "partial" + elif "base_agent.clone(tools=TOOLS)" in helper: + # The unread copy owns its limit; passing TOOLS does not mutate the + # list or erase the two independently declared agents' tool changes. + assert result["comparison_status"] == "partial" + assert _rows(result) == [("agent", "lookup", "changed"), + ("base_agent", "lookup", "changed")] + for side in ("base", "head"): + gaps = result[side]["coverage_gaps"] + assert len(gaps) == 1 + assert gaps[0]["agent"] == "variant" + assert "agent copy at agent.py:6 (clone) is not read" in gaps[0]["reason"] + assert gaps[0]["affects"] == "binding_presence" else: - # The list stays established. A copy passing its own tools is a limit on - # the copy (#876), never on the agent whose list it reads. - assert ("agent", "lookup", "changed") in _rows(result) + # These consumers can retain a callable's constructor namespace even + # when the old membership-only proof regarded them as reads. + assert result["comparison_status"] == "partial" + assert all(row["change"] == "not_established" for row in result["rows"]) + assert any(gap.get("agent") == "agent" and "dynamic tools expression" in gap["reason"] + for gap in result["head"]["coverage_gaps"]) + assert any("constructor identity is not established" in gap["reason"] + for gap in result["head"]["coverage_gaps"]) + assert any(item["path"] == "agent.py" for item in result["head"]["sources"]) def test_a_package_that_reexports_many_modules_does_not_exhaust_the_budget(repo): @@ -1491,7 +1554,10 @@ def test_a_package_hook_is_established_only_when_it_returns_the_submodule(repo, base = commit(repo, files) head = commit(repo, {"agent.py": LAZY_AGENT}) result = run(repo, base, head) - if established: + if established and "import importlib" in hook: + _constructor_candidate(result, "x", "remember", "added", repo=repo, source="pkg/memory.py", + reason="builtin or dynamic import machinery in pkg/__init__.py") + elif established: assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] assert _rows(result) == [("x", "remember", "added")] else: @@ -1794,8 +1860,8 @@ def test_a_sys_modules_store_is_read_by_what_it_can_name(repo, store, outcome): head = commit(repo, {"svc/app/agent.py": AGENT_LOOKUP}) result = run(repo, base, head, "--scope", "svc/app") if outcome == "added": - assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] - assert _rows(result) == [("x", "lookup", "added")] + _constructor_candidate(result, "x", "lookup", "added", repo=repo, source="svc/app/tools.py", + reason="loader.py:3 changes or retains sys; import search identity is unread") elif outcome == "not_established": assert _rows(result) == [("x", "lookup", "not_established")] assert any("computed name" in gap["reason"] for gap in result["head"]["coverage_gaps"]) @@ -1902,8 +1968,12 @@ def test_the_module_table_and_namespace_are_read_by_allow_list(repo, package, ou head = commit(repo, {"agent.py": LAZY_AGENT}) result = run(repo, base, head) if outcome == "added": - assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] - assert _rows(result) == [("x", "remember", "added")] + # A package that stores ``sys`` itself is read first by the import-search + # check; one that only reads its namespace is read as reflective access. + reason = ("pkg/__init__.py:3 changes or retains sys; import search identity is unread" + if "= sys\n" in package else "reflective namespace access in pkg/__init__.py") + _constructor_candidate(result, "x", "remember", "added", repo=repo, source="pkg/memory.py", + reason=reason) elif outcome == "not_established": assert _rows(result) == [("x", "remember", "not_established")] else: @@ -1983,12 +2053,13 @@ def test_what_a_package_above_the_scope_imports_is_read(repo, above): @pytest.mark.parametrize( "above", [ - {"app/__init__.py": "from ._version import version as __version__ # noqa: F401\n"}, + {"app/__init__.py": "from ._version import version as __version__ # noqa: F401\n", "app/_version.py": "version = '0'\n"}, { "app/__init__.py": ( "try:\n from ._version import version as __version__ # noqa: F401\n" "except ImportError:\n __version__ = '0'\n" - ) + ), + "app/_version.py": "version = '0'\n", }, { "app/__init__.py": "from .routers import users # noqa: F401\n", @@ -2019,6 +2090,30 @@ def test_ordinary_packages_above_the_scope_change_nothing(repo, above): assert _rows(result) == [("x", "refund", "added")] +@pytest.mark.parametrize("guarded", [False, True]) +@pytest.mark.parametrize("provider", ["missing", "literal", "constructor-patch"]) +def test_an_above_scope_version_import_requires_readable_constructor_ownership(repo, guarded, provider): + init = "from ._version import version as __version__\n" + if guarded: + init = "try:\n from ._version import version as __version__\nexcept ImportError:\n __version__ = '0'\n" + files = {**SUPPORT_LAYOUT, "app/__init__.py": init, + "app/agents/support/agent.py": SUPPORT_ADK_AGENT.format(tools="lookup")} + if provider != "missing": + files["app/_version.py"] = "version = '0'\n" + if provider == "constructor-patch": + files["app/_version.py"] += "from abc import ABCMeta\ndef replacement(*args, **kwargs):\n return None\nABCMeta.__call__ = replacement\n" + base = commit(repo, files) + head = commit(repo, {"app/agents/support/agent.py": SUPPORT_ADK_AGENT.format(tools="lookup, refund")}) + result = run(repo, base, head, "--scope", "app/agents/support") + if provider == "literal": + assert result["comparison_status"] == "compared" + assert _rows(result) == [("x", "refund", "added")] + else: + assert result["comparison_status"] == "partial" + assert result["head"]["coverage_gaps"] + assert all(row["change"] == "not_established" for row in result["rows"]) + + @pytest.mark.parametrize( "files", [ @@ -2052,10 +2147,14 @@ def test_ordinary_packages_above_the_scope_change_nothing(repo, above): " return sum(1 for v in vars if '\\n' in v)\n" ), }, + { + "svc/app/__init__.py": "from . import debug\n", + "svc/app/debug.py": "import traceback\ndef dump(*args):\n vars = traceback.extract_stack()[-2][-3]\n return sum([1 for v in vars if '\\n' in v])\n", + }, ], ids=[ "forward-refs", "type-hints-namespace", "starred-namespace", "path-iteration", "test-helpers", - "a-local-named-vars", + "a-local-named-vars-generator", "a-local-named-vars-list", ], ) def test_namespace_reads_in_the_chain_are_not_patches(repo, files): @@ -2070,8 +2169,17 @@ def test_namespace_reads_in_the_chain_are_not_patches(repo, files): base = commit(repo, {**layout, "svc/app/agent.py": agent.format(tools="lookup")}) head = commit(repo, {"svc/app/agent.py": agent.format(tools="lookup, refund")}) result = run(repo, base, head, "--scope", "svc/app") - assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] - assert _rows(result) == [("x", "refund", "added")] + if "svc/app/debug.py" in files and "return sum([" in files["svc/app/debug.py"]: + assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] + assert _rows(result) == [("x", "refund", "added")] + else: + _constructor_candidate(result, "x", "refund", "added", repo=repo, source="svc/app/tools.py") + _constructor_candidate(result, "x", "lookup", "changed", repo=repo, source="svc/app/tools.py") + assert any("reflective namespace access" in gap["reason"] + or "builtin or dynamic import machinery" in gap["reason"] + or "unread class construction hook" in gap["reason"] + or "generator retains the constructor namespace" in gap["reason"] + for gap in result["head"]["coverage_gaps"]) def test_a_stdlib_named_module_on_a_plain_import_root_is_the_applications(repo): @@ -2235,8 +2343,13 @@ def test_a_namespace_declaration_above_the_scope_changes_nothing(repo, init): base = commit(repo, _svc({"svc/__init__.py": init})) head = commit(repo, {"svc/app/agent.py": SCOPED_LOOKUP_AGENT.format(tools="lookup")}) result = run(repo, base, head, "--scope", "svc/app") - assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] - assert _rows(result) == [("x", "lookup", "added")] + if "__import__" in init: + assert result["comparison_status"] == "partial" + assert all(row["change"] == "not_established" for row in result["rows"]) + assert any("executable or reflective machinery" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + else: + assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] + assert _rows(result) == [("x", "lookup", "added")] @pytest.mark.parametrize( @@ -2285,7 +2398,8 @@ def test_globals_handed_positionally_to_get_type_hints_is_a_read(repo): base = commit(repo, _svc({"svc/app/tools.py": tools})) head = commit(repo, {"svc/app/agent.py": SCOPED_LOOKUP_AGENT.format(tools="lookup")}) result = run(repo, base, head, "--scope", "svc/app") - assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] + _constructor_candidate(result, "x", "lookup", "added", repo=repo, source="svc/app/tools.py", + reason="reflective namespace access in tools.py") # --------------------------------------------------------------------------- @@ -2306,8 +2420,7 @@ def test_a_logging_helper_reading_its_callers_frame_changes_nothing(repo, helper base = commit(repo, _svc({"svc/app/tools.py": tools, "svc/app/log.py": helper})) head = commit(repo, {"svc/app/agent.py": SCOPED_LOOKUP_AGENT.format(tools="lookup")}) result = run(repo, base, head, "--scope", "svc/app") - assert result["comparison_status"] == "compared", result["head"]["coverage_gaps"] - assert _rows(result) == [("x", "lookup", "added")] + _constructor_candidate(result, "x", "lookup", "added", repo=repo, source="svc/app/tools.py", reason="machinery in log.py") # --------------------------------------------------------------------------- @@ -2384,8 +2497,17 @@ def test_the_real_readers_still_read(repo, reader, established): base = commit(repo, {"tools.py": tools, "agent.py": agent.replace("BIND", "lookup").replace("READER", reader).replace("[lookup, lookup]", "[lookup]")}) head = commit(repo, {"agent.py": agent.replace("BIND", "execute").replace("READER", reader)}) result = run(repo, base, head) - assert (result["comparison_status"] == "compared") is established, result["head"]["limits"] - assert _rows(result) == [("agent", "execute", "added")] + if reader.startswith("print("): + assert (result["comparison_status"] == "compared") is established, result["head"]["limits"] + assert _rows(result) == [("agent", "execute", "added")] + else: + # Library readers need an explicit retained-namespace proof; a named + # mutable list cannot enumerate its members under that uncertainty. + assert result["comparison_status"] == "partial" + assert result["rows"] == [] + assert any("dynamic tools expression" in gap["reason"] for gap in result["head"]["coverage_gaps"]) + assert any("constructor identity is not established" in gap["reason"] + for gap in result["head"]["coverage_gaps"]) def test_a_lazy_hook_importing_another_submodule_under_the_name_is_not_the_idiom(repo): diff --git a/tests/test_large_sample.py b/tests/test_large_sample.py index 1bf1dfa7f..8c8103dd6 100644 --- a/tests/test_large_sample.py +++ b/tests/test_large_sample.py @@ -54,7 +54,10 @@ # pipeline. MIN_CATALOG_TOOLS = 50 MAX_CATALOG_TOOLS = 100 -EXPECTED_REACHABLE_TOOLS = 5 +EXPECTED_POSSIBLE_TOOLS = { + "render_customer_email_preview", "calculate_refund_total", "generate_shipping_label_pdf", + "schedule_internal_callback", "escalate_to_human", +} MIN_TOTAL_FINDINGS = 20 MAX_TOTAL_FINDINGS = 60 MIN_REVIEW_ITEMS = 20 @@ -166,12 +169,22 @@ def test_all_six_tool_sources_load(scanned_sample: ReadinessReport) -> None: def test_tool_inventory_is_in_expected_band(scanned_sample: ReadinessReport) -> None: - assert len(scanned_sample.tool_inventory) == EXPECTED_REACHABLE_TOOLS + assert scanned_sample.tool_inventory == [] n = len(scanned_sample.tool_catalog) assert MIN_CATALOG_TOOLS <= n <= MAX_CATALOG_TOOLS - assert scanned_sample.binding_surface_facts.reachable_tool_ids == sorted( - tool["tool_id"] for tool in scanned_sample.tool_inventory - ) + facts = scanned_sample.binding_surface_facts + assert facts.reachable_tool_ids == [] + possible = [tool for tool in scanned_sample.tool_catalog if tool["tool_id"] in facts.possible_tool_ids] + assert {tool["name"] for tool in possible} == EXPECTED_POSSIBLE_TOOLS + assert len(possible) == 5 and all(tool["binding_assessment"]["status"] == "unknown" for tool in possible) + assert {edge.tool_id for edge in facts.tool_edges} == set(facts.possible_tool_ids) + assert all(not edge.complete and edge.source_pointer == "agents/ops_assistant.py:91" + for edge in facts.tool_edges) + assert scanned_sample.source_warnings == [ + "OpenAI Agents SDK agent 'ops_assistant' at agents/ops_assistant.py:91: its constructor identity " + "is not established: an anonymous callable or generator retains the constructor namespace " + "in agents/ops_assistant.py:40." + ] def test_findings_count_is_in_expected_band(scanned_sample: ReadinessReport) -> None: @@ -183,11 +196,11 @@ def test_findings_count_is_in_expected_band(scanned_sample: ReadinessReport) -> ) -def test_release_decision_requires_review(scanned_sample: ReadinessReport) -> None: - """Only the five statically wired local SDK helpers are in scope.""" +def test_release_decision_requires_constructor_evidence(scanned_sample: ReadinessReport) -> None: + """The generator retains globals; reviewed interfaces cannot prove wiring.""" decision = scanned_sample.release_decision assert decision is not None - assert decision.decision == "review_required" + assert decision.decision == "insufficient_evidence" assert not decision.blockers assert len(decision.review_items) >= MIN_REVIEW_ITEMS @@ -270,7 +283,7 @@ def test_reviewer_summary_block_is_populated( appear on every emitted scan.""" summary = scanned_sample.reviewer_summary assert summary is not None - assert summary.verdict == "review_required" + assert summary.verdict == "insufficient_evidence" assert summary.headline # non-empty diff --git a/tests/test_list_expressions.py b/tests/test_list_expressions.py index 441e82abc..7d5556dc2 100644 --- a/tests/test_list_expressions.py +++ b/tests/test_list_expressions.py @@ -339,14 +339,16 @@ def test_sdk_unread_handoffs_are_named_beside_the_read_ones(tmp_path): }, ) (observation,) = [item for item in _sdk(tmp_path, "triage.py").binding_observations if item.agent == "triage"] - assert observation.handoff_names == ["billing"] + assert observation.handoff_names == ["billing"] # The readable candidate; its edge is incomplete. assert observation.handoffs_complete is False - assert observation.issues == [ + assert any("constructor identity is not established" in issue for issue in observation.issues) + for issue in [ "OpenAI Agents SDK agent 'triage' has dynamic handoffs at triage.py:3. Not read: a call to " "`more_agents`, whose result is not read (triage.py:3).", "OpenAI Agents SDK agent 'triage' at triage.py:3 hands off to `handoff(billing)`, which is not " "resolved to an agent: it is not a name.", - ] + ]: + assert issue in observation.issues def test_sdk_a_change_through_one_agent_reaches_every_agent_sharing_the_list(tmp_path): @@ -671,7 +673,8 @@ def test_the_comparison_never_runs_the_list(tmp_path): observation = _observation(_sdk(tmp_path, "agent.py")) assert observation.tool_names == ["load_and_validate"] assert observation.tools_complete is False - assert "a call to `eval`, whose result is not read (agent.py:4)" in observation.issues[0] + assert any("a call to `eval`, whose result is not read (agent.py:4)" in issue for issue in observation.issues) + assert any("constructor identity is not established" in issue for issue in observation.issues) def test_sdk_reader_writes_nothing(tmp_path): @@ -1129,7 +1132,11 @@ def test_adk_tools_shapes(tmp_path, expression, names, conditions): (observation,) = [item for source in loaded for item in source.binding_observations] assert observation.tool_names == names assert observation.tool_conditions == conditions - assert observation.tools_complete is True + if "for t in" in expression: + assert not observation.tools_complete + assert any("constructor identity is not established" in issue for issue in observation.issues) + else: + assert observation.tools_complete is True @pytest.mark.parametrize( @@ -1150,9 +1157,18 @@ def test_adk_sub_agents_shapes(tmp_path, expression, names, conditions): f"root_agent = Agent(name='triage', sub_agents={expression})\n"}) _, artifacts = _adk(tmp_path) (record,) = [record for record in artifacts.sub_agents if record["agent_name"] == "triage"] - assert record["sub_agents"] == names assert record["conditions"] == conditions + assert record["sub_agents"] == names assert record["sub_agent_count"] == len(names) + if "for a in" in expression: + # The names were read: candidates that survive the constructor gap. The + # gap makes the edge incomplete (``unread``); it does not turn a matched + # agent into one "which this scan could not match to an agent definition". + assert "constructor identity is not established" in record["unread"] + assert not record.get("unresolved_sub_agents") + assert any("constructor identity is not established" in warning for warning in artifacts.warnings) + else: + assert "unread" not in record def test_adk_sub_agents_from_another_module_are_not_this_module_s(tmp_path): @@ -1201,4 +1217,9 @@ def test_sdk_handoffs_shapes(tmp_path, expression, names): f"triage = Agent(name='Triage', handoffs={expression})\n"}) (observation,) = [item for item in _sdk(tmp_path, "triage.py").binding_observations if item.agent == "triage"] assert observation.handoff_names == names - assert observation.handoffs_complete is True + if "for a in" in expression: + # Readable candidate edges survive a constructor gap, incomplete. + assert not observation.handoffs_complete + assert any("constructor identity is not established" in issue for issue in observation.issues) + else: + assert observation.handoffs_complete is True diff --git a/tests/test_object_tool_ownership.py b/tests/test_object_tool_ownership.py new file mode 100644 index 000000000..76335d84e --- /dev/null +++ b/tests/test_object_tool_ownership.py @@ -0,0 +1,719 @@ +"""The constructor-ownership proof accepts the framework objects #985 reads, only as proved. + +An MCP server or toolset, an agent exposed as a tool, a hosted or built-in tool and +``function_tool(f)`` are bindings with an identity (#910). The ownership proof used +to refuse each of them as an opaque use of the framework namespace, which withheld +the whole agent. Each route taught here accepts one shape by its role: + +- the exact import path, resolved by import identity (never a spelling) and not + provided by any file the repository holds; +- used directly as a member of a framework constructor's ``tools=`` or + ``mcp_servers=`` list, or as the documented call that builds the object; +- the result then reaches only the owner census the list reader already runs. + +Every control below keeps a variant of the same shape refused: a same-named local +object, a shadowed, vendored or reassigned import, an object changed after import +or passed anywhere but the receiving list, and each standard-library or class-hook +exemption that is not exactly the standard library's. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from agents_shipgate.inputs.google_adk import load_google_adk_artifacts +from agents_shipgate.inputs.object_tools import reading_object_bindings +from agents_shipgate.inputs.openai_sdk_static import load_openai_sdk_static_tools +from agents_shipgate.schemas.manifest import ToolSourceConfig + + +def _write(root: Path, files: dict[str, str]) -> None: + if not (root / ".git").exists(): + # A checkout root bounds the repository layout the readers walk; without + # one they scan every directory above the temporary tree. + subprocess.run(["git", "init", "-q", str(root)], check=True) + for name, text in files.items(): + (root / name).parent.mkdir(parents=True, exist_ok=True) + (root / name).write_text(text) + + +def _adk(root: Path, files: dict[str, str], entry: str = "agent.py") -> list: + _write(root, files) + with reading_object_bindings(lambda path: False): + loaded, _ = load_google_adk_artifacts( + None, root, sources=[ToolSourceConfig(id="adk", type="google_adk", path=entry)] + ) + return [observation for item in loaded for observation in item.binding_observations] + + +def _sdk(root: Path, files: dict[str, str], entry: str = "agent.py") -> list: + _write(root, files) + with reading_object_bindings(lambda path: False): + loaded = load_openai_sdk_static_tools( + ToolSourceConfig(id="sdk", type="openai_agents_sdk", path=entry), None, root + ) + return list(loaded.binding_observations) + + +def _established(observations: list) -> None: + assert observations + assert all(not item.constructor_issues for item in observations), [ + item.constructor_issues for item in observations + ] + assert any(item.object_bindings for item in observations) + + +def _refused(observations: list) -> None: + assert observations + assert any(item.constructor_issues for item in observations) + + +# -- ADK built-in tools ----------------------------------------------------------- + +ADK = """from google.adk.agents import LlmAgent +from google.adk.tools import google_search +{extra} +root_agent = LlmAgent(name="planner", model="m", tools={tools}) +""" + + +def _built_in(extra: str = "", tools: str = "[google_search]") -> dict[str, str]: + return {"agent.py": ADK.format(extra=extra, tools=tools)} + + +def test_a_built_in_in_the_tools_list_is_established(tmp_path): + _established(_adk(tmp_path, _built_in())) + + +def test_a_built_in_in_a_list_the_agent_receives_is_established(tmp_path): + _established(_adk(tmp_path, _built_in("TOOLS = [google_search]\n", "TOOLS"))) + + +@pytest.mark.parametrize( + "extra", + [ + "google_search.name = 'x'\n", + "setattr(google_search, 'name', 'x')\n", + "google_search = wrap(google_search)\n", + "def google_search():\n pass\n", + "alias = google_search\n", + "opaque(google_search)\n", + "registry = {'search': google_search}\n", + "TOOLS = [google_search]\nTOOLS[0].name = 'x'\n", + "TOOLS = [google_search]\nopaque(TOOLS)\n", + "TOOLS = [google_search]\nalias = TOOLS\n", + ], + ids=[ + "attribute-stored", "setattr", "reassigned", "redefined", "aliased", "passed-opaquely", + "stored-in-a-dict", "member-attribute-stored", "container-passed", "container-aliased", + ], +) +def test_a_built_in_changed_retained_or_handed_on_is_refused(tmp_path, extra): + _refused(_adk(tmp_path, _built_in(extra, "TOOLS" if "TOOLS" in extra else "[google_search]"))) + + +def test_a_built_in_handed_to_something_other_than_the_agent_list_is_refused(tmp_path): + source = ADK.format(extra="", tools="[]") + "other = LlmAgent(name='o', model='m', before_model_callback=[google_search])\n" + _refused(_adk(tmp_path, {"agent.py": source})) + + +def test_a_vendored_google_package_is_not_the_framework(tmp_path): + files = { + "google/__init__.py": "", + "google/adk/__init__.py": "", + "google/adk/tools/__init__.py": "google_search = object()\n", + **_built_in(), + } + _refused(_adk(tmp_path, files)) + + +def test_another_packages_built_in_name_is_not_the_framework(tmp_path): + source = ADK.replace("from google.adk.tools import google_search", "from my_tools import google_search") + observations = _adk(tmp_path, {"agent.py": source.format(extra="", tools="[google_search]")}) + assert all(not item.object_bindings for item in observations) + + +# -- ADK toolsets and AgentTool ------------------------------------------------------ + +TOOLSET = """from google.adk.agents import LlmAgent +from google.adk.tools.mcp_tool import McpToolset, SseConnectionParams +{extra} +toolset = McpToolset(connection_params=SseConnectionParams(url={url}), tool_filter=["search"]) +root_agent = LlmAgent(name="planner", model="m", tools={tools}) +""" + + +def _toolset(url: str = '"https://mcp.example.com/sse"', extra: str = "", tools: str = "[toolset]") -> dict[str, str]: + return {"agent.py": TOOLSET.format(url=url, extra=extra, tools=tools)} + + +def test_a_toolset_with_its_connection_data_is_established(tmp_path): + _established(_adk(tmp_path, _toolset())) + + +def test_a_toolset_whose_url_is_a_call_or_template_is_established(tmp_path): + _established(_adk(tmp_path, _toolset('os.getenv("MCP_URL", "https://mcp.example.com") + "/sse"', "import os\n"))) + + +@pytest.mark.parametrize( + "extra", + [ + "opaque(toolset)\n", + "alias = toolset\n", + "toolset.close()\n", + "toolset.tool_filter.append('admin')\n", + "TOOLS = [toolset]\nopaque(TOOLS)\n", + ], + ids=["passed-opaquely", "aliased", "method-called", "filter-widened", "container-passed"], +) +def test_a_toolset_used_beyond_the_receiving_list_is_refused(tmp_path, extra): + _refused(_adk(tmp_path, _toolset(extra=extra))) + + +@pytest.mark.parametrize( + "url", + ["lambda: 'https://x'", "make_url", "(yield)"], + ids=["a-lambda", "a-function-value", "a-generator"], +) +def test_executable_connection_data_is_refused(tmp_path, url): + extra = "def make_url():\n return 'https://x'\n" + _refused(_adk(tmp_path, _toolset(url=url, extra=extra))) + + +def test_a_connection_class_of_another_package_is_not_the_frameworks(tmp_path): + source = TOOLSET.replace( + "from google.adk.tools.mcp_tool import McpToolset, SseConnectionParams", + "from google.adk.tools.mcp_tool import McpToolset\nfrom my_params import SseConnectionParams", + ) + _refused(_adk(tmp_path, {"agent.py": source.format(url='"https://x"', extra="", tools="[toolset]")})) + + +def test_a_local_toolset_class_of_the_same_name_is_not_the_frameworks(tmp_path): + source = ( + "from google.adk.agents import LlmAgent\n\n\n" + "class McpToolset:\n def __init__(self, **kwargs):\n pass\n\n\n" + 'toolset = McpToolset(connection_params=None)\n' + 'root_agent = LlmAgent(name="planner", model="m", tools=[toolset])\n' + ) + observations = _adk(tmp_path, {"agent.py": source}) + assert all(not item.object_bindings for item in observations) + + +AGENT_TOOL = """from google.adk.agents import LlmAgent +from google.adk.tools.agent_tool import AgentTool + +scout = LlmAgent(name="scout", model="m") +{extra} +root_agent = LlmAgent(name="planner", model="m", tools=[AgentTool(agent=scout)]) +""" + + +def test_an_agent_tool_holding_an_agent_is_established(tmp_path): + _established(_adk(tmp_path, {"agent.py": AGENT_TOOL.format(extra="")})) + + +@pytest.mark.parametrize( + "extra", + [ + "opaque(scout)\n", + "alias = scout\n", + "held = AgentTool(agent=scout)\nopaque(held)\n", + "scout.__init__(tools=[opaque])\n", + "scout.model_post_init(None)\n", + ], + ids=["passed-opaquely", "aliased", "tool-passed-opaquely", "reinitialized", "validation-hook"], +) +def test_an_agent_held_by_a_tool_and_used_elsewhere_is_refused(tmp_path, extra): + _refused(_adk(tmp_path, {"agent.py": AGENT_TOOL.format(extra=extra)})) + + +def test_a_local_agent_tool_class_is_not_the_frameworks(tmp_path): + source = AGENT_TOOL.replace("from google.adk.tools.agent_tool import AgentTool", "from my_tools import AgentTool") + observations = _adk(tmp_path, {"agent.py": source.format(extra="")}) + assert all(not item.object_bindings for item in observations) + + +def test_an_eager_return_annotation_is_established_until_something_reads_annotations(tmp_path): + helper = ( + "from google.adk.tools.mcp_tool import McpToolset, SseConnectionParams\n\n\n" + "def build() -> McpToolset:\n" + ' return McpToolset(connection_params=SseConnectionParams(url="https://x"))\n' + ) + agent = ( + "from google.adk.agents import LlmAgent\nfrom helper import build\n\n" + 'root_agent = LlmAgent(name="planner", model="m", tools=[build()])\n' + ) + _established(_adk(tmp_path, {"agent.py": agent, "helper.py": helper})) + + +@pytest.mark.parametrize( + "reader", + [ + "print(build.__annotations__)\n", + "from typing import get_type_hints\nget_type_hints(build)\n", + "build.__annotations__['return'].get_tools = opaque\n", + ], + ids=["dictionary-read", "type-hints", "class-patched-through-it"], +) +def test_an_annotation_dictionary_that_is_read_refuses_the_eager_annotation(tmp_path, reader): + helper = ( + "from google.adk.tools.mcp_tool import McpToolset, SseConnectionParams\n\n\n" + "def build() -> McpToolset:\n" + ' return McpToolset(connection_params=SseConnectionParams(url="https://x"))\n' + ) + agent = ( + "from google.adk.agents import LlmAgent\nfrom helper import build\n\n" + + reader + + 'root_agent = LlmAgent(name="planner", model="m", tools=[build()])\n' + ) + _refused(_adk(tmp_path, {"agent.py": agent, "helper.py": helper})) + + +def test_a_decorated_function_does_not_take_the_eager_annotation(tmp_path): + helper = ( + "from google.adk.tools.mcp_tool import McpToolset, SseConnectionParams\n\n\n" + "@register\n" + "def build() -> McpToolset:\n" + ' return McpToolset(connection_params=SseConnectionParams(url="https://x"))\n' + ) + agent = ( + "from google.adk.agents import LlmAgent\nfrom helper import build\n\n" + 'root_agent = LlmAgent(name="planner", model="m", tools=[build()])\n' + ) + _refused(_adk(tmp_path, {"agent.py": agent, "helper.py": helper})) + + +def test_an_eager_annotation_in_a_class_body_or_variable_is_refused(tmp_path): + agent = ( + "from google.adk.agents import LlmAgent\nfrom google.adk.tools.mcp_tool import McpToolset\n\n" + "held: McpToolset = None\n" + 'root_agent = LlmAgent(name="planner", model="m", tools=[])\n' + ) + _refused(_adk(tmp_path, {"agent.py": agent})) + + +# -- OpenAI Agents SDK hosted tools, MCP servers, function_tool ------------------------ + +SDK = """from agents import Agent, WebSearchTool, function_tool +from agents.mcp import MCPServerStdio +{extra} +agent = Agent(name="assistant", tools={tools}, mcp_servers={servers}) +""" + + +def _sdk_files(extra: str = "", tools: str = "[WebSearchTool()]", servers: str = "[]") -> dict[str, str]: + return {"agent.py": SDK.format(extra=extra, tools=tools, servers=servers)} + + +def test_a_hosted_tool_called_in_the_list_is_established(tmp_path): + _established(_sdk(tmp_path, _sdk_files())) + + +def test_a_server_held_by_a_with_block_is_established(tmp_path): + source = ( + "from agents import Agent\nfrom agents.mcp import MCPServerStdio\n\n\n" + "async def run() -> None:\n" + ' async with MCPServerStdio(params={"command": "npx"}) as server:\n' + ' agent = Agent(name="assistant", mcp_servers=[server])\n' + " await agent.go()\n" + ) + _established(_sdk(tmp_path, {"agent.py": source})) + + +@pytest.mark.parametrize( + "body", + [ + " opaque(server)\n", + " await server.connect()\n", + " alias = server\n", + " server.params = {'command': 'bash'}\n", + ], + ids=["passed-opaquely", "method-called", "aliased", "attribute-stored"], +) +def test_a_server_used_beyond_the_receiving_list_is_refused(tmp_path, body): + source = ( + "from agents import Agent\nfrom agents.mcp import MCPServerStdio\n\n\n" + "async def run() -> None:\n" + ' async with MCPServerStdio(params={"command": "npx"}) as server:\n' + ' agent = Agent(name="assistant", mcp_servers=[server])\n' + + body + ) + _refused(_sdk(tmp_path, {"agent.py": source})) + + +def test_a_with_block_of_another_class_does_not_bind_the_object(tmp_path): + source = ( + "from agents import Agent\nfrom my_servers import Server\n\n\n" + "async def run() -> None:\n" + ' async with Server(params={"command": "npx"}) as server:\n' + ' agent = Agent(name="assistant", mcp_servers=[server])\n' + ) + observations = _sdk(tmp_path, {"agent.py": source}) + assert all(not item.object_bindings for item in observations) + + +@pytest.mark.parametrize( + "extra", + [ + "WebSearchTool = wrap(WebSearchTool)\n", + "WebSearchTool.__init__ = opaque\n", + "class WebSearchTool:\n pass\n", + "alias = WebSearchTool\n", + "held = WebSearchTool()\nopaque(held)\n", + ], + ids=["reassigned", "patched", "redefined", "aliased", "result-passed-opaquely"], +) +def test_a_hosted_tool_that_is_not_the_frameworks_or_is_handed_on_is_refused_or_unbound(tmp_path, extra): + observations = _sdk(tmp_path, _sdk_files(extra)) + assert all(not item.object_bindings for item in observations) or any(item.constructor_issues for item in observations) + assert not any(item.object_bindings and not item.constructor_issues for item in observations) + + +def test_a_vendored_agents_package_is_not_the_framework(tmp_path): + files = { + "agents/__init__.py": "class WebSearchTool:\n pass\n\n\nclass Agent:\n pass\n", + **_sdk_files(), + } + observations = _sdk(tmp_path, files) + assert not any(item.object_bindings and not item.constructor_issues for item in observations) + + +def test_an_installed_package_name_is_not_a_sibling_package_of_the_module(tmp_path): + # ``src/app/agents`` is reached as ``app.agents``: ``from agents import ...`` + # in a module beside it is the SDK. The module's own directory still counts. + source = ( + "from agents import Agent, function_tool\n\n\n@function_tool\ndef lookup(q: str) -> str:\n" + " return q\n\n\nagent = Agent(name='a', tools=[lookup])\n" + ) + sibling = {"app/__init__.py": "", "app/agents/__init__.py": "", "app/agents/main.py": source} + observations = _sdk(tmp_path, sibling, entry="app/agents/main.py") + assert observations and all(not item.constructor_issues for item in observations) + + +def test_a_local_agents_package_beside_the_script_is_not_the_sdk(tmp_path): + files = { + "agents/__init__.py": "class WebSearchTool:\n pass\n\n\nclass Agent:\n pass\n", + **_sdk_files(), + } + observations = _sdk(tmp_path, files) + assert not any(item.object_bindings and not item.constructor_issues for item in observations) + + +FUNCTION = """from agents import Agent, function_tool + + +def pay(amount: int) -> str: + return "ok" + +{extra} +agent = Agent(name="payer", tools={tools}) +""" + + +def test_a_wrapped_function_value_is_established(tmp_path): + source = FUNCTION.format(extra="pay_tool = function_tool(pay, needs_approval=True)\n", tools="[pay_tool]") + _established_or_function(_sdk(tmp_path, {"agent.py": source})) + + +def _established_or_function(observations: list) -> None: + assert observations + assert all(not item.constructor_issues for item in observations), [item.constructor_issues for item in observations] + + +@pytest.mark.parametrize( + "extra", + [ + "pay_tool = function_tool(pay)\nopaque(pay_tool)\n", + "pay_tool = function_tool(pay)\nalias = pay_tool\n", + "pay_tool = function_tool(pay, failure_error_function=pay)\n", + "pay_tool = function_tool(pay, **options)\n", + "pay_tool = function_tool(*args)\n", + "function_tool = wrap(function_tool)\npay_tool = function_tool(pay)\n", + ], + ids=["passed-opaquely", "aliased", "callable-option", "spread-options", "spread-operand", "reassigned"], +) +def test_a_wrapper_that_is_not_the_documented_call_is_refused(tmp_path, extra): + source = FUNCTION.format(extra=extra, tools="[pay_tool]" if "pay_tool" in extra else "[]") + _refused(_sdk(tmp_path, {"agent.py": source})) + + +# -- agent exposed as a tool, across a module and a helper ---------------------------- + +EXPERT = 'from agents import Agent\n\nexpert = Agent(name="Expert", instructions="Write.")\n' +LEAD = """from agents import Agent + +from experts import expert +{extra} +lead = Agent(name="Lead", instructions="Lead.", tools=[expert.as_tool(tool_name="ask", tool_description="Ask.")]) +""" + + +def test_an_imported_agent_exposed_as_a_tool_is_established(tmp_path): + _established(_sdk(tmp_path, {"experts.py": EXPERT, "agent.py": LEAD.format(extra="")})) + + +@pytest.mark.parametrize( + "extra", + [ + "expert.tools.append(opaque)\n", + "expert.tools = [opaque]\n", + "opaque(expert)\n", + "alias = expert\n", + "held = expert.as_tool(tool_name='a', tool_description='b')\nopaque(held)\n", + "expert.__init__(tools=[opaque])\n", + "value = expert.get_system_prompt()\n", + "expert.model_post_init(None)\n", + ], + ids=[ + "tools-appended", "tools-replaced", "passed-opaquely", "aliased", "tool-passed-opaquely", + "reinitialized", "result-used", "validation-hook", + ], +) +def test_an_exported_agent_changed_or_handed_on_in_the_importer_is_refused(tmp_path, extra): + _refused(_sdk(tmp_path, {"experts.py": EXPERT, "agent.py": LEAD.format(extra=extra)})) + + +def test_an_exported_agent_nothing_reads_is_not_established(tmp_path): + # The agent left its module and no use of it was read: nothing is proved. + lead = ( + "from agents import Agent\n\nfrom experts import expert\n\n" + 'lead = Agent(name="Lead", instructions="Lead.", tools=[])\n' + ) + _refused(_sdk(tmp_path, {"experts.py": EXPERT, "agent.py": lead}, entry="experts.py")) + + +def test_an_agent_imported_with_a_wildcard_or_module_is_refused(tmp_path): + lead = LEAD.replace("from experts import expert", "import experts\nexpert = experts.expert") + _refused(_sdk(tmp_path, {"experts.py": EXPERT, "agent.py": lead.format(extra="")})) + + +def test_a_helper_that_returns_a_tool_from_its_parameter_is_established(tmp_path): + helper = ( + "from agents import Agent\n\n\n" + "def expose(agent: Agent, *, name: str):\n" + " return agent.as_tool(tool_name=name, tool_description='Ask.')\n" + ) + lead = ( + "from agents import Agent\n\nfrom experts import expert\nfrom helper import expose\n\n" + 'lead = Agent(name="Lead", instructions="Lead.", tools=[expose(expert, name="ask")])\n' + ) + _established(_sdk(tmp_path, {"experts.py": EXPERT, "helper.py": helper, "agent.py": lead})) + + +def test_a_helper_that_does_more_with_its_parameter_is_refused(tmp_path): + helper = ( + "from agents import Agent\n\n\n" + "def expose(agent: Agent, *, name: str):\n" + " agent.tools.append(opaque)\n" + " return agent.as_tool(tool_name=name, tool_description='Ask.')\n" + ) + lead = ( + "from agents import Agent\n\nfrom experts import expert\nfrom helper import expose\n\n" + 'lead = Agent(name="Lead", instructions="Lead.", tools=[expose(expert, name="ask")])\n' + ) + _refused(_sdk(tmp_path, {"experts.py": EXPERT, "helper.py": helper, "agent.py": lead})) + + +# -- standard-library reads inside a tool body or a settings class ---------------------- + +BODY = """import os +from agents import Agent, function_tool +{imports} + +@function_tool +def act(city: str) -> str: + token = {read} + return token + + +agent = Agent(name="a", tools=[act]) +""" + + +def _body(read: str, imports: str = "") -> dict[str, str]: + return {"agent.py": BODY.format(read=read, imports=imports)} + + +def test_reading_one_environment_value_in_a_tool_body_is_established(tmp_path): + observations = _sdk(tmp_path, _body('os.environ["TOKEN"]')) + assert observations and all(not item.constructor_issues for item in observations) + + +@pytest.mark.parametrize( + "read", + [ + 'os.environ.update(opaque=1)', + 'os.environ', + 'opaque(os.environ)', + 'dict(os.environ)', + '(os.environ.__setitem__("A", "b"))', + ], + ids=["update-called", "mapping-held", "mapping-passed", "mapping-copied", "dunder-called"], +) +def test_the_environment_mapping_itself_is_not_a_read_of_one_value(tmp_path, read): + # Only ``os.environ[key]`` loaded is a string; the mapping going anywhere, + # or a method of it, is the same unread-handle refusal as before. + observations = _sdk(tmp_path, _body(read)) + if read in {"os.environ.update(opaque=1)", '(os.environ.__setitem__("A", "b"))'}: + return # A call on the mapping keeps its existing ordinary-callee boundary. + _refused(observations) + + +def test_storing_into_the_environment_is_refused(tmp_path): + source = BODY.format(read='"x"', imports='os.environ["A"] = "b"') + _refused(_sdk(tmp_path, {"agent.py": source})) + + +@pytest.mark.parametrize( + "imports", + [ + "import environ_shim as os", + "from compat import os", + ], + ids=["another-module-named-os", "os-from-another-package"], +) +def test_a_name_that_is_not_the_standard_library_is_not_trusted(tmp_path, imports): + source = BODY.replace("import os\n", "").format(read='os.environ["TOKEN"]', imports=imports) + _refused(_sdk(tmp_path, {"agent.py": source})) + + +def test_a_name_rebound_after_the_import_is_not_trusted(tmp_path): + source = BODY.format(read='os.environ["TOKEN"]', imports="os = wrap(os)") + _refused(_sdk(tmp_path, {"agent.py": source})) + + +SETTINGS = """import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Settings: + url: str = {value} + + +settings = Settings() +""" + +SETTINGS_AGENT = """from google.adk.agents import LlmAgent +from google.adk.tools.mcp_tool import McpToolset, SseConnectionParams + +from site_settings import settings +EXTRA +toolset = McpToolset(connection_params=SseConnectionParams(url=settings.url)) +root_agent = LlmAgent(name="planner", model="m", tools=[toolset]) +""" + + +def test_a_frozen_dataclass_of_environment_defaults_is_established(tmp_path): + files = {"site_settings.py": SETTINGS.format(value='os.getenv("URL", "https://x")'), "agent.py": SETTINGS_AGENT.replace("EXTRA", "")} + _established(_adk(tmp_path, files)) + + +@pytest.mark.parametrize( + "value", + ["opaque()", "os.getenv(KEY)", "os.getenv('A', default=opaque())", "lambda: 1", "[x for x in range(3)]"], + ids=["a-call", "a-computed-key", "a-call-default", "a-lambda", "a-comprehension"], +) +def test_a_class_body_that_is_not_inert_data_is_refused(tmp_path, value): + files = {"site_settings.py": SETTINGS.format(value=value), "agent.py": SETTINGS_AGENT.replace("EXTRA", "")} + _refused(_adk(tmp_path, files)) + + +@pytest.mark.parametrize( + "extra", + [ + "opaque(settings)", + "alias = settings", + "copy = replace(settings, url='x')", + "settings.url()", + "held = [settings]", + "registry = {'settings': settings}", + "opaque(settings.__class__)", + ], + ids=["passed-opaquely", "aliased", "copied", "method-called", "held-in-a-list", "held-in-a-dict", "class-passed"], +) +def test_an_inert_instance_handed_on_or_called_is_refused(tmp_path, extra): + files = { + "site_settings.py": SETTINGS.format(value='os.getenv("URL", "https://x")'), + "agent.py": SETTINGS_AGENT.replace("EXTRA", "from dataclasses import replace\n" + extra), + } + _refused(_adk(tmp_path, files)) + + +def test_an_inert_instance_handed_on_inside_its_own_module_is_refused(tmp_path): + settings = SETTINGS.format(value='os.getenv("URL", "https://x")') + "opaque(settings)\n" + _refused(_adk(tmp_path, {"site_settings.py": settings, "agent.py": SETTINGS_AGENT.replace("EXTRA", "")})) + + +def test_a_class_decorator_other_than_the_standard_dataclass_is_refused(tmp_path): + settings = SETTINGS.replace("from dataclasses import dataclass", "from mylib import dataclass").format( + value='"https://x"' + ) + _refused(_adk(tmp_path, {"site_settings.py": settings, "agent.py": SETTINGS_AGENT.replace("EXTRA", "")})) + + +def test_a_local_dataclasses_module_is_not_the_standard_library(tmp_path): + files = { + "dataclasses.py": "def dataclass(*args, **kwargs):\n return lambda cls: cls\n", + "site_settings.py": SETTINGS.format(value='"https://x"'), + "agent.py": SETTINGS_AGENT.replace("EXTRA", ""), + } + _refused(_adk(tmp_path, files)) + + +def test_a_dataclass_with_a_method_or_a_base_is_not_an_inert_instance(tmp_path): + for body in ( + "class Settings(Base):\n url: str = 'x'\n", + "class Settings:\n url: str = 'x'\n\n def __post_init__(self):\n opaque(self)\n", + ): + root = tmp_path / str(abs(hash(body))) + root.mkdir() + settings = "from dataclasses import dataclass\n\n\n@dataclass(frozen=True)\n" + body + "\n\nsettings = Settings()\n" + _refused(_adk(root, {"site_settings.py": settings, "agent.py": SETTINGS_AGENT.replace("EXTRA", "")})) + + +# -- a method named like the builder, on an instance of the framework's own class ----- + + +CENSUS = """from agents import Agent + + +def run() -> None: + assistant = Agent(name="assistant", tools=[]) + {call} + + +run() +""" + + +def test_a_method_on_the_agent_instance_is_not_the_function_of_that_name(tmp_path): + _established_or_function(_sdk(tmp_path, {"agent.py": CENSUS.format(call="assistant.run()")})) + + +@pytest.mark.parametrize( + "call", + [ + "x = assistant.run()", + "assistant.__init__(tools=[opaque])", + "assistant.model_post_init(None)", + "opaque(assistant.run())", + ], + ids=["result-used", "reinitialized", "validation-hook", "result-passed"], +) +def test_other_uses_of_the_agent_instance_are_refused(tmp_path, call): + _refused(_sdk(tmp_path, {"agent.py": CENSUS.format(call=call)})) + + +def test_a_name_bound_to_something_else_is_not_an_agent_instance(tmp_path): + source = ( + "from agents import Agent\nimport importlib\n\n\n" + "def run() -> None:\n" + " assistant = importlib.import_module('agent')\n" + " assistant.run()\n\n\n" + 'agent = Agent(name="a", tools=[])\nrun()\n' + ) + _refused(_sdk(tmp_path, {"agent.py": source})) diff --git a/tests/test_python_import_resolution.py b/tests/test_python_import_resolution.py index ae75c7437..cfd884857 100644 --- a/tests/test_python_import_resolution.py +++ b/tests/test_python_import_resolution.py @@ -313,3 +313,81 @@ def test_resolution_never_executes_the_inspected_code(tmp_path): assert _definition(_resolve(tmp_path, "agent.py", "tool")) == "tools.py:4" assert not marker.exists() assert "tools" not in set(sys.modules) - before + + +@pytest.mark.parametrize("resolved_before_entry", [False, True]) +def test_entry_preserves_canonical_scanned_module_and_function_identity(tmp_path, resolved_before_entry): + path = tmp_path / 'factory.py' + text = 'def build(tools):\n return tools\n' + path.write_text(text) + resolver = ImportResolver(tmp_path) + scanned = resolver._patch_scan(path) + function = scanned.tree.body[0] + if resolved_before_entry: + assert resolver.module(path) is scanned + parsed_before = resolver._parsed + fresh_tree = ast.parse(text) + assert fresh_tree is not scanned.tree + registered = resolver.entry(path, fresh_tree, text) + assert registered is scanned and registered.tree.body[0] is function + assert resolver._scanned[path] is registered and resolver._modules[path] is registered + assert resolver.module(path) is registered and resolver._patch_scan(path) is registered + assert resolver._parsed == parsed_before + + +def test_entry_does_not_reuse_scanned_identity_for_different_captured_text(tmp_path): + path = tmp_path / 'factory.py' + old_text = 'def build(tools):\n return tools\n' + new_text = 'def replacement(tools):\n return tools\n' + path.write_text(old_text) + resolver = ImportResolver(tmp_path) + scanned = resolver._patch_scan(path) + path.write_text(new_text) + tree = ast.parse(new_text) + registered = resolver.entry(path, tree, new_text) + assert registered is not scanned and registered.tree is tree + assert registered.text == new_text and registered.tree.body[0].name == 'replacement' + assert resolver._scanned[path] is scanned + assert resolver._patch_scan(path) is registered + + +@pytest.mark.parametrize("stop_cache", ['_modules', '_scanned']) +def test_entry_preserves_existing_external_parse_behavior_after_cached_stop(tmp_path, stop_cache): + path = tmp_path / 'factory.py' + text = 'def build(tools):\n return tools\n' + path.write_text(text) + resolver = ImportResolver(tmp_path) + if stop_cache == '_modules': + old = resolver._patch_scan(path) + resolver._modules[path] = imports._Stop(imports.RESOLUTION_LIMIT, 'read budget exhausted') + else: + old = None + resolver._scanned[path] = imports._Stop(imports.UNREADABLE_MODULE, 'earlier input unread') + tree = ast.parse(text) + registered = resolver.entry(path, tree, text) + assert registered is not old and registered.tree is tree + assert registered.text == text and resolver._parsed == 0 + + +def test_scanned_resolution_promotion_without_entry_still_enforces_its_budget(tmp_path, monkeypatch): + path = tmp_path / 'factory.py' + text = 'def build(tools):\n return tools\n' + path.write_text(text) + resolver = ImportResolver(tmp_path) + scanned = resolver._patch_scan(path) + monkeypatch.setattr(imports, 'MAX_MODULES', 0) + with pytest.raises(imports._Stop) as error: + resolver.module(path) + assert error.value.reason == imports.RESOLUTION_LIMIT + assert resolver._parsed == 0 and resolver._scanned[path] is scanned and path not in resolver._modules + + +def test_entry_cannot_promote_a_scanned_module_outside_its_scope(tmp_path): + inside = tmp_path / 'inside' + inside.mkdir() + path = tmp_path / 'outside.py' + text = 'def build(tools):\n return tools\n' + path.write_text(text) + resolver = ImportResolver(inside) + assert resolver.entry(path, ast.parse(text), text) is None + assert path not in resolver._modules and path not in resolver._scanned diff --git a/tests/test_release_decision.py b/tests/test_release_decision.py index 96dcdac5d..06914def9 100644 --- a/tests/test_release_decision.py +++ b/tests/test_release_decision.py @@ -1145,3 +1145,67 @@ def test_evidence_gaps_deterministic_ordering(): "alpha [langchain_function]", "zeta [langchain_function]", ] + + + +def test_requested_incomplete_binding_comparison_is_a_gap_even_without_tool_ids(): + from agents_shipgate.schemas.bindings import BindingSurfaceDiff + + report = _report(tools=[], summary_status="no_release_blockers_detected") + report.binding_surface_diff = BindingSurfaceDiff( + enabled=False, base_comparison_requested=True, + notes=["Capability comparison incomplete on base: partial_binding_evidence."], + ) + decision = _build(report, ci_mode="advisory", tools=[]) + assert decision.decision == "insufficient_evidence" + gaps = decision.evidence_coverage.evidence_gaps + comparison = [gap for gap in gaps if gap.source_ref == "--diff-from"] + assert len(comparison) == 1 + assert comparison[0].next_action.kind == "provide_source" + assert comparison[0].next_action.command is None + assert "head or base" in comparison[0].why + + +def test_an_enabled_tool_comparison_that_withheld_absence_is_still_an_incomplete_comparison_gap(): + """The tool comparison now reports additions while a graph is incomplete. + + Its being enabled must not read as "the comparison was established": its + notes (and the binding diff's) carry the limit, and the gap still stands. + """ + from agents_shipgate.core.binding_comparison import binding_comparison_limits + from agents_shipgate.schemas.bindings import ( + AgentBindingGraphAssessment, + AgentBindingIssue, + BindingSurfaceDiff, + ) + from agents_shipgate.schemas.surfaces import ToolSurfaceDiff + + partial = AgentBindingGraphAssessment(status="partial", issues=[AgentBindingIssue( + kind="partial_binding_evidence", message="Unread constructor", + source="framework_constructor_ownership", source_pointer="agent.py:8", + )]) + note = binding_comparison_limits(partial, None, absence_only=True) + whole = binding_comparison_limits(partial, None) + assert note and whole and note != whole + + for binding_notes, tool_notes in ((whole, note), (whole, []), ([], note)): + report = _report(tools=[], summary_status="no_release_blockers_detected") + report.binding_surface_diff = BindingSurfaceDiff( + enabled=False, base_comparison_requested=True, notes=binding_notes, + ) + report.tool_surface_diff = ToolSurfaceDiff(enabled=True, notes=tool_notes) + decision = _build(report, ci_mode="advisory", tools=[]) + gaps = decision.evidence_coverage.evidence_gaps + assert decision.decision == "insufficient_evidence" + assert len([gap for gap in gaps if gap.source_ref == "--diff-from"]) == 1 + + # No marker anywhere: an enabled tool comparison with a disabled binding + # diff (a base predating binding facts) keeps its earlier routing. + report = _report(tools=[], summary_status="no_release_blockers_detected") + report.binding_surface_diff = BindingSurfaceDiff( + enabled=False, base_comparison_requested=True, notes=["Binding diff requires a report_schema_version 0.31 base report."], + ) + report.tool_surface_diff = ToolSurfaceDiff(enabled=True) + decision = _build(report, ci_mode="advisory", tools=[]) + assert not [gap for gap in decision.evidence_coverage.evidence_gaps if gap.source_ref == "--diff-from"] + diff --git a/tests/test_returned_agent_lists.py b/tests/test_returned_agent_lists.py new file mode 100644 index 000000000..8691d2ea1 --- /dev/null +++ b/tests/test_returned_agent_lists.py @@ -0,0 +1,646 @@ +"""Paired caller/factory acceptance prepared for the next #874 increment.""" +import pytest + +from tests.test_builder_bindings import _files, _read +from tests.test_imported_tool_bindings import _write + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('returned,selected', [ + ('[read, write]', 'make_tools(runtime_db())'), + ('(read, write)', 'make_tools(runtime_db())'), + ("{'calendar': [read], 'email': [write]}", "groups['calendar'] + groups['email']"), + ("{'calendar': [read], 'email': [write]}", "groups.get('calendar') + groups.get('email')"), +]) +def test_returned_lists_and_literal_dictionary_projection(tmp_path, framework, returned, selected): + files = _files(framework, 'groups = make_tools(runtime_db())\na = build(' + selected + ')\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read, write\ndef make_tools(db):\n return ' + returned + '\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings == [] + (observed,) = observations + assert observed.tools_complete and observed.tool_names == ['read', 'write'] + assert any('app.py:' in site for sites in observed.tool_sites.values() for site in sites) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('mutation', [ + "groups['calendar'].append(write)", + "groups.get('calendar').append(write)", + "alias = groups['calendar']\nalias.clear()", + "consume(groups)", + "consume(groups['calendar'])", + "groups['calendar'] = [write]", +]) +def test_factory_projection_retains_mutation_and_escape_limits(tmp_path, framework, mutation): + files = _files(framework, "groups = make_tools(runtime_db())\na = build(groups['calendar'])\n" + mutation + '\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = "from tools import read\ndef make_tools(db):\n return {'calendar': [read]}\n" + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and observations + assert all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('expression', ["groups['missing']", "groups[runtime_key()]", "groups.get(runtime_key())"]) +def test_missing_or_dynamic_projection_is_not_a_complete_empty_list(tmp_path, framework, expression): + files = _files(framework, "groups = make_tools(runtime_db())\na = build(" + expression + ')\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = "from tools import read\ndef make_tools(db):\n return {'calendar': [read]}\n" + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and observations + assert all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('escape', [ + 'make_tools.__code__ = replacement.__code__', + 'consume(make_tools)', + 'callback = make_tools\nconsume(callback)', +]) +def test_changed_or_escaped_factory_callable_is_not_established(tmp_path, framework, escape): + files = _files(framework, "a = build(make_tools())\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = "from tools import read, write\ndef make_tools():\n return [read]\ndef replacement():\n return [write]\n" + escape + '\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('other', [ + 'consume(make_tools())', + 'other = make_tools()\nconsume(other)', + "other = make_tools()\nother[0].__name__ = 'renamed'", +]) +def test_every_factory_result_retains_shared_callable_ownership(tmp_path, framework, other): + files = _files(framework, other + '\na = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +def test_unrelated_clean_factory_calls_keep_their_own_sites(tmp_path, framework): + files = _files(framework, 'a = build(make_tools())\nb = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings == [] + (observation,) = observations + assert observation.tools_complete and observation.tool_names == ['read'] + assert {'app.py:5', 'app.py:6'} <= set(observation.tool_sites['read']) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('body', [ + " read.__name__ = 'renamed'\n return {'calendar': [read]}", + " consume(read)\n return {'calendar': [read]}", + " return {'calendar': [read], 'other': consume(read)}", +]) +def test_factory_cannot_change_or_escape_a_projected_callable(tmp_path, framework, body): + files = _files(framework, "groups = make_tools()\na = build(groups['calendar'])\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n' + body + '\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('mutation', [ + "for bucket in groups.values():\n bucket.clear()", + "for key, bucket in groups.items():\n bucket.clear()", + "groups.copy()['calendar'].clear()", + "alias = groups.copy()\nalias['calendar'].clear()", + "alias = groups | {}\nalias['calendar'].clear()", + "consume(groups.values())", + "consume(groups.items())", +]) +def test_dictionary_views_and_shallow_copies_retain_mutable_values(tmp_path, framework, mutation): + files = _files(framework, "groups = make_tools()\na = build(groups['calendar'])\n" + mutation + '\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = "from tools import read\ndef make_tools():\n return {'calendar': [read]}\n" + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('mutation', ["GROUPS['calendar'].clear()", "alias = GROUPS['calendar']\nalias.clear()"]) +def test_imported_module_dictionary_projection_remains_explicitly_unread(tmp_path, framework, mutation): + files = _files(framework, "from registry import GROUPS\na = build(GROUPS['calendar'])\n") + files['registry.py'] = "from tools import read\nGROUPS = {'calendar': [read]}\n" + mutation + '\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +def test_equivalent_factory_refactor_does_not_repeat_the_caller_condition(tmp_path, framework): + files = _files(framework, "if chosen:\n a = build(make_tools())\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings == [] + (observation,) = observations + assert observation.tool_conditions['read'] == ["the caller's condition `chosen` holds"] + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('selected,expected', [ + ("groups.get('calendar', [write])", ['read']), + ("groups.get('missing', [write])", ['write']), + ("groups.get('missing')", []), +]) +def test_dictionary_get_default_belongs_to_its_caller(tmp_path, framework, selected, expected): + files = _files(framework, 'groups = make_tools()\na = build(' + selected + ')\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = "from tools import read\ndef make_tools():\n return {'calendar': [read]}\n" + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings == [] + if framework == 'adk' and not expected: + assert observations == [] # Complete empty ADK bindings are omitted by convention. + else: + (observation,) = observations + assert observation.tools_complete and observation.tool_names == expected + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('call', ["mutate(groups)", "groups.get('calendar', mutate(groups))"]) +def test_a_helpers_legacy_read_proof_cannot_hide_projected_dictionary_mutation(tmp_path, framework, call): + files = _files(framework, "def mutate(groups):\n groups['calendar'].clear()\ngroups = make_tools()\n" + call + "\na = build(groups['calendar'])\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = "from tools import read\ndef make_tools():\n return {'calendar': [read]}\n" + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +def test_a_list_helper_cannot_rename_a_retained_callable_through_indexing(tmp_path, framework): + files = _files(framework, "def mutate(tools):\n tools[0].__name__ = 'renamed'\ngroup = make_tools()\nmutate(group)\na = build(group)\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +def test_invoking_a_returned_callable_cannot_prove_its_identity_unchanged(tmp_path, framework): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n read()\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('argument', ["groups or {}", "groups if wanted else {}"]) +def test_wrappers_preserve_dictionary_retention_uncertainty(tmp_path, framework, argument): + files = _files(framework, "def mutate(groups):\n groups['calendar'].clear()\ngroups = make_tools()\nmutate(" + argument + ")\na = build(groups['calendar'])\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = "from tools import read\ndef make_tools():\n return {'calendar': [read]}\n" + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('escape', [ + 'mutate(group or [])', + 'mutate(group if wanted else [])', + 'mutate(group + [])', + 'mutate(group.copy())', + 'mutate(*group)', + "alias = group.copy()\nalias[0].__name__ = 'renamed'", + "alias = [*group]\nalias[0].__name__ = 'renamed'", + 'leave_alone.__code__ = mutate.__code__\nleave_alone(group)', + "a.tools[0].__name__ = 'renamed'", +]) +def test_retained_callable_identity_survives_no_copy_or_helper_escape(tmp_path, framework, escape): + files = _files(framework, "def mutate(tools):\n tools[0].__name__ = 'renamed'\ndef leave_alone(tools):\n pass\ngroup = make_tools()\na = build(group)\n" + escape + '\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +def test_present_dictionary_keys_do_not_skip_an_executable_default(tmp_path, framework): + files = _files(framework, "def rename(tool):\n tool.__name__ = 'renamed'\n return []\ngroups = make_tools()\na = build(groups.get('calendar', rename(read)))\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = "from tools import read\ndef make_tools():\n return {'calendar': [read]}\n" + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('mutation', [ + " write.__name__ = 'renamed'\n", + " alias.__name__ = 'renamed'\n", + " consume(write)\n", +]) +def test_a_factory_cannot_change_the_callable_in_a_selected_caller_default(tmp_path, framework, mutation): + files = _files(framework, "groups = make_tools()\na = build(groups.get('missing', [write]))\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = "from tools import read, write\nfrom tools import write as alias\ndef make_tools():\n" + mutation + " return {'calendar': [read]}\n" + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('mutation', [ + " alias.__name__ = 'renamed'\n", + " consume(alias)\n", + " alias()\n", + " namespace.read.__name__ = 'renamed'\n", + " consume(namespace)\n", +]) +def test_factory_callable_ownership_uses_import_identity_not_spelling(tmp_path, framework, mutation): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\nfrom tools import read as alias\nimport tools as namespace\ndef make_tools():\n' + mutation + ' return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('effect', [ + "rename()\n return [read]", + "return {'calendar': [read], 'other': rename()}", +]) +def test_executable_factory_preludes_and_unselected_values_remain_unread(tmp_path, framework, effect): + selected = "groups['calendar']" if 'calendar' in effect else 'groups' + files = _files(framework, f'groups = make_tools()\na = build({selected})\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + field = 'name' if framework == 'sdk' else '__name__' + files['factory.py'] = f"from tools import read\ndef rename():\n read.{field} = 'renamed'\ndef make_tools():\n " + effect + '\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('expression', ["getattr(bridge, 'lib').read", "getattr(bridge.lib, 'read')", 'consume(bridge)']) +def test_computed_or_opaque_bridge_namespace_cannot_hide_returned_callable_change(tmp_path, framework, expression): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['bridge.py'] = 'import tools as lib\n' + field = 'name' if framework == 'sdk' else '__name__' + action = expression if expression.startswith('consume') else f"{expression}.{field} = 'renamed'" + files['factory.py'] = 'from tools import read\nimport bridge\ndef make_tools():\n ' + action + '\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('site', ['app.py', 'sibling.py']) +@pytest.mark.parametrize('action', ["alias.name = 'renamed'", 'rename(alias)', 'group.count(rename(alias))', 'group.index(rename(alias))']) +def test_caller_and_sibling_aliases_share_the_returned_callable(tmp_path, framework, site, action): + files = _files(framework, 'group = make_tools()\na = build(group)\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + field = 'name' if framework == 'sdk' else '__name__' + source = f"from tools import read as alias\ndef rename(tool):\n tool.{field} = 'renamed'\n" + action.replace('alias.name', f'alias.{field}') + '\n' + files[site] = files.get(site, '') + source + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('method', ['count', 'index']) +def test_opaque_comparison_can_receive_each_returned_callable(tmp_path, framework, method): + field = 'name' if framework == 'sdk' else '__name__' + files = _files(framework, f"class Mutator:\n def __eq__(self, tool):\n tool.{field} = 'renamed'\n return True\ngroup = make_tools()\ngroup.{method}(Mutator())\na = build(group)\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('operation', ['group == Mutator()', 'group in Mutator()', 'Mutator() in group', 'group + Mutator()', 'isinstance(group, Mutator)']) +def test_opaque_protocols_cannot_receive_returned_lists_or_members(tmp_path, framework, operation): + field = 'name' if framework == 'sdk' else '__name__' + equality = f"value.{field} = 'renamed'" if operation == 'Mutator() in group' else 'value.clear()' + source = f"class Meta(type):\n def __instancecheck__(cls, group):\n group.clear()\n return False\nclass Mutator(metaclass=Meta):\n def __eq__(self, value):\n {equality}\n return True\n def __contains__(self, group):\n group.clear()\n return False\n def __radd__(self, group):\n group.clear()\n return []\ngroup = make_tools()\n{operation}\na = build(group)\n" + files = _files(framework, source) + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('replacement', ['namespace.read = write', 'del namespace.read', 'bridge.lib.read = write']) +def test_replacing_a_callable_before_the_factory_import_is_not_established(tmp_path, framework, replacement): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nimport tools as namespace\nimport bridge\n' + replacement + '\nfrom factory import make_tools') + files['bridge.py'] = 'import tools as lib\n' + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('prelude', ['raise RuntimeError', 'assert False', 'while True:\n pass', 'db + 1']) +def test_inert_factory_allowlist_does_not_equate_absent_call_with_nonexecution(tmp_path, framework, prelude): + files = _files(framework, 'a = build(make_tools(runtime_db()))\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools(db):\n ' + prelude + '\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +def test_conditional_rebinding_cannot_hide_an_imported_callable_alias(tmp_path, framework): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + field = 'name' if framework == 'sdk' else '__name__' + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + files['sibling.py'] = f"from tools import read as alias\nif chosen:\n alias.{field} = 'renamed'\nelse:\n alias = 3\n" + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +def test_identity_tests_literal_copies_and_inert_preludes_are_readable(tmp_path, framework): + files = _files(framework, 'group = make_tools()\ngroup is None\ngroup is not None\nlen(group)\na = build(group + [write])\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'def make_tools():\n """Build tools."""\n from tools import read\n pass\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings == [] + (observation,) = observations + assert observation.tools_complete and observation.tool_names == ['read', 'write'] + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('change', ['empty.__code__ = changed.__code__', 'consume(empty)']) +def test_add_shape_proof_requires_the_same_callable_census(tmp_path, framework, change): + files = _files(framework, "def empty():\n return []\nclass Mutator:\n def __radd__(self, group):\n group.clear()\n return []\ndef changed():\n return Mutator()\n" + change + '\ngroup = make_tools()\ngroup + empty()\na = build(group)\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('machine', ['eval', 'exec', 'alias', 'builtins_dict']) +def test_executable_text_in_a_borrower_cannot_establish_callable_ownership(tmp_path, framework, machine): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + field = 'name' if framework == 'sdk' else '__name__' + statement = f"eval('read').{field} = 'renamed'" if machine == 'eval' else f'exec("read.{field}=\'renamed\'")' + if machine == 'builtins_dict': + statement = statement.replace('exec(', "__builtins__['exec'](") + if machine == 'alias': + statement = 'from builtins import exec as execute\n' + statement.replace('exec(', 'execute(') + files['sibling.py'] = 'from tools import read\n' + statement + '\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('other', ['not_defined', 'write']) +def test_every_eager_dictionary_member_needs_an_established_binding(tmp_path, framework, other): + files = _files(framework, "groups = make_tools()\na = build(groups['calendar'])\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + prefix = 'from tools import read, write\ndel write\n' if other == 'write' else 'from tools import read\n' + files['factory.py'] = prefix + "def make_tools():\n return {'calendar': [read], 'other': [" + other + ']}\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('source', ["others = {'calendar': []}", "def other():\n return {'calendar': []}\nothers = other()"]) +def test_add_shape_cannot_borrow_an_unowned_retained_dictionary_projection(tmp_path, framework, source): + files = _files(framework, "class Mutator:\n def __radd__(self, group):\n group.clear()\n return []\n" + source + "\nothers['calendar'] = Mutator()\ngroup = make_tools()\ngroup + others['calendar']\na = build(group)\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('machine', ['eval', 'exec']) +@pytest.mark.parametrize('imported', ['from machine import execute', 'import machine as carrier', 'from machine import *']) +def test_reexported_execution_machinery_is_read_through_existing_import_evidence(tmp_path, framework, machine, imported): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + files['machine.py'] = f'from builtins import {machine} as execute\n' + field = 'name' if framework == 'sdk' else '__name__' + execute = 'carrier.execute' if 'carrier' in imported else 'execute' + statement = f"{execute}('read').{field} = 'renamed'" if machine == 'eval' else f'{execute}("read.{field}=\'renamed\'")' + files['sibling.py'] = 'from tools import read\n' + imported + '\n' + statement + '\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('route', [ + # ``__all__`` lists the alias, and a star import takes exactly that list. + ({'machine.py': "from builtins import eval as execute\n__all__ = ['execute']\n"}, 'from machine import *'), + # A third module re-exports the unused-here alias; its importer reaches it. + ({'machine.py': 'from builtins import eval as execute\n', 'bridge.py': 'from machine import execute\n'}, + 'from bridge import execute'), + ({'machine.py': 'from builtins import eval as execute\n', 'bridge.py': 'import machine\nexecute = machine.execute\n'}, + 'from bridge import execute'), + ({'machine.py': 'from builtins import eval as execute\n', 'bridge.py': 'from machine import *\n'}, + 'from bridge import execute'), +]) +def test_unused_here_execution_alias_is_not_discharged_while_any_module_imports_it(tmp_path, framework, route): + extra, imported = route + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + files.update(extra) + field = 'name' if framework == 'sdk' else '__name__' + files['sibling.py'] = 'from tools import read\n' + imported + f"\nexecute('read').{field} = 'renamed'\n" + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('anchor_module', ['tools', 'registry']) +@pytest.mark.parametrize('expression', ['anchor.__globals__', "object.__getattribute__(anchor, '__globals__')", 'rename(anchor)']) +def test_another_function_can_retain_the_returned_callable_namespace(tmp_path, framework, anchor_module, expression): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + field = 'name' if framework == 'sdk' else '__name__' + files[anchor_module + '.py'] = files.get(anchor_module + '.py', 'from tools import read\n') + 'def anchor():\n return None\n' + files['consumer.py'] = f"def rename(holder):\n holder.__globals__['r'+'ead'].{field} = 'renamed'\n" + action = expression if expression.startswith('rename') else f"{expression}['r'+'ead'].{field} = 'renamed'" + files['sibling.py'] = f'from {anchor_module} import anchor\nfrom consumer import rename\n' + action + '\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +def test_function_namespaces_retain_subject_through_more_than_one_module(tmp_path, framework): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + field = 'name' if framework == 'sdk' else '__name__' + files['registry.py'] = 'from tools import read\ndef anchor():\n return None\n' + files['bridge.py'] = 'from registry import anchor\ndef carrier():\n return None\n' + files['sibling.py'] = 'from bridge import carrier\nfrom consumer import rename\nrename(carrier)\n' + files['consumer.py'] = f"def rename(holder):\n holder.__globals__['anchor'].__globals__['r'+'ead'].{field} = 'renamed'\n" + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('dictionary', [False, True]) +def test_application_diff_factory_addition_retains_construction_and_definition(tmp_path, framework, dictionary): + from tests.test_imported_tool_bindings import _commit, _compare, _git + + selected = "groups['calendar']" if dictionary else 'groups' + files = _files(framework, f'groups = make_tools()\na = build({selected})\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + returned = "{'calendar': [read]}" if dictionary else '[read]' + files['factory.py'] = 'from tools import read, write\ndef make_tools():\n return ' + returned + '\n' + _git(tmp_path, 'init', '-q', '-b', 'main') + base = _commit(tmp_path, files) + head = _commit(tmp_path, {'factory.py': files['factory.py'].replace('[read]', '[read, write]')}) + result = _compare(tmp_path, base, head, '--scope', '.') + row = next(row for row in result['rows'] if row['agent'] == 'Built' and row['tool'] == 'write') + assert row['change'] == 'added' + assert {'app.py:5', 'app.py:6', 'builders.py:3'} <= set(row['after']['construction_sites']) + assert row['after']['definition']['source'] == 'tools.py' + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('dictionary', [False, True]) +@pytest.mark.parametrize('retention', ['comparison', 'different_call']) +def test_application_diff_cannot_publish_removal_through_opaque_factory_retention(tmp_path, framework, dictionary, retention): + from tests.test_imported_tool_bindings import _commit, _compare, _git + + selected = "groups['calendar']" if dictionary else 'groups' + files = _files(framework, f'groups = make_tools()\na = build({selected})\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + returned = "{'calendar': [read, write]}" if dictionary else '[read, write]' + files['factory.py'] = 'from tools import read, write\ndef make_tools():\n return ' + returned + '\n' + _git(tmp_path, 'init', '-q', '-b', 'main') + base = _commit(tmp_path, files) + field = 'name' if framework == 'sdk' else '__name__' + opaque = 'consume(make_tools())\n' if retention == 'different_call' else f"class Comparator:\n def __eq__(self, tool):\n tool.{field} = 'renamed'\n return True\n{selected}.count(Comparator())\n" + head = _commit(tmp_path, {'factory.py': files['factory.py'].replace('[read, write]', '[read]'), 'app.py': files['app.py'] + opaque}) + result = _compare(tmp_path, base, head, '--scope', '.') + rows = [row for row in result['rows'] if row['agent'] == 'Built' and row['tool'] == 'write'] + assert rows and all(row['change'] == 'not_established' for row in rows) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('site', ['tools', 'registry', 'bridge']) +def test_opaque_class_carriers_cannot_escape_through_method_globals(tmp_path, framework, site): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + field = 'name' if framework == 'sdk' else '__name__' + files['registry.py'] = 'from tools import read\nclass Anchor:\n def method(self):\n return None\n' + files['bridge.py'] = 'from registry import Anchor\n' + if site == 'tools': + files['tools.py'] += 'class Anchor:\n def method(self):\n return None\n' + files['consumer.py'] = f"def consume(holder):\n holder.method.__globals__['r'+'ead'].{field} = 'renamed'\n" + files['sibling.py'] = f'from {site} import Anchor\nfrom consumer import consume\nconsume(Anchor)\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +def test_a_locally_defined_class_is_also_a_namespace_carrier(tmp_path, framework): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + files['tools.py'] += 'class Anchor:\n def method(self):\n return None\nconsume(Anchor)\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +def test_class_construction_does_not_supply_a_read_only_namespace_proof(tmp_path, framework): + files = _files(framework, 'a = build(make_tools())\n') + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = 'from tools import read\ndef make_tools():\n return [read]\n' + files['tools.py'] += 'class Anchor:\n def method(self):\n return None\n' + files['sibling.py'] = 'from tools import Anchor\nconsume(Anchor())\n' + _write(tmp_path, files) + observations, warnings = _read(tmp_path, framework) + assert warnings and all(not item.tools_complete for item in observations) + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +@pytest.mark.parametrize('carrier', ['helper', 'Helper', 'namespace', 'bridge']) +def test_empty_factory_projection_cannot_establish_removal_with_escaped_namespace(tmp_path, framework, carrier): + from tests.test_imported_tool_bindings import _commit, _compare, _git + + files = _files(framework, "groups = make_tools()\na = build(groups['selected'])\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = ( + "from tools import read, write\ndef make_tools():\n return {'selected': [read], 'unused': [write]}\n" + + "def helper():\n return None\nclass Helper:\n def run(self):\n return None\n" + ) + files['bridge.py'] = 'from factory import helper\ndef carrier():\n return None\n' + _git(tmp_path, 'init', '-q', '-b', 'main') + base = _commit(tmp_path, files) + escape = { + 'helper': 'from factory import helper\nconsume(helper)\n', + 'Helper': 'from factory import Helper\nconsume(Helper)\n', + 'namespace': 'import factory\nconsume(factory)\n', + 'bridge': 'from bridge import carrier\nconsume(carrier)\n', + }[carrier] + head = _commit(tmp_path, {'factory.py': files['factory.py'].replace("'selected': [read]", "'selected': []"), 'borrower.py': escape}) + result = _compare(tmp_path, base, head, '--scope', '.') + assert not any(row['change'] == 'removed' for row in result['rows']) + assert result['head']['limits'] and result['head']['coverage_gaps'] + + +@pytest.mark.parametrize('framework', ['sdk', 'adk']) +def test_clean_empty_factory_projection_still_establishes_removal(tmp_path, framework): + from tests.test_imported_tool_bindings import _commit, _compare, _git + + files = _files(framework, "groups = make_tools()\na = build(groups['selected'])\n") + files['app.py'] = files['app.py'].replace('from tools import read, write', 'from tools import read, write\nfrom factory import make_tools') + files['factory.py'] = "from tools import read, write\ndef make_tools():\n return {'selected': [read], 'unused': [write]}\n" + _git(tmp_path, 'init', '-q', '-b', 'main') + base = _commit(tmp_path, files) + head = _commit(tmp_path, {'factory.py': files['factory.py'].replace("'selected': [read]", "'selected': []")}) + result = _compare(tmp_path, base, head, '--scope', '.') + assert [(row['tool'], row['change']) for row in result['rows']] == [('read', 'removed')] + assert result['comparison_status'] == 'compared' diff --git a/tests/test_saved_vars_repository_context.py b/tests/test_saved_vars_repository_context.py new file mode 100644 index 000000000..b544a50f6 --- /dev/null +++ b/tests/test_saved_vars_repository_context.py @@ -0,0 +1,20 @@ +"""Saved namespace writes are proved inside a stable real repository boundary.""" + +import pytest + +from tests.test_builder_bindings import ( + _assert_namespace_mutation_result, + _namespace_mutation_observations, +) +from tests.test_builder_calls import namespace_workspace as namespace_workspace + + +@pytest.mark.parametrize("framework", ["sdk", "adk"]) +@pytest.mark.parametrize("same_namespace", [False, True]) +def test_saved_vars_namespace_write_retains_the_actual_owner_in_a_repository( + namespace_workspace, framework, same_namespace, +): + namespace = "framework" if same_namespace else "fake" + helper = f"replace = vars\nslots = replace({namespace})\nslots['Agent'] = fake.Agent\n" + built, warnings = _namespace_mutation_observations(namespace_workspace, framework, helper) + _assert_namespace_mutation_result(built, warnings, same_namespace) diff --git a/tests/test_scan.py b/tests/test_scan.py index 5bb61ef47..064475fe3 100644 --- a/tests/test_scan.py +++ b/tests/test_scan.py @@ -960,7 +960,14 @@ def test_baseline_save_and_scan_matches_existing_findings(tmp_path): assert ( second_report.release_decision.evidence_coverage.semantic_coverage.gap_count > 0 ) + # The sample intentionally conflicts with its reviewed bindings; matching + # a baseline cannot establish a comparable capability surface. + assert second_report.binding_surface_facts.status == "conflicting" assert second_report.tool_surface_diff.enabled is True + assert second_report.tool_surface_diff.finding_deltas.resolved_findings == [] + assert second_report.binding_surface_diff.base_comparison_requested + assert second_report.binding_surface_diff.enabled is False + assert any("comparison incomplete" in note for note in second_report.tool_surface_diff.notes) assert second_report.tool_surface_diff.base.kind == "baseline" assert all(finding.baseline_status in {None, "matched"} for finding in second_report.findings) @@ -1012,7 +1019,12 @@ def test_scan_diff_from_prior_report_does_not_change_release_gate(tmp_path): assert with_diff.release_decision is not None assert without_diff.release_decision is not None assert with_diff.release_decision.decision == without_diff.release_decision.decision + assert with_diff.binding_surface_facts.status == "conflicting" assert with_diff.tool_surface_diff.enabled is True + assert with_diff.tool_surface_diff.finding_deltas.resolved_findings == [] + assert with_diff.binding_surface_diff.base_comparison_requested + assert with_diff.binding_surface_diff.enabled is False + assert any("comparison incomplete" in note for note in with_diff.tool_surface_diff.notes) assert with_diff.tool_surface_diff.base.kind == "report" diff --git a/tests/test_shard_partition.py b/tests/test_shard_partition.py index a01709289..a377ec95c 100644 --- a/tests/test_shard_partition.py +++ b/tests/test_shard_partition.py @@ -8,6 +8,7 @@ from __future__ import annotations +import functools import os import re import shutil @@ -177,6 +178,49 @@ def _collect(shard: int | None, shards: int | None) -> dict[str, int]: return collected +@functools.cache +def _whole_collection() -> dict[str, int]: + """The unsharded CI collection, taken once per process.""" + + return _collect(None, None) + + +#: Test files the sharded CI suite does not run, so they carry no weight: the +#: static-only adapter test is ignored by the CI command and the latency-budget +#: tests are ``perf``, which have their own step. +_OUTSIDE_THE_SHARDED_SUITE = frozenset({ + "tests/test_adapter_static_only.py", + "tests/test_latency_budget.py", +}) + + +def test_every_file_the_ci_suite_collects_has_a_measured_weight() -> None: + """An unmeasured file is guessed at, and a wrong guess overruns a shard. + + A file without a weight costs its item count at the suite's average seconds + per item, which is how a handful of git-fixture files weighed like pure + ones and one shard ran past its cap (#904). New test files are the usual + cause. Re-measure instead of guessing: + + python -m pytest -n auto -m "not perf and not slow" \\ + --ignore=tests/test_adapter_static_only.py --junitxml=junit.xml + python scripts/measure_shard_seconds.py junit.xml + """ + + measured = load_seconds(SECONDS_FILE) + unmeasured = sorted(set(_whole_collection()) - set(measured) - _OUTSIDE_THE_SHARDED_SUITE) + assert not unmeasured, ( + f"{len(unmeasured)} collected test file(s) have no weight in " + f"{SECONDS_FILE.name}: {unmeasured}" + ) + + +def test_the_files_outside_the_sharded_suite_are_not_in_it() -> None: + """The allow-list names files CI really does not run in a shard.""" + + assert not _OUTSIDE_THE_SHARDED_SUITE & set(_whole_collection()) + + def test_the_real_collection_partitions_exhaustively() -> None: """The property that matters, on the actual suite rather than a fixture. @@ -185,7 +229,7 @@ def test_the_real_collection_partitions_exhaustively() -> None: nothing at all. """ - whole = _collect(None, None) + whole = _whole_collection() assert whole, "the baseline collection found no tests" shards = [_collect(index, 3) for index in (1, 2, 3)] union: dict[str, int] = {} @@ -293,3 +337,29 @@ def test_a_half_configured_shard_is_refused(shards: str, shard: str) -> None: check=False, ) assert result.returncode != 0, result.stdout[-2000:] + + +def test_remeasurement_records_provenance_from_the_same_report_bytes(tmp_path, monkeypatch): + import hashlib + import json + + from scripts import measure_shard_seconds as measure + + report = tmp_path / "junit.xml" + original = b'' + report.write_bytes(original) + output = tmp_path / "weights.json" + monkeypatch.setattr(measure, "OUTPUT", output) + read = measure.file_seconds + + def change_report_after_capture(path, root, *, report_bytes=None): + path.write_bytes(b'') + return read(path, root, report_bytes=report_bytes) + + monkeypatch.setattr(measure, "file_seconds", change_report_after_capture) + assert measure.main([str(report)]) == 0 + payload = json.loads(output.read_text()) + assert payload["files"] == {"tests/test_a.py": 1.2, "tests/test_b.py": 2.3} + assert payload["measurement"]["report_sha256"] == hashlib.sha256(original).hexdigest() + assert payload["measurement"]["case_count"] == payload["measurement"]["file_count"] == 2 + assert payload["measurement"]["source"] == "junit" diff --git a/tests/test_source_slot_refusal_context.py b/tests/test_source_slot_refusal_context.py new file mode 100644 index 000000000..596aacf54 --- /dev/null +++ b/tests/test_source_slot_refusal_context.py @@ -0,0 +1,143 @@ +"""Refusal text follows the selected actual import, never another proof.""" + +import ast + +import pytest + +from agents_shipgate.inputs.builder_calls import BuilderCalls, CallLimit +from agents_shipgate.inputs.list_expressions import ListExpressions +from agents_shipgate.inputs.python_imports import ImportResolver, ScopeIndex +from tests.test_builder_calls import _source_slot_fixture +from tests.test_builder_calls import namespace_workspace as namespace_workspace + + +@pytest.mark.parametrize("extra, expected", [("", True), ("fake.Agent([])\n", False)]) +def test_actual_source_slot_outcome_is_unchanged_and_only_refusals_are_collected(namespace_workspace, extra, expected): + resolver, module, statement = _source_slot_fixture(namespace_workspace, extra) + calls = BuilderCalls(resolver) + home, function, _ = calls._source_slot_candidate(module, statement) + refused = [] + assert calls.uncalled_source_slot(home, function, refused_models=refused) is expected + if expected: + assert refused == [] + else: + assert len(refused) == 2 + assert refused[0].startswith("agents: ") and refused[1].startswith("google.adk: ") + assert all(f"{module.ref}:{statement.lineno}: " in row for row in refused) + assert all(len(row) <= 1024 for row in refused) + assert not resolver._checking_source_module_slot + + +def test_second_genuine_model_success_discards_first_model_refusal(namespace_workspace, monkeypatch): + resolver, module, statement = _source_slot_fixture(namespace_workspace) + calls = BuilderCalls(resolver) + home, function, _ = calls._source_slot_candidate(module, statement) + original = BuilderCalls.idempotent_source_slot + attempted = [] + + def decline_first(self, caller, node, family): + result = original(self, caller, node, family) + attempted.append(family) + if family == "agents": + raise CallLimit("first model declined after its genuine proof") + return result + + monkeypatch.setattr(BuilderCalls, "idempotent_source_slot", decline_first) + refused = [] + assert calls.uncalled_source_slot(home, function, refused_models=refused) + assert attempted == ["agents", "google.adk"] and refused == [] + + +def test_selected_import_context_survives_cache_hits_without_same_line_collision(namespace_workspace, monkeypatch): + root = namespace_workspace + (root / "tools.py").write_text("def read():\n return 1\nBASE = [read]\nOTHER = [read]\n") + text = "from tools import BASE, OTHER\nopaque(BASE)\nopaque(OTHER)\n" + path = root / "app.py" + path.write_text(text) + reader = ImportResolver(root) + module = reader.entry(path, ast.parse(text), text) + defining = reader.module(root / "tools.py") + lists = ListExpressions(ref=module.ref, tree=module.tree, scopes=ScopeIndex(module.tree), + bindings=module.bindings, module=module, resolver=reader, + agent_reads=lambda call, keyword: False) + view = lists.entry + statement = module.tree.body[0] + first, second = statement.names + view.changed_import_context[first, statement] = ("agents: selected first import",) + view.changed_import_context[second, statement] = ("google.adk: selected second import",) + original = lists._first_change_reaching + calls = 0 + + def counted(*args, **kwargs): + nonlocal calls + calls += 1 + return original(*args, **kwargs) + + monkeypatch.setattr(lists, "_first_change_reaching", counted) + assert lists._changed_through(view, defining, "BASE") == statement.lineno + first_suffix = lists._change_refusal_suffix(view, defining, "BASE") + assert "selected first import" in first_suffix and "selected second import" not in first_suffix + assert first_suffix.startswith(" Additional constructor refusal context: ") + view.changed_import_context[first, statement] = ("agents: later unrelated refusal",) + assert lists._changed_through(view, defining, "BASE") == statement.lineno + assert calls == 1 and lists._change_refusal_suffix(view, defining, "BASE") == first_suffix + assert lists._changed_through(view, defining, "OTHER") == statement.lineno + second_suffix = lists._change_refusal_suffix(view, defining, "OTHER") + assert "selected second import" in second_suffix and "selected first import" not in second_suffix + assert calls == 2 + assert lists._changed_through(view, defining, "MISSING") is None + assert lists._change_refusal_suffix(view, defining, "MISSING") == "" + + +def _lists(root, text): + path = root / "app.py" + path.write_text(text) + reader = ImportResolver(root) + module = reader.entry(path, ast.parse(text), text) + return ListExpressions(ref=module.ref, tree=module.tree, scopes=ScopeIndex(module.tree), + bindings=module.bindings, module=module, resolver=reader, + agent_reads=lambda call, keyword: keyword == "tools") + + +def test_successful_genuine_callee_discards_rejected_agent_read_context(namespace_workspace, monkeypatch): + lists = _lists(namespace_workspace, "def count_items(items):\n return len(items)\nBASE = []\ncount_items(BASE)\n") + call = lists.entry.tree.body[-1].value + original = lists._reads_agent + + def with_refusal(view, current, keyword, *, refused_context=None): + result = original(view, current, keyword) + assert not result + if refused_context is not None: + refused_context.append("agents: declined consumer model") + return result + + monkeypatch.setattr(lists, "_reads_agent", with_refusal) + refused = [] + assert lists._call_reads(lists.entry, refused_reads=refused)(call, 0, None) + assert refused == [] + + +@pytest.mark.parametrize("matching", [True, False]) +def test_constructor_refusal_requires_the_actual_result_key(namespace_workspace, monkeypatch, matching): + lists = _lists(namespace_workspace, "from agents import Agent\na = Agent(name='App', tools=[])\n") + call = lists.entry.tree.body[-1].value + key = ((1, 0, 0, 0, 0, 0, 0), ((), ())) + other_key = ((2, 0, 0, 0, 0, 0, 0), ((), ())) + lists._constructor_namespace_result = (key, True) + lists._constructor_namespace_refusal = (key if matching else other_key, ("agents: selected model",)) + attempts = 0 + + def existing_refusal(): + nonlocal attempts + attempts += 1 + return True + + monkeypatch.setattr(lists, "constructor_namespace_changed", existing_refusal) + refused = [] + assert not lists._reads_agent(lists.entry, call, "tools", refused_context=refused) + assert attempts == 1 + if matching: + assert refused == ["agents: selected model"] + else: + assert refused == [f"constructor read in {lists.entry.ref}: retained constructor namespace ownership census refused"] + assert "agents: selected model" not in refused diff --git a/tests/test_surface_exclusions.py b/tests/test_surface_exclusions.py index 1881d8a96..5b4f66729 100644 --- a/tests/test_surface_exclusions.py +++ b/tests/test_surface_exclusions.py @@ -2021,3 +2021,132 @@ def test_a_tool_that_lost_its_binding_is_not_claimed_to_have_been_added(tmp_path assert "added" not in row.detail assert "'find_duplicate [server_mcp]' — not bound to the root agent" in note assert "added by this diff" not in note + + +@pytest.mark.parametrize("facts_present", [False, True]) +def test_partial_zero_id_base_survives_public_sanitizer_and_blocks_comparison(tmp_path, facts_present): + config = _write_tree(tmp_path / "app", [], []) + _scan(config, tmp_path / "base-reports") + reference = tmp_path / "base-reports" / "report.json" + payload = json.loads(reference.read_text()) + secret = "ghp_" + "A" * 36 + payload["binding_surface_facts"]["status"] = "partial" + payload["binding_surface_facts"]["pass_eligible"] = False + payload["binding_surface_facts"]["issues"] = [dict( + kind="partial_binding_evidence", message="Unread source", source="framework_constructor_ownership", + source_pointer="agent.py:3?token=" + secret, + )] + if not facts_present: + payload.pop("tool_surface_facts") + reference.write_text(json.dumps(payload)) + report, _ = _scan(config, tmp_path / "head-reports", diff_from=reference) + assert report.binding_surface_facts.possible_tool_ids == [] + assert report.binding_surface_diff.base_comparison_requested and not report.binding_surface_diff.enabled + # Without the base's tool facts there is no tool comparison to make; with + # them it is made and only what depends on absence is withheld. + assert report.tool_surface_diff.enabled is facts_present + assert report.tool_surface_diff.finding_deltas.resolved_findings == [] + assert not any(row.kind == "removed" for row in report.tool_surface_diff.tools) + assert report.capability_change is None or not ( + report.capability_change.removed or report.capability_change.narrowed + ) + assert any("comparison incomplete on base" in note for note in report.tool_surface_diff.notes) + assert secret not in report.model_dump_json() + gaps = report.release_decision.evidence_coverage.evidence_gaps + assert any(gap.source_ref == "--diff-from" and gap.next_action.kind == "provide_source" for gap in gaps) + assert report.release_decision.decision == "insufficient_evidence" + validate_semantic_consistency(report, _rehydrated_tools(report)) + + +def _write_constructor_scan(root, framework, source): + root.mkdir(parents=True) + (root / "agent.py").write_text(source) + (root / "shipgate.yaml").write_text( + 'version: "0.1"\nproject: {name: comparison}\n' + 'agent: {name: app, declared_purpose: [test comparison coverage]}\n' + 'environment: {target: local}\ntool_sources:\n' + ' - id: sdk\n type: ' + framework + '\n path: agent.py\n' + ) + return root / "shipgate.yaml" + + +def test_zero_id_partial_head_is_gated_with_a_fact_only_base_reference(tmp_path): + base_config = _write_tree(tmp_path / "base", [], []) + _scan(base_config, tmp_path / "base-reports") + reference = tmp_path / "base-reports" / "report.json" + payload = json.loads(reference.read_text()) + payload.pop("binding_surface_facts") + reference.write_text(json.dumps(payload)) + config = _write_constructor_scan(tmp_path / "head", "openai_agents_sdk", + "from agents import Agent\nAgent.__init__ = object\nagent = Agent(name='App', tools=[])\n") + report, _ = _scan(config, tmp_path / "head-reports", diff_from=reference) + assert report.binding_surface_facts.possible_tool_ids == [] + assert report.binding_surface_diff.base_comparison_requested and not report.binding_surface_diff.enabled + assert not any(row.kind == "removed" for row in report.tool_surface_diff.tools) + assert any("comparison incomplete on head" in note for note in report.tool_surface_diff.notes) + gaps = report.release_decision.evidence_coverage.evidence_gaps + assert any(gap.source_ref == "--diff-from" and gap.next_action.kind == "provide_source" for gap in gaps) + assert report.release_decision.decision == "insufficient_evidence" + validate_semantic_consistency(report, _rehydrated_tools(report)) + + +@pytest.mark.parametrize("change", ["runner", "awaited_runner", "genexpr"]) +def test_unread_sdk_head_cannot_report_capabilities_removed_by_scan_diff(tmp_path, change): + common = ("from agents import Agent, function_tool\n" + "@function_tool\ndef delete() -> str:\n return 'deleted'\n" + "worker = Agent(name='Worker', tools=[delete])\n") + before = common + "agent = Agent(name='App', handoffs=[worker])\n" + if change == "runner": + after = before + "from agents import Runner\nif __name__ == '__main__':\n Runner.run_sync(agent, 'hello')\n" + elif change == "awaited_runner": + after = before + ( + "from agents import Runner\nimport asyncio\n" + "async def main():\n" + " result = await Runner.run(agent, 'hello')\n" + " print(result.final_output)\n" + "if __name__ == '__main__':\n asyncio.run(main())\n" + ) + else: + after = common + "agent = Agent(name='App', tools=sum(([] for _ in []), []), handoffs=[worker])\n" + base = _write_constructor_scan(tmp_path / "base", "openai_agents_sdk", before) + head = _write_constructor_scan(tmp_path / "head", "openai_agents_sdk", after) + _scan(base, tmp_path / "base-reports") + report, _ = _scan(head, tmp_path / "head-reports", diff_from=tmp_path / "base-reports" / "report.json") + assert report.binding_surface_facts.status == "partial" + assert report.binding_surface_facts.possible_tool_ids + assert report.binding_surface_facts.handoff_edges + assert report.binding_surface_diff.base_comparison_requested and not report.binding_surface_diff.enabled + assert report.binding_surface_diff.removed_handoffs == [] + assert report.binding_surface_diff.removed_reachable_tool_ids == [] + # The tool comparison is made and says what it withheld; nothing removed, + # narrowed or resolved is claimed from a head that only failed to be read. + assert report.tool_surface_diff.enabled + assert any("comparison incomplete on head" in note for note in report.tool_surface_diff.notes) + assert not any((report.tool_surface_diff.tools, report.tool_surface_diff.high_risk_effects, + report.tool_surface_diff.scopes, report.tool_surface_diff.controls)) + assert report.tool_surface_diff.finding_deltas.resolved_findings == [] + assert not (report.capability_change.removed or report.capability_change.narrowed) + assert report.release_decision.decision != "passed" + gaps = report.release_decision.evidence_coverage.evidence_gaps + assert any(gap.source_ref == "--diff-from" for gap in gaps) + validate_semantic_consistency(report, _rehydrated_tools(report)) + + +def test_adk_unread_parent_preserves_child_candidate_and_freeform_findings(tmp_path): + source = ("from google.adk.agents import Agent\n" + "def lookup() -> str:\n return 'ok'\n" + "worker = Agent(name='Worker', tools=[lookup])\n" + "Agent.__init__ = object\n" + "root = Agent(name='Root', sub_agents=[worker])\n") + config = _write_constructor_scan(tmp_path / "app", "google_adk", source) + report, _ = _scan(config, tmp_path / "reports") + graph = report.binding_surface_facts + assert graph.root_agent_id is not None and graph.status == "partial" + assert graph.possible_tool_ids and graph.unbound_tool_ids == [] + assert graph.handoff_edges and all(not edge.complete for edge in graph.handoff_edges) + # The child was matched to its definition: the constructor gap makes the + # edge incomplete, and nothing claims the definition could not be found. + assert not any("could not match" in issue.message for issue in graph.issues) + assert any(finding.check_id == "SHIP-SCHEMA-FREEFORM-OUTPUT" for finding in report.findings) + assert report.release_decision.decision != "passed" + validate_semantic_consistency(report, _rehydrated_tools(report)) diff --git a/tests/test_tool_surface_diff.py b/tests/test_tool_surface_diff.py index c82851d29..987376c87 100644 --- a/tests/test_tool_surface_diff.py +++ b/tests/test_tool_surface_diff.py @@ -1,3 +1,9 @@ +import pytest + +from agents_shipgate.core.binding_comparison import ( + binding_comparison_limits, + comparison_withholds_absence, +) from agents_shipgate.core.domain import ( Tool, ToolRiskHint, @@ -344,3 +350,167 @@ def test_build_tool_surface_facts_projects_controls_and_metadata(): ("approval_policy", "stripe.refund"), ("idempotency_evidence", "stripe.refund"), } + + + +def test_incomplete_binding_comparison_cannot_report_risk_or_finding_removals(): + from agents_shipgate.schemas.bindings import AgentBindingGraphAssessment, AgentBindingIssue + + base = ToolSurfaceFacts(tools=[ToolSurfaceToolFact( + name="delete", source_type="mcp", risk_tags=["destructive"], auth_scopes=["admin"], + )], scopes=[ToolSurfaceScopeFact(kind="tool_required", scope="admin", tool_names=["delete"])], + controls=[ToolSurfaceControlFact(kind="approval_policy", tool="delete", source="manifest")]) + partial = AgentBindingGraphAssessment(status="partial", issues=[AgentBindingIssue( + kind="partial_binding_evidence", message="Unread constructor", source="framework_constructor_ownership", + source_pointer="agent.py:8", + )]) + complete = AgentBindingGraphAssessment(status="structural", pass_eligible=True) + for head_graph, base_graph in ((partial, complete), (complete, partial)): + diff = compute_tool_surface_diff( + ToolSurfaceFacts(), base, [], head_binding_facts=head_graph, + reference=ToolSurfaceDiffReference(kind="report", facts=base, binding_facts=base_graph), + ) + # The comparison is still made; only what depends on absence is withheld. + assert diff.enabled + assert not any((diff.tools, diff.scopes, diff.controls, diff.high_risk_effects, diff.metadata_changes)) + assert diff.finding_deltas.resolved_findings == [] + assert diff.summary.tools_removed == 0 and diff.summary.removed_high_risk_effects == 0 + assert any("comparison incomplete" in note and "agent.py:8" in note for note in diff.notes) + assert comparison_withholds_absence(diff.notes) + + +def _partial_graph(source="framework_constructor_ownership"): + from agents_shipgate.schemas.bindings import AgentBindingGraphAssessment, AgentBindingIssue + + return AgentBindingGraphAssessment(status="partial", issues=[AgentBindingIssue( + kind="partial_binding_evidence", message="Unread constructor", source=source, + source_pointer="agent.py:8", + )]) + + +def _tool_fact(name, **kwargs): + return ToolSurfaceToolFact(name=name, source_type="mcp", **kwargs) + + +@pytest.mark.parametrize("side", ["head", "base"]) +def test_an_incomplete_graph_still_reports_additions_changes_and_removed_controls(side): + """What presence proves reaches the reviewer; only absence is withheld.""" + from agents_shipgate.schemas.bindings import AgentBindingGraphAssessment + + base = ToolSurfaceFacts( + tools=[_tool_fact("kept", auth_scopes=["read"]), _tool_fact("gone", risk_tags=["destructive"])], + scopes=[ + ToolSurfaceScopeFact(kind="tool_required", scope="read", tool_names=["kept"]), + ToolSurfaceScopeFact(kind="tool_required", scope="admin", tool_names=["gone"]), + ], + controls=[ + ToolSurfaceControlFact(kind="approval_policy", tool="kept", source="manifest"), + ToolSurfaceControlFact(kind="approval_policy", tool="gone", source="manifest"), + ], + ) + head = ToolSurfaceFacts( + tools=[_tool_fact("kept", auth_scopes=["read", "write"]), _tool_fact("brand_new")], + scopes=[ + ToolSurfaceScopeFact(kind="tool_required", scope="read", tool_names=["kept"]), + ToolSurfaceScopeFact(kind="tool_required", scope="write", tool_names=["kept"]), + ], + controls=[], + ) + complete = AgentBindingGraphAssessment(status="structural", pass_eligible=True) + diff = compute_tool_surface_diff( + head, base, [], + head_binding_facts=_partial_graph() if side == "head" else complete, + reference=ToolSurfaceDiffReference( + kind="report", facts=base, + binding_facts=_partial_graph() if side == "base" else complete, + ), + ) + assert diff.enabled + assert {(row.name, row.kind) for row in diff.tools} == {("brand_new", "added"), ("kept", "changed")} + assert {(row.scope, row.kind) for row in diff.scopes} == {("write", "added")} + # The control lost with the absent tool is that tool's absence restated; + # the control on the tool that is still there is a real removal. + assert [(row.tool, row.kind) for row in diff.controls] == [("kept", "removed")] + assert diff.summary.tools_removed == 0 and diff.summary.tools_added == 1 + assert comparison_withholds_absence(diff.notes) + + +def test_a_toolset_inventory_gap_alone_is_not_incomplete_binding_evidence(): + """A remote toolset nobody can enumerate neither withholds nor forges a removal.""" + from agents_shipgate.core.agent_bindings import FRAMEWORK_TOOLSET_INVENTORY + + base = ToolSurfaceFacts(tools=[_tool_fact("gone")]) + for graph in (_partial_graph(FRAMEWORK_TOOLSET_INVENTORY), None): + diff = compute_tool_surface_diff( + ToolSurfaceFacts(), base, [], head_binding_facts=graph, + reference=ToolSurfaceDiffReference( + kind="report", facts=base, binding_facts=_partial_graph(FRAMEWORK_TOOLSET_INVENTORY), + ), + ) + assert diff.enabled and [(row.name, row.kind) for row in diff.tools] == [("gone", "removed")] + assert not comparison_withholds_absence(diff.notes) + # Beside a real gap it still counts only for what it is. + mixed = _partial_graph() + mixed = mixed.model_copy(update={"issues": [*mixed.issues, *_partial_graph(FRAMEWORK_TOOLSET_INVENTORY).issues]}) + assert len(binding_comparison_limits(mixed, None)) == 1 + only_inventory = _partial_graph(FRAMEWORK_TOOLSET_INVENTORY) + assert binding_comparison_limits(only_inventory, only_inventory) == [] + # A partial graph with no recorded cause is not excused by the marker. + unexplained = only_inventory.model_copy(update={"issues": []}) + assert len(binding_comparison_limits(unexplained, None)) == 1 + + +def test_unknown_catalog_and_fact_only_comparisons_still_report_real_removals(): + from agents_shipgate.schemas.bindings import AgentBindingGraphAssessment, AgentBindingIssue + + base = ToolSurfaceFacts(tools=[ToolSurfaceToolFact(name="delete", source_type="mcp")]) + catalog = AgentBindingGraphAssessment(status="unknown", unbound_tool_ids=["delete"], issues=[ + AgentBindingIssue(kind="ambiguous_root_agent", message="Catalog has no agent")]) + for head_graph in (None, catalog): + diff = compute_tool_surface_diff(ToolSurfaceFacts(), base, [], head_binding_facts=head_graph) + assert diff.enabled and [(row.name, row.kind) for row in diff.tools] == [("delete", "removed")] + + +@pytest.mark.parametrize("side", ["head", "base"]) +def test_findings_only_reference_cannot_claim_resolution_with_incomplete_bindings(side): + from agents_shipgate.schemas.bindings import AgentBindingGraphAssessment, AgentBindingIssue + + partial = AgentBindingGraphAssessment(status="partial", issues=[AgentBindingIssue( + kind="partial_binding_evidence", message="Unread constructor", source_pointer="agent.py:3", + )]) + reference = ToolSurfaceDiffReference( + kind="report", facts=None, + findings=[ToolSurfaceFindingDeltaItem(fingerprint="old", check_id="old", title="old", severity="high")], + binding_facts=partial if side == "base" else None, + ) + diff = compute_tool_surface_diff( + ToolSurfaceFacts(), None, [], reference=reference, + head_binding_facts=partial if side == "head" else None, + ) + assert not diff.enabled and diff.finding_deltas.resolved_findings == [] + assert diff.summary.resolved_findings == 0 + assert any("comparison incomplete on " + side in note for note in diff.notes) + # A finding the head has and the reference lacks is presence, still reported. + new = compute_tool_surface_diff( + ToolSurfaceFacts(), None, [Finding( + id="fresh", fingerprint="fresh", check_id="SHIP-DOC-MISSING-DESCRIPTION", title="new", + severity="high", category="documentation", confidence="high", recommendation="Add it.", + )], reference=reference, + head_binding_facts=partial if side == "head" else None, + ) + assert [item.fingerprint for item in new.finding_deltas.new_findings] == ["fresh"] + assert new.finding_deltas.resolved_findings == [] + + +@pytest.mark.parametrize("status", ["partial", "conflicting"]) +def test_a_fresh_scan_does_not_claim_a_comparison_was_requested(status): + from agents_shipgate.schemas.bindings import AgentBindingGraphAssessment + + graph = AgentBindingGraphAssessment(status=status, possible_tool_ids=["unread"]) + diff = compute_tool_surface_diff( + ToolSurfaceFacts(), None, [], head_binding_facts=graph, + ) + assert not diff.enabled + assert diff.notes == ["No --diff-from report or v0.3 baseline snapshot was provided."] + assert diff.finding_deltas.resolved_findings == [] + assert not any((diff.tools, diff.scopes, diff.controls, diff.high_risk_effects)) diff --git a/tests/test_type_checking_constructor_ownership.py b/tests/test_type_checking_constructor_ownership.py new file mode 100644 index 000000000..e6e626d92 --- /dev/null +++ b/tests/test_type_checking_constructor_ownership.py @@ -0,0 +1,189 @@ +"""Only an actual canonical false guard can remove executable import edges.""" + +import ast + +import pytest + +from agents_shipgate.inputs.python_imports import ImportResolver, _type_checking_only +from tests.constructor_engine_fixture import frozen_constructor_engine as frozen_constructor_engine +from tests.test_imported_tool_bindings import _commit, _compare, _git + + +@pytest.mark.parametrize("source,dead", [ + ("from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n import patcher\n", True), + ("import typing as t\nif t.TYPE_CHECKING:\n import patcher\n", True), + ("from typing_extensions import TYPE_CHECKING as TC\nif TC:\n import patcher\n", True), + ("def build():\n from typing import TYPE_CHECKING\n if TYPE_CHECKING:\n import patcher\n", True), + ("class Owner:\n from typing import TYPE_CHECKING\n if TYPE_CHECKING:\n import patcher\n", True), + ("from typing import TYPE_CHECKING\ndef build(TYPE_CHECKING):\n if TYPE_CHECKING:\n import patcher\n", False), + ("from typing import TYPE_CHECKING\ndef build():\n TYPE_CHECKING = True\n if TYPE_CHECKING:\n import patcher\n", False), + ("from typing import TYPE_CHECKING\nclass Owner:\n TYPE_CHECKING = True\n if TYPE_CHECKING:\n import patcher\n", False), + ("from typing import TYPE_CHECKING\ndef outer():\n TYPE_CHECKING = True\n def inner():\n if TYPE_CHECKING:\n import patcher\n", False), + ("from typing import TYPE_CHECKING\ndef outer():\n TYPE_CHECKING = True\n def inner():\n nonlocal TYPE_CHECKING\n if TYPE_CHECKING:\n import patcher\n", False), + ("from typing import TYPE_CHECKING\ndef build():\n from shim import TYPE_CHECKING\n if TYPE_CHECKING:\n import patcher\n", False), + ("from typing import TYPE_CHECKING\ndef build():\n if choice:\n from typing import TYPE_CHECKING\n if TYPE_CHECKING:\n import patcher\n", False), + ("from typing import TYPE_CHECKING\nTYPE_CHECKING = True\nif TYPE_CHECKING:\n import patcher\n", False), + ("import typing as t\nt.TYPE_CHECKING = True\nif t.TYPE_CHECKING:\n import patcher\n", False), + ("from typing import TYPE_CHECKING\nif not TYPE_CHECKING:\n import patcher\n", False), + ("from typing import TYPE_CHECKING\nif TYPE_CHECKING is False:\n import patcher\n", False), + ("from typing import TYPE_CHECKING\nfrom shim import *\nif TYPE_CHECKING:\n import patcher\n", False), + ("if TYPE_CHECKING:\n import patcher\nfrom typing import TYPE_CHECKING\n", False), + ("from typing import TYPE_CHECKING\ndef build():\n if TYPE_CHECKING:\n TYPE_CHECKING = True\n import patcher\n", False), + ("import typing as t\nif t.TYPE_CHECKING:\n t.TYPE_CHECKING = True\n import patcher\n", False), +]) +def test_false_guard_uses_the_actual_lexical_binding(tmp_path, source, dead): + path = tmp_path / "agent.py" + path.write_text(source) + resolver = ImportResolver(tmp_path) + module = resolver.module(path) + [patcher] = [node for node in ast.walk(module.tree) if isinstance(node, ast.Import) + and any(alias.name == "patcher" for alias in node.names)] + assert (id(patcher) in _type_checking_only(module, resolver)) is dead + + +@pytest.mark.parametrize("provider", ["typing.py", "typing/__init__.py", "typing_extensions.py"]) +def test_a_local_typing_provider_does_not_prove_the_canonical_flag(tmp_path, provider): + target = tmp_path / provider + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("TYPE_CHECKING = True\n") + path = tmp_path / "agent.py" + path.write_text(f"from {provider.split('/')[0].removesuffix('.py')} import TYPE_CHECKING\nif TYPE_CHECKING:\n import patcher\n") + resolver = ImportResolver(tmp_path) + module = resolver.module(path) + assert not _type_checking_only(module, resolver) + + +def _files(family, guard, tools): + constructor = "Agent" if family == "sdk" else "LlmAgent" + header = ("from agents import Agent, function_tool\n" if family == "sdk" + else "from google.adk.agents import LlmAgent\n") + decorator = "@function_tool\n" if family == "sdk" else "" + return { + "app/agent.py": header + "from typing import TYPE_CHECKING\n" + decorator + + "def read(value: str) -> str:\n return value\n" + decorator + + "def write(value: str) -> str:\n return value\n" + guard + + f" return {constructor}(name='Built', tools={tools})\nagent = build(True)\n", + "patcher.py": "from abc import ABCMeta\ndef replacement(*args, **kwargs):\n return None\nABCMeta.__call__ = replacement\n", + } + + +@pytest.mark.parametrize("family", ["sdk", "adk"]) +@pytest.mark.parametrize("route", ["parameter", "local", "inverted", "identity-false"]) +def test_an_executable_shadowed_guard_does_not_hide_an_outside_constructor_patch(tmp_path, family, route): + guard = { + "parameter": "def build(TYPE_CHECKING):\n if TYPE_CHECKING:\n import patcher\n", + "local": "def build(unused):\n TYPE_CHECKING = True\n if TYPE_CHECKING:\n import patcher\n", + "inverted": "def build(unused):\n if not TYPE_CHECKING:\n import patcher\n", + "identity-false": "def build(unused):\n if TYPE_CHECKING is False:\n import patcher\n", + }[route] + before = _files(family, "def build(unused):\n", "[read]") + after = _files(family, guard, "[read, write]") + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", "app") + assert not result["base"]["coverage_gaps"] + assert result["comparison_status"] == "partial" + assert result["head"]["coverage_gaps"] + assert all(row["change"] == "not_established" for row in result["rows"]) + + +@pytest.mark.parametrize("family", ["sdk", "adk"]) +@pytest.mark.parametrize("route", ["module", "local", "module-alias", "extension"]) +def test_the_exact_canonical_false_boolean_role_preserves_construction(tmp_path, family, route): + guard = { + "module": "def build(unused):\n if TYPE_CHECKING:\n import patcher\n", + "local": "def build(unused):\n from typing import TYPE_CHECKING as TC\n if TC:\n import patcher\n", + "module-alias": "def build(unused):\n import typing as t\n if t.TYPE_CHECKING:\n import patcher\n", + "extension": "def build(unused):\n from typing_extensions import TYPE_CHECKING as TC\n if TC:\n import patcher\n", + }[route] + _git(tmp_path, "init", "-q", "-b", "main") + base = _commit(tmp_path, _files(family, guard, "[read]")) + head = _commit(tmp_path, _files(family, guard, "[read, write]")) + result = _compare(tmp_path, base, head, "--scope", "app") + assert result["comparison_status"] == "compared" + assert not result["head"]["coverage_gaps"] + assert [(row["tool"], row["change"]) for row in result["rows"]] == [("write", "added")] + + +_MUTATIONS = { + "globals": "globals()['Agent'] = None\n", + "constructor": "{constructor}.__init__ = None\n", + "module-table": "import sys\nsys.modules['{namespace}'] = None\n", + "aliased-table": "from sys import modules as table\ntable['{namespace}'] = None\n", + "own-table": "import sys\nsys.modules[__name__].Agent = None\n", + "search-path": "__path__.append('elsewhere')\n", +} + + +def _mutation_files(family, location, mutation, route): + files = _files(family, "def build(unused):\n", "[read, write]") + constructor = "Agent" if family == "sdk" else "LlmAgent" + namespace = "agents" if family == "sdk" else "google.adk.agents" + body = _MUTATIONS[mutation].format(constructor=constructor, namespace=namespace) + # Above-scope and imported modules acquire the framework name only in + # the guarded body; an unconditional protected import is its own limit. + if location in {"outside", "enclosing"} and mutation == "constructor": + body = f"from {namespace} import {constructor}\n" + body + guard = { + "dead": "if TYPE_CHECKING:\n", + "live": "if not TYPE_CHECKING:\n", + "shadowed": "TYPE_CHECKING = True\nif TYPE_CHECKING:\n", + }[route] + fragment = "from typing import TYPE_CHECKING\n" + guard + "".join( + " " + line + "\n" for line in body.splitlines() + ) + if location == "agent": + fragment = fragment.removeprefix("from typing import TYPE_CHECKING\n") + files["app/agent.py"] = files["app/agent.py"].replace("def build(", fragment + "def build(") + elif location == "tool": + source = files["app/agent.py"] + header, functions = source.split("from typing import TYPE_CHECKING\n", 1) + functions = functions.split("def build(", 1)[0] + files["app/tools.py"] = header + functions + fragment + files["app/agent.py"] = source.replace(functions, "from tools import read, write\n") + elif location == "outside": + files["patcher.py"] = fragment + files["__init__.py"] = "import patcher\n" + else: + files["__init__.py"] = fragment + return files + + +@pytest.mark.parametrize("family", ["sdk", "adk"]) +@pytest.mark.parametrize("location", ["agent", "tool", "outside", "enclosing"]) +@pytest.mark.parametrize("mutation", _MUTATIONS) +@pytest.mark.parametrize("route", ["dead", "live", "shadowed"]) +def test_only_proved_false_bodies_leave_auxiliary_ownership_maps(tmp_path, family, location, mutation, route): + before = _files(family, "def build(unused):\n", "[read]") + after = _mutation_files(family, location, mutation, route) + _git(tmp_path, "init", "-q", "-b", "main") + base, head = _commit(tmp_path, before), _commit(tmp_path, after) + result = _compare(tmp_path, base, head, "--scope", "app") + assert not result["base"]["coverage_gaps"] + if route == "dead": + assert result["comparison_status"] == "compared" + assert not result["head"]["coverage_gaps"] + assert [(row["tool"], row["change"]) for row in result["rows"]] == [("write", "added")] + else: + assert result["comparison_status"] == "partial" + assert result["head"]["coverage_gaps"] + assert all(row["change"] == "not_established" for row in result["rows"]) + + +@pytest.mark.parametrize("mutation", _MUTATIONS) +@pytest.mark.parametrize("order", ["dead-first", "live-first"]) +def test_recomputed_patch_map_keeps_a_live_store_of_the_same_key(tmp_path, mutation, order): + body = _MUTATIONS[mutation].format(constructor="Agent", namespace="agents") + dead = "if TYPE_CHECKING:\n" + "".join(" " + line + "\n" for line in body.splitlines()) + source = "from typing import TYPE_CHECKING\nfrom agents import Agent\n" + source += dead + body if order == "dead-first" else body + dead + path = tmp_path / "agent.py" + path.write_text(source) + resolver = ImportResolver(tmp_path) + module = resolver.module(path) + live_lines = {node.lineno for node in ast.walk(module.tree) + if id(node) not in _type_checking_only(module, resolver) and hasattr(node, "lineno")} + patches = resolver._runtime_attribute_patches(module) + assert patches + assert all(line in live_lines for line in patches.values()) + assert resolver._runtime_attribute_patches(module) == patches diff --git a/tests/test_verifier_blocks.py b/tests/test_verifier_blocks.py index f1ddabc07..393dfd152 100644 --- a/tests/test_verifier_blocks.py +++ b/tests/test_verifier_blocks.py @@ -445,6 +445,53 @@ def test_capability_change_control_added_narrows(): assert block.broadened == [] +def test_capability_change_withholds_absence_claims_while_a_comparison_is_incomplete(): + """Removed and narrowed members are absence claims; additions stand.""" + from agents_shipgate.core.binding_comparison import binding_comparison_limits + from agents_shipgate.schemas.bindings import AgentBindingGraphAssessment, AgentBindingIssue + from agents_shipgate.schemas.surfaces import ( + ActionSurfaceChange, + ActionSurfaceDiff, + ToolSurfaceControlChange, + ToolSurfaceDiff, + ToolSurfaceScopeChange, + ToolSurfaceToolChange, + ) + + partial = AgentBindingGraphAssessment(status="partial", issues=[AgentBindingIssue( + kind="partial_binding_evidence", message="Unread constructor", + source="framework_constructor_ownership", source_pointer="agent.py:8", + )]) + rows = dict( + tools=[ + ToolSurfaceToolChange(kind="added", name="fresh"), + ToolSurfaceToolChange(kind="removed", name="gone"), + ToolSurfaceToolChange(kind="changed", name="moved"), + ], + scopes=[ToolSurfaceScopeChange(kind="removed", scope="admin", scope_kind="tool_required", tool_names=["gone"])], + controls=[ToolSurfaceControlChange(kind="added", control="approval_policy", tool="moved")], + ) + action = ActionSurfaceDiff( + enabled=True, + removed=[ActionSurfaceChange(type="ACTION_REMOVED", action_id="a.gone", tool_name="gone", reason="removed")], + ) + + complete = build_capability_change(_diffed_report( + tool_surface_diff=ToolSurfaceDiff(enabled=True, **rows), action_surface_diff=action, + )) + assert [m.tool for m in complete.removed] == ["gone", "gone"] and complete.narrowed + + withheld = build_capability_change(_diffed_report( + tool_surface_diff=ToolSurfaceDiff( + enabled=True, notes=binding_comparison_limits(partial, None, absence_only=True), **rows, + ), + action_surface_diff=action, + )) + assert withheld.removed == [] and withheld.narrowed == [] + assert [m.tool for m in withheld.added] == ["fresh"] + assert [m.tool for m in withheld.broadened] == ["moved"] + + def test_capability_change_enriches_semantic_fields_from_action_facts(): from agents_shipgate.schemas.surfaces import ( ActionApprovalFact, diff --git a/tests/test_verify.py b/tests/test_verify.py index d0a7b144f..088f53c16 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -1096,6 +1096,29 @@ def test_capability_review_pr_comment_leads_with_top_changes_and_trust_root() -> ) +def test_capability_review_says_removed_and_narrowed_were_withheld_not_absent() -> None: + """Zero removed beside an enabled comparison would read as "none removed".""" + from agents_shipgate.core.binding_comparison import binding_comparison_limits + from agents_shipgate.schemas.bindings import AgentBindingGraphAssessment, AgentBindingIssue + from agents_shipgate.schemas.surfaces import ToolSurfaceDiff, ToolSurfaceToolChange + + partial = AgentBindingGraphAssessment(status="partial", issues=[AgentBindingIssue( + kind="partial_binding_evidence", message="Unread constructor", + source="framework_constructor_ownership", source_pointer="agent.py:8", + )]) + notes = binding_comparison_limits(partial, None, absence_only=True) + report = _report(decision="passed", exit_code=0) + report.tool_surface_diff = ToolSurfaceDiff( + enabled=True, tools=[ToolSurfaceToolChange(kind="added", name="fresh")], notes=["unrelated", *notes], + ) + review = build_capability_review(report) + assert review.added == 1 and review.removed == 0 + assert review.notes == notes + + report.tool_surface_diff = ToolSurfaceDiff(enabled=True, tools=[ToolSurfaceToolChange(kind="added", name="fresh")]) + assert build_capability_review(report).notes == [] + + def test_capability_review_groups_one_bound_tool_without_changing_legacy_counts() -> None: """One reader subject may still carry two stable machine change rows (#439)."""