From 690640c75d6847d59f6d2c58859cc54c70fa6a3c Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 08:54:04 +0900 Subject: [PATCH 01/70] test(config): enforce reference wiring contract --- .github/workflows/lint.yml | 7 +- docs/config-reference.md | 26 +- scripts/check-config-reference-contract.py | 588 ++++++++++++++++++ .../test_check_config_reference_contract.py | 360 +++++++++++ 4 files changed, 971 insertions(+), 10 deletions(-) create mode 100644 scripts/check-config-reference-contract.py create mode 100644 tests/unit/scripts/test_check_config_reference_contract.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 72668c3ba4..a484bfd1f2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,11 +31,14 @@ jobs: - name: Run ruff run: | - uv run ruff check src/ tests/ + uv run ruff check src/ tests/ scripts/check-config-reference-contract.py - name: Run ruff format check run: | - uv run ruff format --check src/ tests/ + uv run ruff format --check src/ tests/ scripts/check-config-reference-contract.py + + - name: Verify config reference contract + run: uv run python scripts/check-config-reference-contract.py mypy: name: MyPy Type Check diff --git a/docs/config-reference.md b/docs/config-reference.md index f761921653..81e44b7884 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -441,14 +441,20 @@ evaluation: | Option | Type | Default | Description | |--------|------|---------|-------------| -| `stage1_enabled` | `bool` | `true` | **Currently inert in `config.yaml`.** Runtime builders do not copy this field into `PipelineConfig`. | -| `stage2_enabled` | `bool` | `true` | **Currently inert in `config.yaml`.** Runtime builders do not copy this field into `PipelineConfig`. | -| `stage3_enabled` | `bool` | `true` | **Currently inert in `config.yaml`.** Runtime builders do not copy this field into `PipelineConfig`. | -| `satisfaction_threshold` | `float [0.0, 1.0]` | `0.8` | **Currently inert.** The field is validated but the pipeline compares Stage 2 scores against a hardcoded `0.8`; changing this value does not change the gate. See [Evaluation Pipeline Guide](./guides/evaluation-pipeline.md#stage-2-semantic-evaluation). | -| `uncertainty_threshold` | `float [0.0, 1.0]` | `0.3` | **Currently inert in `config.yaml`.** Runtime builders do not copy it into `TriggerConfig`. | +| `stage1_enabled` | `bool` | `true` | **Currently inert in `config.yaml`.** Runtime builders do not copy this field into `PipelineConfig`. **Effective control:** direct-Python `PipelineConfig.stage1_enabled`. | +| `stage2_enabled` | `bool` | `true` | **Currently inert in `config.yaml`.** Runtime builders do not copy this field into `PipelineConfig`. **Effective control:** direct-Python `PipelineConfig.stage2_enabled`. | +| `stage3_enabled` | `bool` | `true` | **Currently inert in `config.yaml`.** Runtime builders do not copy this field into `PipelineConfig`. **Effective control:** direct-Python `PipelineConfig.stage3_enabled`. | +| `satisfaction_threshold` | `float [0.0, 1.0]` | `0.8` | **Currently inert.** The field is validated but the pipeline compares Stage 2 scores against a hardcoded `0.8`; changing this value does not change the gate. **Effective control:** the hardcoded `0.8` comparison. See [Evaluation Pipeline Guide](./guides/evaluation-pipeline.md#stage-2-semantic-evaluation). | +| `uncertainty_threshold` | `float [0.0, 1.0]` | `0.3` | **Currently inert in `config.yaml`.** Runtime builders do not copy it into `TriggerConfig`. **Effective control:** direct-Python `TriggerConfig.uncertainty_threshold`. | | `semantic_model` | `string` | `"claude-opus-4-8"` | Model used for Stage 2 semantic evaluation. Overridable via `OUROBOROS_SEMANTIC_MODEL`. | | `assertion_extraction_model` | `string` | `"claude-sonnet-4-6"` | Model used for extracting verification assertions from seed criteria. Overridable via `OUROBOROS_ASSERTION_EXTRACTION_MODEL`. | + + + + + + > **Configuration boundary:** the top-level `evaluation.stage1_enabled`, `stage2_enabled`, `stage3_enabled`, and `uncertainty_threshold` keys are schema-validated placeholders, not runtime controls. The similarly named direct-Python `PipelineConfig.stage*_enabled` fields and `TriggerConfig.uncertainty_threshold` are separate and active when explicitly supplied to `EvaluationPipeline`; see [Disabling Stages](./guides/evaluation-pipeline.md#disabling-stages) and [Trigger Configuration](./guides/evaluation-pipeline.md#trigger-configuration). --- @@ -481,14 +487,18 @@ consensus: | Option | Type | Default | Description | |--------|------|---------|-------------| -| `min_models` | `int >= 2` | `3` | **Currently inert.** After reviewer-independence filtering, simple consensus separately requires at least two successfully collected votes; this top-level field is not wired to that rule. | -| `threshold` | `float [0.0, 1.0]` | `0.67` | **Currently inert.** Runtime simple consensus compares approvals divided by successful post-filter votes with direct-Python `ConsensusConfig.majority_threshold` (default `0.66`); this top-level field is not copied into it. | -| `diversity_required` | `bool` | `true` | **Currently inert.** The field exists on `ConsensusConfig` and in the schema, but nothing reads it. Provider diversity depends on actual adapter routing; neither this flag nor differently named roster entries attest it. See [Evaluation Pipeline Guide](./guides/evaluation-pipeline.md#stage-3-consensus-multi-model-or-single-model-fallback). | +| `min_models` | `int >= 2` | `3` | **Currently inert.** After reviewer-independence filtering, simple consensus separately requires at least two successfully collected votes; this top-level field is not wired to that rule. **Effective control:** the hardcoded minimum of two successfully collected post-filter votes. | +| `threshold` | `float [0.0, 1.0]` | `0.67` | **Currently inert.** Runtime simple consensus compares approvals divided by successful post-filter votes with direct-Python `ConsensusConfig.majority_threshold` (default `0.66`); this top-level field is not copied into it. **Effective control:** `ConsensusConfig.majority_threshold`. | +| `diversity_required` | `bool` | `true` | **Currently inert.** The field exists on `ConsensusConfig` and in the schema, but nothing reads it. Provider diversity depends on actual adapter routing; neither this flag nor differently named roster entries attest it. **Effective control:** actual adapter routing and reviewer-independence filtering. See [Evaluation Pipeline Guide](./guides/evaluation-pipeline.md#stage-3-consensus-multi-model-or-single-model-fallback). | | `models` | `list[string]` | (see above) | Model roster for Stage 3 simple voting. With `llm.backend: litellm`, use `provider/model` or `openrouter/provider/model`. With `llm.backend: codex`, use Codex/OpenAI model IDs such as `gpt-5.4`. Overridable via `OUROBOROS_CONSENSUS_MODELS` (comma-separated). | | `advocate_model` | `string` | `"openrouter/anthropic/claude-opus-4.8"` | Model that argues in favor of the proposed solution in deliberative consensus. With `llm.backend: codex`, this can be a Codex/OpenAI model ID such as `gpt-5.4`. Overridable via `OUROBOROS_CONSENSUS_ADVOCATE_MODEL`. | | `devil_model` | `string` | `"openrouter/openai/gpt-4o"` | Model that argues against (devil's advocate) in deliberative consensus. With `llm.backend: codex`, this can be a Codex/OpenAI model ID such as `gpt-5.4`. Overridable via `OUROBOROS_CONSENSUS_DEVIL_MODEL`. | | `judge_model` | `string` | `"openrouter/google/gemini-2.5-pro"` | Model that renders a final verdict after deliberation. With `llm.backend: codex`, this can be a Codex/OpenAI model ID such as `gpt-5.4`. Overridable via `OUROBOROS_CONSENSUS_JUDGE_MODEL`. | + + + + > **Configuration boundary:** `consensus.min_models` and `consensus.threshold` are schema-validated placeholders. Runtime simple consensus hardcodes a minimum of two successful post-filter votes and reads the separate direct-Python `ConsensusConfig.majority_threshold`. Changing these YAML keys does not change either rule. > > **Backend note:** With `llm.backend: litellm`, consensus models typically go through OpenRouter/LiteLLM and require the corresponding provider credentials (commonly `OPENROUTER_API_KEY`). With `llm.backend: codex`, the configured model strings are sent through Codex CLI instead. diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py new file mode 100644 index 0000000000..885be3c944 --- /dev/null +++ b/scripts/check-config-reference-contract.py @@ -0,0 +1,588 @@ +#!/usr/bin/env python3 +"""Keep evaluation/consensus schema, runtime wiring, and docs in agreement. + +Every field in the user-facing ``EvaluationConfig`` and ``ConsensusConfig`` +schema must have exactly one truthful disposition: + +* production code reads it from the corresponding config section; +* ``docs/config-reference.md`` marks it as inert and names the effective + control; or +* it is present in the explicit, justified schema-only allowlist below. + +The scan is syntax-aware. It inspects Python attribute loads rather than source +text, and it reads structured Markdown table rows plus JSON contract markers. +That lets the same check catch both directions of drift: a new unwired field, +and a previously inert field that becomes wired while the reference still says +it does nothing. +""" + +from __future__ import annotations + +import ast +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +import json +from pathlib import Path +import re +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC_ROOT = REPO_ROOT / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from ouroboros.config._model_defaults import ( # noqa: E402 + DEFAULT_CONSENSUS_OPUS_MODEL, + DEFAULT_OPUS_MODEL, +) +from ouroboros.config.models import ConsensusConfig, EvaluationConfig # noqa: E402 + + +@dataclass(frozen=True, order=True) +class ConfigField: + """One field in a named user-facing config section.""" + + section: str + name: str + + @property + def dotted(self) -> str: + return f"{self.section}.{self.name}" + + +@dataclass(frozen=True) +class ReferenceRow: + """Relevant cells from one config-reference field row.""" + + default: str + description: str + + +@dataclass(frozen=True) +class InertMarker: + """Machine-readable companion to one visible inert-field description.""" + + field: ConfigField + effective_control: str + + +@dataclass(frozen=True) +class ContractReport: + """All violations found in one complete contract audit.""" + + violations: tuple[str, ...] + runtime_reads: frozenset[ConfigField] + + +TRACKED_SECTIONS = frozenset({"evaluation", "consensus"}) +REFERENCE_PATH = Path("docs/config-reference.md") +_SECTION_HEADING = re.compile(r"^## `(?P
evaluation|consensus)`\s*$") +_FIELD_ROW = re.compile(r"^\|\s*`(?P[a-z][a-z0-9_]*)`\s*\|") +_INERT_MARKER = re.compile(r"") + +# Escape hatch for a deliberately schema-only field that should not be called +# inert in the public reference. Keep this small. Every entry must carry a +# concrete rationale; the audit rejects stale entries once production reads the +# field. The eight fields motivating #1998 are documented, so none belong here. +SCHEMA_ONLY_ALLOWLIST: Mapping[ConfigField, str] = {} + +# Bounded guard for the two Opus identifiers that intentionally use different +# normalized forms. This catches a blanket replacement without turning the +# checker into a general-purpose documentation-value synchronizer. +DOCUMENTED_DEFAULTS: Mapping[ConfigField, str] = { + ConfigField("evaluation", "semantic_model"): DEFAULT_OPUS_MODEL, + ConfigField("consensus", "advocate_model"): DEFAULT_CONSENSUS_OPUS_MODEL, +} + + +def schema_fields() -> frozenset[ConfigField]: + """Enumerate the authoritative Pydantic fields for the two sections.""" + + return frozenset( + ConfigField(section, name) + for section, model in ( + ("evaluation", EvaluationConfig), + ("consensus", ConsensusConfig), + ) + for name in model.model_fields + ) + + +_CONFIG_ROOT = "" +_UNKNOWN_STATE: frozenset[str] = frozenset() +_CONFIG_NAME = re.compile( + r"(?:^|_)(?:cfg|config|configs|configuration|settings)$", + flags=re.IGNORECASE, +) +_CONFIG_FACTORY = re.compile( + r"(?:^|_)(?:build|create|get|load|read|resolve)_(?:config|configuration|settings)$", + flags=re.IGNORECASE, +) + + +def _looks_like_config_name(name: str) -> bool: + return _CONFIG_NAME.search(name) is not None + + +def _callable_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return node.attr + return None + + +def _annotation_names_config(annotation: ast.AST | None) -> bool: + if annotation is None: + return False + return any( + ( + isinstance(node, ast.Name) + and ("config" in node.id.lower() or "settings" in node.id.lower()) + ) + or ( + isinstance(node, ast.Attribute) + and ("config" in node.attr.lower() or "settings" in node.attr.lower()) + ) + or ( + isinstance(node, ast.Constant) + and isinstance(node.value, str) + and ("config" in node.value.lower() or "settings" in node.value.lower()) + ) + for node in ast.walk(annotation) + ) + + +class _RuntimeReadVisitor(ast.NodeVisitor): + """Collect reads proven to originate from application configuration. + + Each scope tracks abstract provenance rather than matching arbitrary + ``*.evaluation.field`` text. A value can be a config root, one or more + tracked section aliases, or unknown. Conditional control flow joins the + possible states, while an unconditional reassignment replaces them. + """ + + def __init__(self, fields: frozenset[ConfigField]) -> None: + self._fields = fields + # An explicit empty state is a deliberate shadow. Without it, a + # function parameter or local reassignment could inherit an outer alias. + self._states: list[dict[str, frozenset[str]]] = [{}] + self.reads: set[ConfigField] = set() + + def _name_state(self, name: str) -> frozenset[str]: + for scope in reversed(self._states): + if name in scope: + return scope[name] + if _looks_like_config_name(name): + return frozenset({_CONFIG_ROOT}) + return _UNKNOWN_STATE + + def _expression_state(self, node: ast.AST) -> frozenset[str]: + if isinstance(node, ast.Name): + return self._name_state(node.id) + if isinstance(node, ast.Attribute): + owner_state = self._expression_state(node.value) + if node.attr in TRACKED_SECTIONS and _CONFIG_ROOT in owner_state: + return frozenset({node.attr}) + if _looks_like_config_name(node.attr): + return frozenset({_CONFIG_ROOT}) + return _UNKNOWN_STATE + if isinstance(node, ast.Call): + callable_name = _callable_name(node.func) + if callable_name is not None and _CONFIG_FACTORY.search(callable_name): + return frozenset({_CONFIG_ROOT}) + if ( + isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and len(node.args) >= 2 + and isinstance(node.args[1], ast.Constant) + and node.args[1].value in TRACKED_SECTIONS + and _CONFIG_ROOT in self._expression_state(node.args[0]) + ): + return frozenset({node.args[1].value}) + return _UNKNOWN_STATE + if isinstance(node, ast.Subscript): + state = self._expression_state(node.value) + if _CONFIG_ROOT in state: + return frozenset({_CONFIG_ROOT}) + return _UNKNOWN_STATE + if isinstance(node, ast.IfExp): + return self._expression_state(node.body) | self._expression_state(node.orelse) + if isinstance(node, ast.BoolOp): + state = _UNKNOWN_STATE + for value in node.values: + state |= self._expression_state(value) + return state + if isinstance(node, ast.NamedExpr): + return self._expression_state(node.value) + return _UNKNOWN_STATE + + def _record(self, section: str, name: str) -> None: + field = ConfigField(section, name) + if field in self._fields: + self.reads.add(field) + + def visit_Attribute(self, node: ast.Attribute) -> None: + if isinstance(node.ctx, ast.Load): + for section in self._expression_state(node.value) & TRACKED_SECTIONS: + self._record(section, node.attr) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + # ``getattr(config.evaluation, "field")`` is still an explicit read. + if ( + isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and len(node.args) >= 2 + and isinstance(node.args[1], ast.Constant) + and isinstance(node.args[1].value, str) + ): + for section in self._expression_state(node.args[0]) & TRACKED_SECTIONS: + self._record(section, node.args[1].value) + self.generic_visit(node) + + def _bind_aliases(self, targets: Iterable[ast.expr], value: ast.AST) -> None: + state = self._expression_state(value) + for target in targets: + if isinstance(target, ast.Name): + self._states[-1][target.id] = state + + def visit_Assign(self, node: ast.Assign) -> None: + self.visit(node.value) + self._bind_aliases(node.targets, node.value) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + if node.value is not None: + self.visit(node.value) + self._bind_aliases((node.target,), node.value) + + def visit_NamedExpr(self, node: ast.NamedExpr) -> None: + self.visit(node.value) + self._bind_aliases((node.target,), node.value) + + def _visit_branch( + self, + statements: Iterable[ast.stmt], + initial: dict[str, frozenset[str]], + ) -> dict[str, frozenset[str]]: + self._states[-1] = dict(initial) + for statement in statements: + self.visit(statement) + return dict(self._states[-1]) + + def visit_If(self, node: ast.If) -> None: + self.visit(node.test) + initial = dict(self._states[-1]) + body_state = self._visit_branch(node.body, initial) + else_state = self._visit_branch(node.orelse, initial) if node.orelse else initial + self._states[-1] = { + name: body_state.get(name, _UNKNOWN_STATE) | else_state.get(name, _UNKNOWN_STATE) + for name in body_state.keys() | else_state.keys() + } + + @staticmethod + def _argument_state(argument: ast.arg) -> frozenset[str]: + if _looks_like_config_name(argument.arg) or _annotation_names_config(argument.annotation): + return frozenset({_CONFIG_ROOT}) + return _UNKNOWN_STATE + + def _scoped_arguments(self, arguments: ast.arguments) -> dict[str, frozenset[str]]: + scoped = { + argument.arg: self._argument_state(argument) + for argument in ( + *arguments.posonlyargs, + *arguments.args, + *arguments.kwonlyargs, + ) + } + if arguments.vararg is not None: + scoped[arguments.vararg.arg] = self._argument_state(arguments.vararg) + if arguments.kwarg is not None: + scoped[arguments.kwarg.arg] = self._argument_state(arguments.kwarg) + return scoped + + def _visit_scoped(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + # Defaults and decorators execute in the containing scope. + for decorator in node.decorator_list: + self.visit(decorator) + for default in (*node.args.defaults, *node.args.kw_defaults): + if default is not None: + self.visit(default) + + self._states.append(self._scoped_arguments(node.args)) + try: + for statement in node.body: + self.visit(statement) + finally: + self._states.pop() + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_scoped(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_scoped(node) + + def visit_Lambda(self, node: ast.Lambda) -> None: + for default in (*node.args.defaults, *node.args.kw_defaults): + if default is not None: + self.visit(default) + self._states.append(self._scoped_arguments(node.args)) + try: + self.visit(node.body) + finally: + self._states.pop() + + +def runtime_reads(source_root: Path, fields: frozenset[ConfigField]) -> frozenset[ConfigField]: + """Return config fields loaded from their named sections in production Python.""" + + reads: set[ConfigField] = set() + for path in sorted(source_root.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + visitor = _RuntimeReadVisitor(fields) + visitor.visit(tree) + reads.update(visitor.reads) + return frozenset(reads) + + +def _split_markdown_row(line: str) -> tuple[str, ...]: + """Split a Markdown table row without treating escaped pipes as separators.""" + + cells: list[str] = [] + current: list[str] = [] + escaped = False + for character in line.strip().strip("|"): + if escaped: + current.append(character) + escaped = False + elif character == "\\": + current.append(character) + escaped = True + elif character == "|": + cells.append("".join(current).strip()) + current = [] + else: + current.append(character) + cells.append("".join(current).strip()) + return tuple(cells) + + +def parse_reference_rows(text: str) -> dict[ConfigField, ReferenceRow]: + """Parse field rows from the two tracked config-reference sections.""" + + section: str | None = None + rows: dict[ConfigField, ReferenceRow] = {} + for line in text.splitlines(): + heading = _SECTION_HEADING.match(line) + if heading is not None: + section = heading.group("section") + continue + if line.startswith("## "): + section = None + continue + field_match = _FIELD_ROW.match(line) + if section is None or field_match is None: + continue + cells = _split_markdown_row(line) + if len(cells) != 4: + raise ValueError(f"malformed config-reference row: {line}") + field = ConfigField(section, field_match.group("field")) + if field in rows: + raise ValueError(f"duplicate config-reference row for {field.dotted}") + rows[field] = ReferenceRow(default=cells[2], description=cells[3]) + return rows + + +def parse_inert_markers(text: str) -> dict[ConfigField, InertMarker]: + """Parse JSON markers that make inert documentation machine-checkable.""" + + markers: dict[ConfigField, InertMarker] = {} + for match in _INERT_MARKER.finditer(text): + payload = json.loads(match.group("payload")) + if set(payload) != {"section", "field", "status", "effective_control"}: + raise ValueError("config-field-contract marker has an invalid schema") + if payload["status"] != "inert": + raise ValueError("config-field-contract marker status must be inert") + if payload["section"] not in TRACKED_SECTIONS: + raise ValueError("config-field-contract marker has an unknown section") + if not isinstance(payload["field"], str) or not re.fullmatch( + r"[a-z][a-z0-9_]*", payload["field"] + ): + raise ValueError("config-field-contract marker has an invalid field name") + field = ConfigField(payload["section"], payload["field"]) + effective_control = payload["effective_control"] + if not isinstance(effective_control, str) or not effective_control.strip(): + raise ValueError(f"inert marker for {field.dotted} needs an effective control") + if field in markers: + raise ValueError(f"duplicate inert marker for {field.dotted}") + markers[field] = InertMarker(field=field, effective_control=effective_control) + return markers + + +def _literal_default(value: str) -> str: + normalized = value.strip() + if normalized.startswith("`") and normalized.endswith("`"): + normalized = normalized[1:-1].strip() + if normalized.startswith('"') and normalized.endswith('"'): + normalized = normalized[1:-1] + return normalized + + +def opus_default_violations(direct_model: str, consensus_model: str) -> tuple[str, ...]: + """Validate the intentionally different direct and OpenRouter Opus ids.""" + + direct_pattern = re.compile(r"claude-opus-(?P[1-9][0-9]*)-(?P0|[1-9][0-9]*)") + consensus_pattern = re.compile( + r"openrouter/anthropic/claude-opus-" + r"(?P[1-9][0-9]*)\.(?P0|[1-9][0-9]*)" + ) + direct_match = direct_pattern.fullmatch(direct_model) + consensus_match = consensus_pattern.fullmatch(consensus_model) + violations: list[str] = [] + + if direct_model == consensus_model: + violations.append("Opus defaults must use distinct direct and OpenRouter identifiers") + if direct_match is None: + violations.append( + "evaluation.semantic_model: invalid direct Opus default " + f"{direct_model!r}; expected 'claude-opus--'" + ) + if consensus_match is None: + violations.append( + "consensus.advocate_model: invalid OpenRouter Opus default " + f"{consensus_model!r}; expected " + "'openrouter/anthropic/claude-opus-.'" + ) + if direct_match is not None and consensus_match is not None: + direct_version = direct_match.group("major", "minor") + consensus_version = consensus_match.group("major", "minor") + if direct_version != consensus_version: + expected_consensus = ( + f"openrouter/anthropic/claude-opus-{direct_version[0]}.{direct_version[1]}" + ) + violations.append( + "consensus.advocate_model: OpenRouter Opus default " + f"{consensus_model!r} does not correspond to direct default {direct_model!r}; " + f"expected {expected_consensus!r}" + ) + return tuple(violations) + + +def audit_contract( + *, + fields: frozenset[ConfigField], + reads: frozenset[ConfigField], + rows: Mapping[ConfigField, ReferenceRow], + markers: Mapping[ConfigField, InertMarker], + allowlist: Mapping[ConfigField, str], + documented_defaults: Mapping[ConfigField, str], +) -> ContractReport: + """Classify every field and return precise bidirectional drift failures.""" + + violations: list[str] = [] + marker_fields = frozenset(markers) + allowlisted_fields = frozenset(allowlist) + + for stale in sorted((marker_fields | allowlisted_fields | frozenset(rows)) - fields): + violations.append(f"{stale.dotted}: docs/allowlist names no schema field") + + for field, reason in sorted(allowlist.items()): + if not isinstance(reason, str) or len(reason.strip()) < 20: + violations.append(f"{field.dotted}: schema-only allowlist needs a concrete rationale") + if field in reads: + violations.append( + f"{field.dotted}: production-wired field remains schema-only allowlisted" + ) + if field in marker_fields: + violations.append(f"{field.dotted}: field is both documented inert and allowlisted") + + for field in sorted(fields): + dispositions = ( + int(field in reads) + int(field in marker_fields) + int(field in allowlisted_fields) + ) + if dispositions == 0: + violations.append( + f"{field.dotted}: no production read, inert documentation, or schema-only rationale" + ) + elif dispositions > 1: + violations.append(f"{field.dotted}: conflicting config-field dispositions") + + row = rows.get(field) + if row is None: + violations.append(f"{field.dotted}: missing docs/config-reference.md field row") + continue + visibly_inert = "currently inert" in row.description.lower() + if field in reads and visibly_inert: + violations.append(f"{field.dotted}: production-wired field is still documented inert") + if field in marker_fields: + marker = markers[field] + if not visibly_inert: + violations.append( + f"{field.dotted}: inert marker lacks visible 'Currently inert' text" + ) + if "effective control:" not in row.description.lower(): + violations.append(f"{field.dotted}: inert docs do not label the effective control") + if marker.effective_control not in row.description: + violations.append( + f"{field.dotted}: visible docs omit marker effective control " + f"{marker.effective_control!r}" + ) + elif visibly_inert: + violations.append(f"{field.dotted}: visible inert text lacks a structured marker") + + for field, expected in sorted(documented_defaults.items()): + if field not in fields: + violations.append(f"{field.dotted}: default contract names no schema field") + continue + row = rows.get(field) + if row is not None and _literal_default(row.default) != expected: + violations.append( + f"{field.dotted}: documented default {_literal_default(row.default)!r} " + f"does not match {expected!r}" + ) + + return ContractReport(tuple(sorted(set(violations))), reads) + + +def audit_repository(repo_root: Path = REPO_ROOT) -> ContractReport: + """Audit the checked-out repository contract.""" + + fields = schema_fields() + reference = (repo_root / REFERENCE_PATH).read_text(encoding="utf-8") + report = audit_contract( + fields=fields, + reads=runtime_reads(repo_root / "src" / "ouroboros", fields), + rows=parse_reference_rows(reference), + markers=parse_inert_markers(reference), + allowlist=SCHEMA_ONLY_ALLOWLIST, + documented_defaults=DOCUMENTED_DEFAULTS, + ) + return ContractReport( + tuple( + sorted( + set(report.violations) + | set(opus_default_violations(DEFAULT_OPUS_MODEL, DEFAULT_CONSENSUS_OPUS_MODEL)) + ) + ), + report.runtime_reads, + ) + + +def main() -> int: + try: + report = audit_repository() + except (OSError, SyntaxError, ValueError) as error: + print(f"Config reference contract could not run: {error}", file=sys.stderr) + return 2 + if report.violations: + print("Config reference contract violations:") + for violation in report.violations: + print(f"- {violation}") + return 1 + reads = ", ".join(field.dotted for field in sorted(report.runtime_reads)) + print(f"Config reference contract OK ({len(report.runtime_reads)} production reads: {reads})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py new file mode 100644 index 0000000000..0cb4aa37fd --- /dev/null +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -0,0 +1,360 @@ +"""Self-tests for ``scripts/check-config-reference-contract.py``. + +The repository-level assertion matters only if its scanner and documentation +markers fail in both directions: an unwired field must be rejected, and a +newly wired field must stop being described as inert. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import subprocess +import sys + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "scripts" / "check-config-reference-contract.py" + + +@pytest.fixture(scope="module") +def contract(): + spec = importlib.util.spec_from_file_location("check_config_reference_contract", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_current_repository_passes_standalone_contract() -> None: + result = subprocess.run( + [sys.executable, str(SCRIPT)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "Config reference contract OK" in result.stdout + + +def test_runtime_scan_finds_attribute_alias_and_literal_getattr_reads( + contract, tmp_path: Path +) -> None: + source = tmp_path / "runtime.py" + source.write_text( + """ +section = settings.evaluation +direct = settings.consensus.models +alias = section.stage1_enabled +dynamic = getattr(section, "stage2_enabled") +settings.evaluation.stage3_enabled = False +text = "settings.evaluation.satisfaction_threshold" + +def local_alias(config): + evaluation = config.evaluation + return evaluation.uncertainty_threshold + +def shadow(section): + return section.satisfaction_threshold + +section = object() +ignored_after_rebind = section.semantic_model +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "models"), + } + ) + + +def test_runtime_scan_handles_natural_roots_without_unrelated_object_false_positives( + contract, tmp_path: Path +) -> None: + source = tmp_path / "natural_roots.py" + source.write_text( + """ +factory_read = get_config().evaluation.stage1_enabled +indexed_read = configs[key].evaluation.stage2_enabled +method_read = self.config.consensus.models +loaded = get_config() +aliased_root_read = loaded.evaluation.stage3_enabled + +unrelated_attribute = report.evaluation.satisfaction_threshold +unrelated_call = build_report().evaluation.satisfaction_threshold +unrelated_subscript = reports[key].evaluation.uncertainty_threshold +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("consensus", "models"), + } + ) + + +def test_runtime_scan_joins_conditional_alias_fallbacks_but_honors_reassignment( + contract, tmp_path: Path +) -> None: + source = tmp_path / "conditional_aliases.py" + source.write_text( + """ +def branch_fallback(config, override, enabled): + section = config.evaluation + if enabled: + section = override + return section.stage1_enabled + +def expression_fallback(config, override, enabled): + section = override if enabled else config.evaluation + alternate = override or config.evaluation + return section.stage2_enabled, alternate.stage3_enabled + +def unconditional_reassignment(config): + section = config.evaluation + section = object() + return section.satisfaction_threshold +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + } + ) + + +def test_every_schema_field_needs_exactly_one_disposition(contract) -> None: + active = contract.ConfigField("evaluation", "active") + inert = contract.ConfigField("evaluation", "inert") + schema_only = contract.ConfigField("evaluation", "schema_only") + fields = frozenset({active, inert, schema_only}) + rows = { + active: contract.ReferenceRow("true", "Active runtime control."), + inert: contract.ReferenceRow( + "true", "Currently inert. Effective control: RuntimeConfig.inert." + ), + schema_only: contract.ReferenceRow("true", "Compatibility-only schema value."), + } + markers = { + inert: contract.InertMarker(inert, "RuntimeConfig.inert"), + } + + report = contract.audit_contract( + fields=fields, + reads=frozenset({active}), + rows=rows, + markers=markers, + allowlist={schema_only: "Retained to read configuration written by release 0.1.0."}, + documented_defaults={}, + ) + + assert report.violations == () + + +def test_new_unwired_field_fails_with_actionable_name(contract) -> None: + field = contract.ConfigField("evaluation", "new_knob") + + report = contract.audit_contract( + fields=frozenset({field}), + reads=frozenset(), + rows={field: contract.ReferenceRow("true", "A new control.")}, + markers={}, + allowlist={}, + documented_defaults={}, + ) + + assert report.violations == ( + "evaluation.new_knob: no production read, inert documentation, or schema-only rationale", + ) + + +def test_wired_field_cannot_remain_documented_inert(contract) -> None: + field = contract.ConfigField("consensus", "threshold") + + report = contract.audit_contract( + fields=frozenset({field}), + reads=frozenset({field}), + rows={ + field: contract.ReferenceRow( + "0.67", "Currently inert. Effective control: majority_threshold." + ) + }, + markers={field: contract.InertMarker(field, "majority_threshold")}, + allowlist={}, + documented_defaults={}, + ) + + assert "consensus.threshold: conflicting config-field dispositions" in report.violations + assert ( + "consensus.threshold: production-wired field is still documented inert" in report.violations + ) + + +def test_visible_inert_claim_requires_structured_effective_control(contract) -> None: + field = contract.ConfigField("consensus", "min_models") + + report = contract.audit_contract( + fields=frozenset({field}), + reads=frozenset(), + rows={field: contract.ReferenceRow("3", "Currently inert.")}, + markers={field: contract.InertMarker(field, "two successful votes")}, + allowlist={}, + documented_defaults={}, + ) + + assert "consensus.min_models: inert docs do not label the effective control" in ( + report.violations + ) + assert any("visible docs omit marker effective control" in item for item in report.violations) + + +def test_reference_parser_uses_sections_and_structured_json_markers(contract) -> None: + text = """ +## `evaluation` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `stage1_enabled` | `bool` | `true` | **Currently inert. Effective control:** `PipelineConfig.stage1_enabled`. | + + +""" + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.parse_reference_rows(text)[field].default == "`true`" + assert contract.parse_inert_markers(text)[field].effective_control == ( + "PipelineConfig.stage1_enabled" + ) + + +def test_opus_defaults_preserve_direct_and_openrouter_formats(contract) -> None: + assert ( + contract.opus_default_violations("claude-opus-4-8", "openrouter/anthropic/claude-opus-4.8") + == () + ) + + violations = contract.opus_default_violations("claude-opus-4-8", "claude-opus-4-8") + + assert violations == ( + "Opus defaults must use distinct direct and OpenRouter identifiers", + "consensus.advocate_model: invalid OpenRouter Opus default 'claude-opus-4-8'; " + "expected 'openrouter/anthropic/claude-opus-.'", + ) + + +@pytest.mark.parametrize( + ("direct_model", "consensus_model", "expected_fragments"), + [ + ( + "claude-opus-4.8", + "claude-opus-4.8", + ("distinct", "invalid direct", "invalid OpenRouter"), + ), + ( + "claude-opus-", + "claude-opus-", + ("distinct", "invalid direct", "invalid OpenRouter"), + ), + ( + "claude-opus-4-8", + "claude-opus-4-8", + ("distinct", "invalid OpenRouter"), + ), + ( + "claude-opus-4-8", + "openrouter/anthropic/claude-opus-4-8", + ("invalid OpenRouter",), + ), + ( + "claude-opus-4-8-extra", + "openrouter/anthropic/claude-opus-4.8", + ("invalid direct",), + ), + ], +) +def test_opus_defaults_reject_collapsed_or_malformed_formats( + contract, + direct_model: str, + consensus_model: str, + expected_fragments: tuple[str, ...], +) -> None: + violations = contract.opus_default_violations(direct_model, consensus_model) + + assert all( + any(fragment in violation for violation in violations) for fragment in expected_fragments + ) + + +def test_opus_defaults_reject_mismatched_valid_versions(contract) -> None: + violations = contract.opus_default_violations( + "claude-opus-4-8", "openrouter/anthropic/claude-opus-4.9" + ) + + assert violations == ( + "consensus.advocate_model: OpenRouter Opus default " + "'openrouter/anthropic/claude-opus-4.9' does not correspond to direct default " + "'claude-opus-4-8'; expected 'openrouter/anthropic/claude-opus-4.8'", + ) + + +def test_documented_default_must_match_its_ssot_value(contract) -> None: + field = contract.ConfigField("evaluation", "semantic_model") + + report = contract.audit_contract( + fields=frozenset({field}), + reads=frozenset({field}), + rows={field: contract.ReferenceRow('"claude-opus-4-6"', "Wired model.")}, + markers={}, + allowlist={}, + documented_defaults={field: "claude-opus-4-8"}, + ) + + assert report.violations == ( + "evaluation.semantic_model: documented default 'claude-opus-4-6' " + "does not match 'claude-opus-4-8'", + ) From 804574901694c71f5fbeb7dcf428446a9b27a918 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 09:53:44 +0900 Subject: [PATCH 02/70] fix(config): preserve runtime read provenance --- scripts/check-config-reference-contract.py | 276 +++++++++++++++++- .../test_check_config_reference_contract.py | 160 ++++++++++ 2 files changed, 431 insertions(+), 5 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 885be3c944..18bfa2e512 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -184,13 +184,24 @@ def _expression_state(self, node: ast.AST) -> frozenset[str]: owner_state = self._expression_state(node.value) if node.attr in TRACKED_SECTIONS and _CONFIG_ROOT in owner_state: return frozenset({node.attr}) - if _looks_like_config_name(node.attr): + if _looks_like_config_name(node.attr) and ( + _CONFIG_ROOT in owner_state + or isinstance(node.value, ast.Name) + and node.value.id in {"self", "cls"} + ): return frozenset({_CONFIG_ROOT}) return _UNKNOWN_STATE if isinstance(node, ast.Call): callable_name = _callable_name(node.func) if callable_name is not None and _CONFIG_FACTORY.search(callable_name): - return frozenset({_CONFIG_ROOT}) + if isinstance(node.func, ast.Name): + return frozenset({_CONFIG_ROOT}) + if isinstance(node.func, ast.Attribute) and ( + _CONFIG_ROOT in self._expression_state(node.func.value) + or isinstance(node.func.value, ast.Name) + and node.func.value.id in {"self", "cls"} + ): + return frozenset({_CONFIG_ROOT}) if ( isinstance(node.func, ast.Name) and node.func.id == "getattr" @@ -241,11 +252,93 @@ def visit_Call(self, node: ast.Call) -> None: self._record(section, node.args[1].value) self.generic_visit(node) + @staticmethod + def _join_states( + *states: Mapping[str, frozenset[str]], + ) -> dict[str, frozenset[str]]: + names = set().union(*(state.keys() for state in states)) + return { + name: frozenset().union(*(state.get(name, _UNKNOWN_STATE) for state in states)) + for name in names + } + + def _bind_target_state(self, target: ast.expr, state: frozenset[str]) -> None: + if isinstance(target, ast.Name): + self._states[-1][target.id] = state + elif isinstance(target, ast.Starred): + self._bind_target_state(target.value, state) + elif isinstance(target, (ast.Tuple, ast.List)): + for element in target.elts: + self._bind_target_state(element, state) + + def _sequence_item_state(self, value: ast.AST) -> frozenset[str]: + if isinstance(value, (ast.Tuple, ast.List, ast.Set)): + return frozenset().union( + *( + self._sequence_item_state(element.value) + if isinstance(element, ast.Starred) + else self._expression_state(element) + for element in value.elts + ) + ) + if isinstance(value, ast.Dict): + return frozenset().union( + *(self._expression_state(key) for key in value.keys if key is not None) + ) + return _UNKNOWN_STATE + + def _bind_destructured(self, target: ast.expr, value: ast.AST) -> None: + if isinstance(target, ast.Name): + self._states[-1][target.id] = self._expression_state(value) + return + if isinstance(target, ast.Starred): + self._bind_target_state(target.value, self._sequence_item_state(value)) + return + if not isinstance(target, (ast.Tuple, ast.List)): + return + + if not isinstance(value, (ast.Tuple, ast.List)): + self._bind_target_state(target, self._sequence_item_state(value)) + return + + starred = next( + ( + index + for index, element in enumerate(target.elts) + if isinstance(element, ast.Starred) + ), + None, + ) + if starred is None: + if len(target.elts) == len(value.elts): + for child_target, child_value in zip(target.elts, value.elts, strict=True): + self._bind_destructured(child_target, child_value) + else: + self._bind_target_state(target, self._sequence_item_state(value)) + return + + suffix_length = len(target.elts) - starred - 1 + if len(value.elts) < starred + suffix_length: + self._bind_target_state(target, self._sequence_item_state(value)) + return + for child_target, child_value in zip( + target.elts[:starred], value.elts[:starred], strict=True + ): + self._bind_destructured(child_target, child_value) + starred_values = value.elts[starred : len(value.elts) - suffix_length or None] + self._bind_target_state( + target.elts[starred], + frozenset().union(*(self._expression_state(item) for item in starred_values)), + ) + if suffix_length: + for child_target, child_value in zip( + target.elts[-suffix_length:], value.elts[-suffix_length:], strict=True + ): + self._bind_destructured(child_target, child_value) + def _bind_aliases(self, targets: Iterable[ast.expr], value: ast.AST) -> None: - state = self._expression_state(value) for target in targets: - if isinstance(target, ast.Name): - self._states[-1][target.id] = state + self._bind_destructured(target, value) def visit_Assign(self, node: ast.Assign) -> None: self.visit(node.value) @@ -270,6 +363,20 @@ def _visit_branch( self.visit(statement) return dict(self._states[-1]) + def _visit_paths( + self, + statements: Iterable[ast.stmt], + initial: dict[str, frozenset[str]], + ) -> tuple[dict[str, frozenset[str]], dict[str, frozenset[str]]]: + """Visit statements and retain every state where an exception may branch.""" + + self._states[-1] = dict(initial) + prefixes = dict(initial) + for statement in statements: + self.visit(statement) + prefixes = self._join_states(prefixes, self._states[-1]) + return dict(self._states[-1]), prefixes + def visit_If(self, node: ast.If) -> None: self.visit(node.test) initial = dict(self._states[-1]) @@ -280,6 +387,165 @@ def visit_If(self, node: ast.If) -> None: for name in body_state.keys() | else_state.keys() } + def _bind_iteration_target(self, target: ast.expr, iterable: ast.AST) -> None: + if isinstance(iterable, (ast.Tuple, ast.List, ast.Set)): + candidate_states: list[dict[str, frozenset[str]]] = [] + initial = dict(self._states[-1]) + for element in iterable.elts: + self._states[-1] = dict(initial) + if isinstance(element, ast.Starred): + self._bind_target_state(target, self._sequence_item_state(element.value)) + else: + self._bind_destructured(target, element) + candidate_states.append(dict(self._states[-1])) + self._states[-1] = self._join_states(*candidate_states) if candidate_states else initial + return + self._bind_target_state(target, self._sequence_item_state(iterable)) + + def visit_For(self, node: ast.For) -> None: + self.visit(node.iter) + entry = dict(self._states[-1]) + header = entry + while True: + self._states[-1] = dict(header) + self._bind_iteration_target(node.target, node.iter) + body_state = self._visit_branch(node.body, dict(self._states[-1])) + joined = self._join_states(entry, body_state) + if joined == header: + break + header = joined + # The else suite can run after zero or more iterations; a break can + # bypass it, so retain both paths. + else_state = self._visit_branch(node.orelse, header) if node.orelse else header + self._states[-1] = self._join_states(header, body_state, else_state) + + def visit_AsyncFor(self, node: ast.AsyncFor) -> None: + self.visit_For(node) + + def visit_While(self, node: ast.While) -> None: + entry = dict(self._states[-1]) + header = entry + while True: + self._states[-1] = dict(header) + self.visit(node.test) + tested_state = dict(self._states[-1]) + body_state = self._visit_branch(node.body, tested_state) + joined = self._join_states(entry, body_state) + if joined == header: + break + header = joined + # ``tested_state`` is the false-test exit; ``body_state`` also covers + # a possible break that does not execute the else suite. + else_state = self._visit_branch(node.orelse, tested_state) if node.orelse else tested_state + self._states[-1] = self._join_states(tested_state, body_state, else_state) + + def _visit_try(self, node: ast.Try | ast.TryStar) -> None: + entry = dict(self._states[-1]) + body_state, exception_states = self._visit_paths(node.body, entry) + normal_state = self._visit_branch(node.orelse, body_state) if node.orelse else body_state + completed = [normal_state] + for handler in node.handlers: + self._states[-1] = dict(exception_states) + if handler.type is not None: + self.visit(handler.type) + if handler.name is not None: + self._states[-1][handler.name] = _UNKNOWN_STATE + completed.append(self._visit_branch(handler.body, dict(self._states[-1]))) + + if node.finalbody: + # ``finally`` observes normal, handled, and still-propagating + # exception paths. Applying it to their may-join preserves every + # possible config origin without inventing a report origin. + incoming = self._join_states(exception_states, *completed) + self._states[-1] = self._visit_branch(node.finalbody, incoming) + else: + self._states[-1] = self._join_states(*completed) + + def visit_Try(self, node: ast.Try) -> None: + self._visit_try(node) + + def visit_TryStar(self, node: ast.TryStar) -> None: + self._visit_try(node) + + def _bind_pattern(self, pattern: ast.pattern, subject: ast.AST) -> None: + if isinstance(pattern, ast.MatchAs): + if pattern.pattern is not None: + self._bind_pattern(pattern.pattern, subject) + if pattern.name is not None: + self._states[-1][pattern.name] = self._expression_state(subject) + return + if isinstance(pattern, ast.MatchStar): + if pattern.name is not None: + self._states[-1][pattern.name] = self._sequence_item_state(subject) + return + if isinstance(pattern, ast.MatchOr): + initial = dict(self._states[-1]) + alternatives: list[dict[str, frozenset[str]]] = [] + for alternative in pattern.patterns: + self._states[-1] = dict(initial) + self._bind_pattern(alternative, subject) + alternatives.append(dict(self._states[-1])) + self._states[-1] = self._join_states(*alternatives) + return + if isinstance(pattern, ast.MatchSequence): + if isinstance(subject, (ast.Tuple, ast.List)) and len(pattern.patterns) == len( + subject.elts + ): + for child_pattern, child_subject in zip( + pattern.patterns, subject.elts, strict=True + ): + self._bind_pattern(child_pattern, child_subject) + else: + state = self._sequence_item_state(subject) + for child_pattern in pattern.patterns: + self._bind_pattern_state(child_pattern, state) + return + if isinstance(pattern, ast.MatchMapping): + for child_pattern in pattern.patterns: + self._bind_pattern_state(child_pattern, _UNKNOWN_STATE) + if pattern.rest is not None: + self._states[-1][pattern.rest] = _UNKNOWN_STATE + return + if isinstance(pattern, ast.MatchClass): + for child_pattern in (*pattern.patterns, *pattern.kwd_patterns): + self._bind_pattern_state(child_pattern, _UNKNOWN_STATE) + + def _bind_pattern_state(self, pattern: ast.pattern, state: frozenset[str]) -> None: + if isinstance(pattern, ast.MatchAs): + if pattern.pattern is not None: + self._bind_pattern_state(pattern.pattern, state) + if pattern.name is not None: + self._states[-1][pattern.name] = state + elif isinstance(pattern, ast.MatchStar): + if pattern.name is not None: + self._states[-1][pattern.name] = state + elif isinstance(pattern, ast.MatchOr): + for alternative in pattern.patterns: + self._bind_pattern_state(alternative, state) + elif isinstance(pattern, ast.MatchSequence): + for child_pattern in pattern.patterns: + self._bind_pattern_state(child_pattern, state) + elif isinstance(pattern, ast.MatchMapping): + for child_pattern in pattern.patterns: + self._bind_pattern_state(child_pattern, state) + if pattern.rest is not None: + self._states[-1][pattern.rest] = state + elif isinstance(pattern, ast.MatchClass): + for child_pattern in (*pattern.patterns, *pattern.kwd_patterns): + self._bind_pattern_state(child_pattern, state) + + def visit_Match(self, node: ast.Match) -> None: + self.visit(node.subject) + initial = dict(self._states[-1]) + branches = [initial] + for case in node.cases: + self._states[-1] = dict(initial) + self._bind_pattern(case.pattern, node.subject) + if case.guard is not None: + self.visit(case.guard) + branches.append(self._visit_branch(case.body, dict(self._states[-1]))) + self._states[-1] = self._join_states(*branches) + @staticmethod def _argument_state(argument: ast.arg) -> frozenset[str]: if _looks_like_config_name(argument.arg) or _annotation_names_config(argument.annotation): diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 0cb4aa37fd..7f6782d72d 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -100,9 +100,14 @@ def test_runtime_scan_handles_natural_roots_without_unrelated_object_false_posit loaded = get_config() aliased_root_read = loaded.evaluation.stage3_enabled +class Runtime: + def read(self): + return self.get_config().evaluation.semantic_model + unrelated_attribute = report.evaluation.satisfaction_threshold unrelated_call = build_report().evaluation.satisfaction_threshold unrelated_subscript = reports[key].evaluation.uncertainty_threshold +unrelated_factory_method = report.get_config().consensus.threshold """, encoding="utf-8", ) @@ -112,8 +117,10 @@ def test_runtime_scan_handles_natural_roots_without_unrelated_object_false_posit contract.ConfigField("evaluation", "stage2_enabled"), contract.ConfigField("evaluation", "stage3_enabled"), contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "semantic_model"), contract.ConfigField("evaluation", "uncertainty_threshold"), contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), } ) @@ -122,6 +129,7 @@ def test_runtime_scan_handles_natural_roots_without_unrelated_object_false_posit contract.ConfigField("evaluation", "stage1_enabled"), contract.ConfigField("evaluation", "stage2_enabled"), contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "semantic_model"), contract.ConfigField("consensus", "models"), } ) @@ -169,6 +177,158 @@ def unconditional_reassignment(config): ) +def test_runtime_scan_preserves_zero_iteration_and_loop_body_provenance( + contract, tmp_path: Path +) -> None: + (tmp_path / "loops.py").write_text( + """ +def loop_paths(config, report, enabled): + section = config.evaluation + for section in []: + pass + zero_iteration_read = section.stage1_enabled + + section = report.evaluation + while enabled: + section = config.evaluation + possible_iteration_read = section.stage2_enabled + + for candidate, ignored in ( + (report.evaluation, 0), + (config.evaluation, 1), + ): + loop_destructured_read = candidate.stage3_enabled + + unrelated = report.config.evaluation.satisfaction_threshold +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + } + ) + + +def test_runtime_scan_joins_successful_try_handler_and_finally_paths( + contract, tmp_path: Path +) -> None: + (tmp_path / "try_paths.py").write_text( + """ +def try_paths(config, report, risky): + successful = config.evaluation + try: + risky() + except ValueError: + successful = report.evaluation + successful_path_read = successful.stage1_enabled + + handler_source = report.evaluation + try: + handler_source = config.evaluation + risky() + handler_source = report.evaluation + except RuntimeError: + handler_read = handler_source.stage2_enabled + + final_source = report.consensus + try: + final_source = config.consensus + risky() + except LookupError: + final_source = report.consensus + finally: + final_read = final_source.models + + unrelated = report.settings.consensus.threshold +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("consensus", "models"), + } + ) + + +def test_runtime_scan_joins_match_branches_and_destructured_aliases( + contract, tmp_path: Path +) -> None: + (tmp_path / "match_and_destructure.py").write_text( + """ +def match_paths(config, report, selector): + evaluation_alias, other = config.evaluation, report.evaluation + assigned_evaluation_read = evaluation_alias.uncertainty_threshold + + [list_alias] = [config.evaluation] + assigned_list_read = list_alias.satisfaction_threshold + + consensus_alias, ignored_alias = config.consensus, report.evaluation + assigned_consensus_read = consensus_alias.devil_model + + section = report.evaluation + match selector: + case 0: + section = config.evaluation + case _: + section = report.evaluation + branch_read = section.stage1_enabled + + match (config.evaluation, (config.consensus, report.evaluation)): + case (evaluation, (consensus, ignored)): + destructured_evaluation_read = evaluation.stage2_enabled + destructured_consensus_read = consensus.models + + unrelated = report.config.consensus.threshold +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "models"), + } + ) + + def test_every_schema_field_needs_exactly_one_disposition(contract) -> None: active = contract.ConfigField("evaluation", "active") inert = contract.ConfigField("evaluation", "inert") From 6a4ff8f69f5ab8d9efe137cb6f800d230b275f53 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 10:23:25 +0900 Subject: [PATCH 03/70] fix(config): track structured runtime provenance --- scripts/check-config-reference-contract.py | 609 +++++++++++++----- .../test_check_config_reference_contract.py | 217 +++++++ 2 files changed, 649 insertions(+), 177 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 18bfa2e512..2e1315f7ba 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -109,7 +109,6 @@ def schema_fields() -> frozenset[ConfigField]: _CONFIG_ROOT = "" -_UNKNOWN_STATE: frozenset[str] = frozenset() _CONFIG_NAME = re.compile( r"(?:^|_)(?:cfg|config|configs|configuration|settings)$", flags=re.IGNORECASE, @@ -153,80 +152,212 @@ def _annotation_names_config(annotation: ast.AST | None) -> bool: ) -class _RuntimeReadVisitor(ast.NodeVisitor): - """Collect reads proven to originate from application configuration. +@dataclass(frozen=True) +class _AbstractValue: + """Possible config provenance plus bounded container/object shape.""" + + origins: frozenset[str] = frozenset() + items: tuple[_AbstractValue, ...] | None = None + entries: tuple[tuple[str, _AbstractValue], ...] | None = None + attributes: tuple[tuple[str, _AbstractValue], ...] | None = None + + +_UNKNOWN_VALUE = _AbstractValue() + + +def _origin_value(*origins: str) -> _AbstractValue: + return _AbstractValue(origins=frozenset(origins)) - Each scope tracks abstract provenance rather than matching arbitrary - ``*.evaluation.field`` text. A value can be a config root, one or more - tracked section aliases, or unknown. Conditional control flow joins the - possible states, while an unconditional reassignment replaces them. - """ + +def _contained_origins(value: _AbstractValue) -> frozenset[str]: + origins = set(value.origins) + for item in value.items or (): + origins.update(_contained_origins(item)) + for _, item in value.entries or (): + origins.update(_contained_origins(item)) + for _, item in value.attributes or (): + origins.update(_contained_origins(item)) + return frozenset(origins) + + +def _conservative_value(value: _AbstractValue) -> _AbstractValue: + return _AbstractValue(origins=_contained_origins(value)) + + +def _join_values(*values: _AbstractValue) -> _AbstractValue: + if not values: + return _UNKNOWN_VALUE + origins = frozenset().union(*(value.origins for value in values)) + + known_items = [value.items for value in values if value.items is not None] + items: tuple[_AbstractValue, ...] | None = None + if known_items: + lengths = {len(candidate) for candidate in known_items} + if len(lengths) == 1: + items = tuple(_join_values(*position) for position in zip(*known_items, strict=True)) + else: + items = (_join_values(*(item for candidate in known_items for item in candidate)),) + + def join_named( + groups: Iterable[tuple[tuple[str, _AbstractValue], ...] | None], + ) -> tuple[tuple[str, _AbstractValue], ...] | None: + known = [dict(group) for group in groups if group is not None] + if not known: + return None + names = set().union(*(group.keys() for group in known)) + return tuple( + (name, _join_values(*(group[name] for group in known if name in group))) + for name in sorted(names) + ) + + return _AbstractValue( + origins=origins, + items=items, + entries=join_named(value.entries for value in values), + attributes=join_named(value.attributes for value in values), + ) + + +def _key_token(node: ast.AST) -> str: + return ast.dump(node, annotate_fields=True, include_attributes=False) + + +class _RuntimeReadVisitor(ast.NodeVisitor): + """Collect config reads with conservative flow- and binding-aware provenance.""" def __init__(self, fields: frozenset[ConfigField]) -> None: self._fields = fields - # An explicit empty state is a deliberate shadow. Without it, a - # function parameter or local reassignment could inherit an outer alias. - self._states: list[dict[str, frozenset[str]]] = [{}] + # Explicit unknown values shadow name-based config inference. + self._states: list[dict[str, _AbstractValue]] = [{}] + self._expression_cache: dict[int, _AbstractValue] = {} self.reads: set[ConfigField] = set() - def _name_state(self, name: str) -> frozenset[str]: + def _name_value(self, name: str) -> _AbstractValue: for scope in reversed(self._states): if name in scope: return scope[name] if _looks_like_config_name(name): - return frozenset({_CONFIG_ROOT}) - return _UNKNOWN_STATE + return _origin_value(_CONFIG_ROOT) + return _UNKNOWN_VALUE - def _expression_state(self, node: ast.AST) -> frozenset[str]: + @staticmethod + def _named_value( + pairs: tuple[tuple[str, _AbstractValue], ...] | None, name: str + ) -> _AbstractValue | None: + if pairs is None: + return None + return dict(pairs).get(name) + + def _expression_value(self, node: ast.AST) -> _AbstractValue: + cached = self._expression_cache.get(id(node)) + if cached is not None: + return cached if isinstance(node, ast.Name): - return self._name_state(node.id) + return self._name_value(node.id) if isinstance(node, ast.Attribute): - owner_state = self._expression_state(node.value) - if node.attr in TRACKED_SECTIONS and _CONFIG_ROOT in owner_state: - return frozenset({node.attr}) + owner = self._expression_value(node.value) + attribute = self._named_value(owner.attributes, node.attr) + if attribute is not None: + return attribute + if node.attr in TRACKED_SECTIONS and _CONFIG_ROOT in owner.origins: + return _origin_value(node.attr) if _looks_like_config_name(node.attr) and ( - _CONFIG_ROOT in owner_state + _CONFIG_ROOT in owner.origins or isinstance(node.value, ast.Name) and node.value.id in {"self", "cls"} ): - return frozenset({_CONFIG_ROOT}) - return _UNKNOWN_STATE + return _origin_value(_CONFIG_ROOT) + return _UNKNOWN_VALUE if isinstance(node, ast.Call): callable_name = _callable_name(node.func) if callable_name is not None and _CONFIG_FACTORY.search(callable_name): if isinstance(node.func, ast.Name): - return frozenset({_CONFIG_ROOT}) + return _origin_value(_CONFIG_ROOT) if isinstance(node.func, ast.Attribute) and ( - _CONFIG_ROOT in self._expression_state(node.func.value) + _CONFIG_ROOT in self._expression_value(node.func.value).origins or isinstance(node.func.value, ast.Name) and node.func.value.id in {"self", "cls"} ): - return frozenset({_CONFIG_ROOT}) + return _origin_value(_CONFIG_ROOT) if ( isinstance(node.func, ast.Name) and node.func.id == "getattr" and len(node.args) >= 2 and isinstance(node.args[1], ast.Constant) and node.args[1].value in TRACKED_SECTIONS - and _CONFIG_ROOT in self._expression_state(node.args[0]) + and _CONFIG_ROOT in self._expression_value(node.args[0]).origins ): - return frozenset({node.args[1].value}) - return _UNKNOWN_STATE + return _origin_value(node.args[1].value) + if callable_name is not None and callable_name[:1].isupper(): + items: list[_AbstractValue] = [] + for argument in node.args: + if isinstance(argument, ast.Starred): + expanded = self._expression_value(argument.value) + if expanded.items is not None: + items.extend(expanded.items) + else: + items.append(_conservative_value(expanded)) + else: + items.append(self._expression_value(argument)) + attributes: list[tuple[str, _AbstractValue]] = [] + for keyword in node.keywords: + value = self._expression_value(keyword.value) + attributes.append( + (keyword.arg, value) + if keyword.arg is not None + else ("**", _conservative_value(value)) + ) + return _AbstractValue( + items=tuple(items), + attributes=tuple(attributes), + ) + return _UNKNOWN_VALUE if isinstance(node, ast.Subscript): - state = self._expression_state(node.value) - if _CONFIG_ROOT in state: - return frozenset({_CONFIG_ROOT}) - return _UNKNOWN_STATE + owner = self._expression_value(node.value) + if _CONFIG_ROOT in owner.origins: + return _origin_value(_CONFIG_ROOT) + if isinstance(node.slice, ast.Constant): + if isinstance(node.slice.value, int) and owner.items is not None: + try: + return owner.items[node.slice.value] + except IndexError: + return _UNKNOWN_VALUE + entry = self._named_value(owner.entries, _key_token(node.slice)) + if entry is not None: + return entry + candidates = [*(owner.items or ()), *(value for _, value in owner.entries or ())] + return _join_values(*candidates) + if isinstance(node, (ast.Tuple, ast.List, ast.Set)): + items: list[_AbstractValue] = [] + for element in node.elts: + value = self._expression_value( + element.value if isinstance(element, ast.Starred) else element + ) + if isinstance(element, ast.Starred) and value.items is not None: + items.extend(value.items) + else: + items.append(value) + return _AbstractValue(items=tuple(items)) + if isinstance(node, ast.Dict): + entries: list[tuple[str, _AbstractValue]] = [] + for key, item in zip(node.keys, node.values, strict=True): + value = self._expression_value(item) + if key is not None: + entries.append((_key_token(key), value)) + elif value.entries is not None: + entries.extend(value.entries) + else: + entries.append(("**", _conservative_value(value))) + return _AbstractValue(entries=tuple(entries)) if isinstance(node, ast.IfExp): - return self._expression_state(node.body) | self._expression_state(node.orelse) + return _join_values( + self._expression_value(node.body), self._expression_value(node.orelse) + ) if isinstance(node, ast.BoolOp): - state = _UNKNOWN_STATE - for value in node.values: - state |= self._expression_state(value) - return state + return _join_values(*(self._expression_value(value) for value in node.values)) if isinstance(node, ast.NamedExpr): - return self._expression_state(node.value) - return _UNKNOWN_STATE + return self._expression_value(node.value) + return _UNKNOWN_VALUE def _record(self, section: str, name: str) -> None: field = ConfigField(section, name) @@ -235,12 +366,11 @@ def _record(self, section: str, name: str) -> None: def visit_Attribute(self, node: ast.Attribute) -> None: if isinstance(node.ctx, ast.Load): - for section in self._expression_state(node.value) & TRACKED_SECTIONS: + for section in self._expression_value(node.value).origins & TRACKED_SECTIONS: self._record(section, node.attr) self.generic_visit(node) def visit_Call(self, node: ast.Call) -> None: - # ``getattr(config.evaluation, "field")`` is still an explicit read. if ( isinstance(node.func, ast.Name) and node.func.id == "getattr" @@ -248,57 +378,40 @@ def visit_Call(self, node: ast.Call) -> None: and isinstance(node.args[1], ast.Constant) and isinstance(node.args[1].value, str) ): - for section in self._expression_state(node.args[0]) & TRACKED_SECTIONS: + for section in self._expression_value(node.args[0]).origins & TRACKED_SECTIONS: self._record(section, node.args[1].value) self.generic_visit(node) @staticmethod def _join_states( - *states: Mapping[str, frozenset[str]], - ) -> dict[str, frozenset[str]]: + *states: Mapping[str, _AbstractValue], + ) -> dict[str, _AbstractValue]: names = set().union(*(state.keys() for state in states)) return { - name: frozenset().union(*(state.get(name, _UNKNOWN_STATE) for state in states)) + name: _join_values(*(state.get(name, _UNKNOWN_VALUE) for state in states)) for name in names } - def _bind_target_state(self, target: ast.expr, state: frozenset[str]) -> None: + def _bind_target_value(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Name): - self._states[-1][target.id] = state + self._states[-1][target.id] = value elif isinstance(target, ast.Starred): - self._bind_target_state(target.value, state) + self._bind_target_value(target.value, value) elif isinstance(target, (ast.Tuple, ast.List)): for element in target.elts: - self._bind_target_state(element, state) - - def _sequence_item_state(self, value: ast.AST) -> frozenset[str]: - if isinstance(value, (ast.Tuple, ast.List, ast.Set)): - return frozenset().union( - *( - self._sequence_item_state(element.value) - if isinstance(element, ast.Starred) - else self._expression_state(element) - for element in value.elts - ) - ) - if isinstance(value, ast.Dict): - return frozenset().union( - *(self._expression_state(key) for key in value.keys if key is not None) - ) - return _UNKNOWN_STATE + self._bind_target_value(element, _conservative_value(value)) - def _bind_destructured(self, target: ast.expr, value: ast.AST) -> None: + def _bind_destructured(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Name): - self._states[-1][target.id] = self._expression_state(value) + self._states[-1][target.id] = value return if isinstance(target, ast.Starred): - self._bind_target_state(target.value, self._sequence_item_state(value)) + self._bind_target_value(target.value, value) return if not isinstance(target, (ast.Tuple, ast.List)): return - - if not isinstance(value, (ast.Tuple, ast.List)): - self._bind_target_state(target, self._sequence_item_state(value)) + if value.items is None: + self._bind_target_value(target, _conservative_value(value)) return starred = next( @@ -310,54 +423,70 @@ def _bind_destructured(self, target: ast.expr, value: ast.AST) -> None: None, ) if starred is None: - if len(target.elts) == len(value.elts): - for child_target, child_value in zip(target.elts, value.elts, strict=True): + if len(target.elts) == len(value.items): + for child_target, child_value in zip(target.elts, value.items, strict=True): self._bind_destructured(child_target, child_value) else: - self._bind_target_state(target, self._sequence_item_state(value)) + self._bind_target_value(target, _conservative_value(value)) return suffix_length = len(target.elts) - starred - 1 - if len(value.elts) < starred + suffix_length: - self._bind_target_state(target, self._sequence_item_state(value)) + if len(value.items) < starred + suffix_length: + self._bind_target_value(target, _conservative_value(value)) return for child_target, child_value in zip( - target.elts[:starred], value.elts[:starred], strict=True + target.elts[:starred], value.items[:starred], strict=True ): self._bind_destructured(child_target, child_value) - starred_values = value.elts[starred : len(value.elts) - suffix_length or None] - self._bind_target_state( - target.elts[starred], - frozenset().union(*(self._expression_state(item) for item in starred_values)), - ) + starred_items = value.items[starred : len(value.items) - suffix_length or None] + self._bind_target_value(target.elts[starred], _AbstractValue(items=starred_items)) if suffix_length: for child_target, child_value in zip( - target.elts[-suffix_length:], value.elts[-suffix_length:], strict=True + target.elts[-suffix_length:], value.items[-suffix_length:], strict=True ): self._bind_destructured(child_target, child_value) - def _bind_aliases(self, targets: Iterable[ast.expr], value: ast.AST) -> None: - for target in targets: - self._bind_destructured(target, value) + def _visit_store_target(self, target: ast.expr) -> None: + if isinstance(target, ast.Attribute): + self.visit(target.value) + elif isinstance(target, ast.Subscript): + self.visit(target.value) + self.visit(target.slice) + elif isinstance(target, (ast.Tuple, ast.List)): + for element in target.elts: + self._visit_store_target(element) + elif isinstance(target, ast.Starred): + self._visit_store_target(target.value) def visit_Assign(self, node: ast.Assign) -> None: self.visit(node.value) - self._bind_aliases(node.targets, node.value) + value = self._expression_value(node.value) + for target in node.targets: + self._visit_store_target(target) + self._bind_destructured(target, value) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + self.visit(node.annotation) if node.value is not None: self.visit(node.value) - self._bind_aliases((node.target,), node.value) + self._visit_store_target(node.target) + self._bind_destructured(node.target, self._expression_value(node.value)) def visit_NamedExpr(self, node: ast.NamedExpr) -> None: self.visit(node.value) - self._bind_aliases((node.target,), node.value) + self._bind_destructured(node.target, self._expression_value(node.value)) + + def visit_AugAssign(self, node: ast.AugAssign) -> None: + self.visit(node.target) + self.visit(node.value) + if isinstance(node.target, ast.Name): + self._states[-1][node.target.id] = self._name_value(node.target.id) def _visit_branch( self, statements: Iterable[ast.stmt], - initial: dict[str, frozenset[str]], - ) -> dict[str, frozenset[str]]: + initial: dict[str, _AbstractValue], + ) -> dict[str, _AbstractValue]: self._states[-1] = dict(initial) for statement in statements: self.visit(statement) @@ -366,10 +495,8 @@ def _visit_branch( def _visit_paths( self, statements: Iterable[ast.stmt], - initial: dict[str, frozenset[str]], - ) -> tuple[dict[str, frozenset[str]], dict[str, frozenset[str]]]: - """Visit statements and retain every state where an exception may branch.""" - + initial: dict[str, _AbstractValue], + ) -> tuple[dict[str, _AbstractValue], dict[str, _AbstractValue]]: self._states[-1] = dict(initial) prefixes = dict(initial) for statement in statements: @@ -382,40 +509,46 @@ def visit_If(self, node: ast.If) -> None: initial = dict(self._states[-1]) body_state = self._visit_branch(node.body, initial) else_state = self._visit_branch(node.orelse, initial) if node.orelse else initial - self._states[-1] = { - name: body_state.get(name, _UNKNOWN_STATE) | else_state.get(name, _UNKNOWN_STATE) - for name in body_state.keys() | else_state.keys() - } + self._states[-1] = self._join_states(body_state, else_state) - def _bind_iteration_target(self, target: ast.expr, iterable: ast.AST) -> None: - if isinstance(iterable, (ast.Tuple, ast.List, ast.Set)): - candidate_states: list[dict[str, frozenset[str]]] = [] - initial = dict(self._states[-1]) - for element in iterable.elts: - self._states[-1] = dict(initial) - if isinstance(element, ast.Starred): - self._bind_target_state(target, self._sequence_item_state(element.value)) - else: - self._bind_destructured(target, element) - candidate_states.append(dict(self._states[-1])) - self._states[-1] = self._join_states(*candidate_states) if candidate_states else initial - return - self._bind_target_state(target, self._sequence_item_state(iterable)) + @staticmethod + def _iteration_values(value: _AbstractValue) -> tuple[_AbstractValue, ...]: + if value.items is not None: + return value.items + if value.entries is not None: + return tuple(_UNKNOWN_VALUE for _ in value.entries) + return (_UNKNOWN_VALUE,) + + def _bind_iteration_target(self, target: ast.expr, value: _AbstractValue) -> bool: + candidates = self._iteration_values(value) + if not candidates: + return False + initial = dict(self._states[-1]) + candidate_states: list[dict[str, _AbstractValue]] = [] + for candidate in candidates: + self._states[-1] = dict(initial) + self._bind_destructured(target, candidate) + candidate_states.append(dict(self._states[-1])) + self._states[-1] = self._join_states(*candidate_states) + return True def visit_For(self, node: ast.For) -> None: self.visit(node.iter) + iterable = self._expression_value(node.iter) entry = dict(self._states[-1]) + self._states[-1] = dict(entry) + if not self._bind_iteration_target(node.target, iterable): + self._states[-1] = self._visit_branch(node.orelse, entry) if node.orelse else entry + return header = entry while True: self._states[-1] = dict(header) - self._bind_iteration_target(node.target, node.iter) + self._bind_iteration_target(node.target, iterable) body_state = self._visit_branch(node.body, dict(self._states[-1])) joined = self._join_states(entry, body_state) if joined == header: break header = joined - # The else suite can run after zero or more iterations; a break can - # bypass it, so retain both paths. else_state = self._visit_branch(node.orelse, header) if node.orelse else header self._states[-1] = self._join_states(header, body_state, else_state) @@ -434,8 +567,6 @@ def visit_While(self, node: ast.While) -> None: if joined == header: break header = joined - # ``tested_state`` is the false-test exit; ``body_state`` also covers - # a possible break that does not execute the else suite. else_state = self._visit_branch(node.orelse, tested_state) if node.orelse else tested_state self._states[-1] = self._join_states(tested_state, body_state, else_state) @@ -449,13 +580,9 @@ def _visit_try(self, node: ast.Try | ast.TryStar) -> None: if handler.type is not None: self.visit(handler.type) if handler.name is not None: - self._states[-1][handler.name] = _UNKNOWN_STATE + self._states[-1][handler.name] = _UNKNOWN_VALUE completed.append(self._visit_branch(handler.body, dict(self._states[-1]))) - if node.finalbody: - # ``finally`` observes normal, handled, and still-propagating - # exception paths. Applying it to their may-join preserves every - # possible config origin without inventing a report origin. incoming = self._join_states(exception_states, *completed) self._states[-1] = self._visit_branch(node.finalbody, incoming) else: @@ -467,20 +594,41 @@ def visit_Try(self, node: ast.Try) -> None: def visit_TryStar(self, node: ast.TryStar) -> None: self._visit_try(node) - def _bind_pattern(self, pattern: ast.pattern, subject: ast.AST) -> None: + def _visit_pattern_reads(self, pattern: ast.pattern) -> None: + if isinstance(pattern, ast.MatchValue): + self.visit(pattern.value) + elif isinstance(pattern, ast.MatchSequence): + for child in pattern.patterns: + self._visit_pattern_reads(child) + elif isinstance(pattern, ast.MatchMapping): + for key in pattern.keys: + self.visit(key) + for child in pattern.patterns: + self._visit_pattern_reads(child) + elif isinstance(pattern, ast.MatchClass): + self.visit(pattern.cls) + for child in (*pattern.patterns, *pattern.kwd_patterns): + self._visit_pattern_reads(child) + elif isinstance(pattern, ast.MatchOr): + for child in pattern.patterns: + self._visit_pattern_reads(child) + elif isinstance(pattern, ast.MatchAs) and pattern.pattern is not None: + self._visit_pattern_reads(pattern.pattern) + + def _bind_pattern(self, pattern: ast.pattern, subject: _AbstractValue) -> None: if isinstance(pattern, ast.MatchAs): if pattern.pattern is not None: self._bind_pattern(pattern.pattern, subject) if pattern.name is not None: - self._states[-1][pattern.name] = self._expression_state(subject) + self._states[-1][pattern.name] = subject return if isinstance(pattern, ast.MatchStar): if pattern.name is not None: - self._states[-1][pattern.name] = self._sequence_item_state(subject) + self._states[-1][pattern.name] = subject return if isinstance(pattern, ast.MatchOr): initial = dict(self._states[-1]) - alternatives: list[dict[str, frozenset[str]]] = [] + alternatives: list[dict[str, _AbstractValue]] = [] for alternative in pattern.patterns: self._states[-1] = dict(initial) self._bind_pattern(alternative, subject) @@ -488,73 +636,167 @@ def _bind_pattern(self, pattern: ast.pattern, subject: ast.AST) -> None: self._states[-1] = self._join_states(*alternatives) return if isinstance(pattern, ast.MatchSequence): - if isinstance(subject, (ast.Tuple, ast.List)) and len(pattern.patterns) == len( - subject.elts - ): - for child_pattern, child_subject in zip( - pattern.patterns, subject.elts, strict=True - ): - self._bind_pattern(child_pattern, child_subject) - else: - state = self._sequence_item_state(subject) - for child_pattern in pattern.patterns: - self._bind_pattern_state(child_pattern, state) + self._bind_sequence_pattern(pattern, subject) return if isinstance(pattern, ast.MatchMapping): - for child_pattern in pattern.patterns: - self._bind_pattern_state(child_pattern, _UNKNOWN_STATE) + entries = dict(subject.entries or ()) + fallback = _conservative_value(subject) + matched_tokens: set[str] = set() + for key, child in zip(pattern.keys, pattern.patterns, strict=True): + token = _key_token(key) + matched_tokens.add(token) + self._bind_pattern(child, entries.get(token, fallback)) if pattern.rest is not None: - self._states[-1][pattern.rest] = _UNKNOWN_STATE + remaining = tuple( + (key, value) + for key, value in subject.entries or () + if key not in matched_tokens + ) + self._states[-1][pattern.rest] = _AbstractValue(entries=remaining) return if isinstance(pattern, ast.MatchClass): - for child_pattern in (*pattern.patterns, *pattern.kwd_patterns): - self._bind_pattern_state(child_pattern, _UNKNOWN_STATE) - - def _bind_pattern_state(self, pattern: ast.pattern, state: frozenset[str]) -> None: - if isinstance(pattern, ast.MatchAs): - if pattern.pattern is not None: - self._bind_pattern_state(pattern.pattern, state) - if pattern.name is not None: - self._states[-1][pattern.name] = state - elif isinstance(pattern, ast.MatchStar): - if pattern.name is not None: - self._states[-1][pattern.name] = state - elif isinstance(pattern, ast.MatchOr): - for alternative in pattern.patterns: - self._bind_pattern_state(alternative, state) - elif isinstance(pattern, ast.MatchSequence): - for child_pattern in pattern.patterns: - self._bind_pattern_state(child_pattern, state) - elif isinstance(pattern, ast.MatchMapping): - for child_pattern in pattern.patterns: - self._bind_pattern_state(child_pattern, state) - if pattern.rest is not None: - self._states[-1][pattern.rest] = state - elif isinstance(pattern, ast.MatchClass): - for child_pattern in (*pattern.patterns, *pattern.kwd_patterns): - self._bind_pattern_state(child_pattern, state) + fallback = _conservative_value(subject) + for index, child in enumerate(pattern.patterns): + value = ( + subject.items[index] + if subject.items is not None and index < len(subject.items) + else fallback + ) + self._bind_pattern(child, value) + attributes = dict(subject.attributes or ()) + for name, child in zip(pattern.kwd_attrs, pattern.kwd_patterns, strict=True): + self._bind_pattern(child, attributes.get(name, fallback)) + + def _bind_sequence_pattern(self, pattern: ast.MatchSequence, subject: _AbstractValue) -> None: + if subject.items is None: + fallback = _conservative_value(subject) + for child in pattern.patterns: + self._bind_pattern(child, fallback) + return + starred = next( + ( + index + for index, child in enumerate(pattern.patterns) + if isinstance(child, ast.MatchStar) + ), + None, + ) + if starred is None and len(pattern.patterns) == len(subject.items): + for child, value in zip(pattern.patterns, subject.items, strict=True): + self._bind_pattern(child, value) + return + if starred is not None: + suffix_length = len(pattern.patterns) - starred - 1 + if len(subject.items) >= starred + suffix_length: + for child, value in zip( + pattern.patterns[:starred], subject.items[:starred], strict=True + ): + self._bind_pattern(child, value) + middle = subject.items[starred : len(subject.items) - suffix_length or None] + self._bind_pattern(pattern.patterns[starred], _AbstractValue(items=middle)) + if suffix_length: + for child, value in zip( + pattern.patterns[-suffix_length:], + subject.items[-suffix_length:], + strict=True, + ): + self._bind_pattern(child, value) + return + fallback = _conservative_value(subject) + for child in pattern.patterns: + self._bind_pattern(child, fallback) def visit_Match(self, node: ast.Match) -> None: self.visit(node.subject) + subject = self._expression_value(node.subject) initial = dict(self._states[-1]) branches = [initial] for case in node.cases: self._states[-1] = dict(initial) - self._bind_pattern(case.pattern, node.subject) + self._visit_pattern_reads(case.pattern) + self._bind_pattern(case.pattern, subject) if case.guard is not None: self.visit(case.guard) branches.append(self._visit_branch(case.body, dict(self._states[-1]))) self._states[-1] = self._join_states(*branches) + def _visit_comprehension( + self, + node: ast.ListComp | ast.SetComp | ast.GeneratorExp | ast.DictComp, + ) -> None: + first, *remaining = node.generators + self.visit(first.iter) + first_value = self._expression_value(first.iter) + self._states.append({}) + try: + self._bind_iteration_target(first.target, first_value) + for condition in first.ifs: + self.visit(condition) + for generator in remaining: + self.visit(generator.iter) + self._bind_iteration_target( + generator.target, self._expression_value(generator.iter) + ) + for condition in generator.ifs: + self.visit(condition) + if isinstance(node, ast.DictComp): + self.visit(node.key) + self.visit(node.value) + result = _AbstractValue( + entries=((_key_token(node.key), self._expression_value(node.value)),) + ) + else: + self.visit(node.elt) + result = _AbstractValue(items=(self._expression_value(node.elt),)) + self._expression_cache[id(node)] = result + finally: + self._states.pop() + + def visit_ListComp(self, node: ast.ListComp) -> None: + self._visit_comprehension(node) + + def visit_SetComp(self, node: ast.SetComp) -> None: + self._visit_comprehension(node) + + def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: + self._visit_comprehension(node) + + def visit_DictComp(self, node: ast.DictComp) -> None: + self._visit_comprehension(node) + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + self._states[-1][alias.asname or alias.name.partition(".")[0]] = _UNKNOWN_VALUE + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + for alias in node.names: + if alias.name != "*": + self._states[-1][alias.asname or alias.name] = _UNKNOWN_VALUE + + def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: + for item in node.items: + self.visit(item.context_expr) + if item.optional_vars is not None: + self._visit_store_target(item.optional_vars) + self._bind_target_value(item.optional_vars, _UNKNOWN_VALUE) + for statement in node.body: + self.visit(statement) + + def visit_With(self, node: ast.With) -> None: + self._visit_with(node) + + def visit_AsyncWith(self, node: ast.AsyncWith) -> None: + self._visit_with(node) + @staticmethod - def _argument_state(argument: ast.arg) -> frozenset[str]: + def _argument_value(argument: ast.arg) -> _AbstractValue: if _looks_like_config_name(argument.arg) or _annotation_names_config(argument.annotation): - return frozenset({_CONFIG_ROOT}) - return _UNKNOWN_STATE + return _origin_value(_CONFIG_ROOT) + return _UNKNOWN_VALUE - def _scoped_arguments(self, arguments: ast.arguments) -> dict[str, frozenset[str]]: + def _scoped_arguments(self, arguments: ast.arguments) -> dict[str, _AbstractValue]: scoped = { - argument.arg: self._argument_state(argument) + argument.arg: self._argument_value(argument) for argument in ( *arguments.posonlyargs, *arguments.args, @@ -562,19 +804,17 @@ def _scoped_arguments(self, arguments: ast.arguments) -> dict[str, frozenset[str ) } if arguments.vararg is not None: - scoped[arguments.vararg.arg] = self._argument_state(arguments.vararg) + scoped[arguments.vararg.arg] = self._argument_value(arguments.vararg) if arguments.kwarg is not None: - scoped[arguments.kwarg.arg] = self._argument_state(arguments.kwarg) + scoped[arguments.kwarg.arg] = self._argument_value(arguments.kwarg) return scoped def _visit_scoped(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: - # Defaults and decorators execute in the containing scope. for decorator in node.decorator_list: self.visit(decorator) for default in (*node.args.defaults, *node.args.kw_defaults): if default is not None: self.visit(default) - self._states.append(self._scoped_arguments(node.args)) try: for statement in node.body: @@ -584,9 +824,24 @@ def _visit_scoped(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: def visit_FunctionDef(self, node: ast.FunctionDef) -> None: self._visit_scoped(node) + self._states[-1][node.name] = _UNKNOWN_VALUE def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: self._visit_scoped(node) + self._states[-1][node.name] = _UNKNOWN_VALUE + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + for expression in (*node.decorator_list, *node.bases): + self.visit(expression) + for keyword in node.keywords: + self.visit(keyword.value) + self._states.append({}) + try: + for statement in node.body: + self.visit(statement) + finally: + self._states.pop() + self._states[-1][node.name] = _UNKNOWN_VALUE def visit_Lambda(self, node: ast.Lambda) -> None: for default in (*node.args.defaults, *node.args.kw_defaults): diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 7f6782d72d..a33671d827 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -329,6 +329,223 @@ def match_paths(config, report, selector): ) +def test_runtime_scan_preserves_mapping_class_and_rest_pattern_provenance( + contract, tmp_path: Path +) -> None: + (tmp_path / "structural_patterns.py").write_text( + """ +def mapping_pattern(config, report): + match {"section": config.evaluation}: + case {"section": direct_section}: + direct_mapping_read = direct_section.stage1_enabled + + mapping_source = {"section": config.evaluation} + match {**mapping_source}: + case {"section": expanded_section}: + expanded_mapping_read = expanded_section.assertion_extraction_model + + payload = { + "section": config.evaluation, + "remaining": config.consensus, + "unrelated": report.evaluation, + } + match payload: + case {"section": section, "unrelated": unrelated, **rest}: + mapping_read = section.stage2_enabled + rest_read = rest["remaining"].models + unrelated_read = unrelated.satisfaction_threshold + +class Envelope: + __match_args__ = ("positional",) + +def class_pattern(config, report): + match Envelope(section=config.evaluation): + case Envelope(section=direct_section): + direct_keyword_read = direct_section.stage3_enabled + + keyword_source = {"section": config.consensus} + match Envelope(**keyword_source): + case Envelope(section=expanded_section): + expanded_keyword_read = expanded_section.judge_model + + payload = Envelope( + config.consensus, + section=config.evaluation, + unrelated=report.consensus, + ) + match payload: + case Envelope(positional, section=section, unrelated=unrelated): + positional_read = positional.devil_model + keyword_read = section.uncertainty_threshold + unrelated_read = unrelated.threshold +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "models"), + } + ) + + +def test_runtime_scan_tracks_indirect_loop_containers_and_sequence_star_patterns( + contract, tmp_path: Path +) -> None: + (tmp_path / "indirect_containers.py").write_text( + """ +def indirect_loop(config, report): + sections = [config.evaluation] + aliases = sections + for section in aliases: + loop_read = section.stage1_enabled + + pairs = [(config.evaluation, report.evaluation)] + for section, unrelated in pairs: + destructured_loop_read = section.stage2_enabled + unrelated_read = unrelated.satisfaction_threshold + + match [config.consensus, report.consensus]: + case [consensus, *rest]: + head_read = consensus.models + unrelated_star_read = rest[0].threshold + +async def indirect_async_loop(config): + sections = [config.consensus] + async for section in sections: + async_loop_read = section.devil_model +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "models"), + } + ) + + +def test_runtime_scan_comprehension_targets_shadow_outer_aliases_in_evaluation_order( + contract, tmp_path: Path +) -> None: + (tmp_path / "comprehensions.py").write_text( + """ +def comprehension_shadowing(config, reports): + section = config.evaluation + ignored_list = [section.stage1_enabled for section in reports] + ignored_set = {section.stage2_enabled for section in reports} + ignored_dict = {section.stage3_enabled: section for section in reports} + ignored_generator = (section.satisfaction_threshold for section in reports) + outer_read = section.semantic_model + + sections = [config.evaluation] + positive_list = [item.uncertainty_threshold for item in sections] + nested = [ + item.assertion_extraction_model + for group in [[config.evaluation]] + for item in group + ] + +async def async_comprehension_shadowing(config, reports): + section = config.consensus + ignored = [section.threshold async for section in reports] + outer_read = section.models +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "models"), + } + ) + + +def test_runtime_scan_import_and_context_manager_bindings_override_config_inference( + contract, tmp_path: Path +) -> None: + (tmp_path / "binding_overrides.py").write_text( + """ +def imported_names(): + import report as config + from report import settings + imported_config = config.evaluation.stage1_enabled + imported_settings = settings.consensus.models + +def context_names(manager): + with manager as config: + context_config = config.evaluation.stage2_enabled + after_context = config.evaluation.stage3_enabled + +async def async_context_names(manager): + async with manager as settings: + context_settings = settings.consensus.threshold +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset() + + def test_every_schema_field_needs_exactly_one_disposition(contract) -> None: active = contract.ConfigField("evaluation", "active") inert = contract.ConfigField("evaluation", "inert") From 689d68d59f3c745f0b62f2bc8c979a98798ce708 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 10:48:32 +0900 Subject: [PATCH 04/70] fix(config): model standard mapping consumers --- scripts/check-config-reference-contract.py | 87 +++++++++++++++++++ .../test_check_config_reference_contract.py | 84 ++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 2e1315f7ba..94721bef4b 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -248,6 +248,71 @@ def _named_value( return None return dict(pairs).get(name) + def _replace_name_value(self, name: str, value: _AbstractValue) -> None: + for scope in reversed(self._states): + if name in scope: + scope[name] = value + return + self._states[-1][name] = value + + @staticmethod + def _mapping_values(value: _AbstractValue) -> tuple[_AbstractValue, ...]: + return tuple(item for _, item in value.entries or ()) + + def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: + if not isinstance(node.func, ast.Attribute): + return None + method = node.func.attr + if method not in {"copy", "get", "items", "keys", "setdefault", "values"}: + return None + owner = self._expression_value(node.func.value) + values = self._mapping_values(owner) + if method == "copy": + return owner + if method == "values": + return _AbstractValue(items=values) if owner.entries is not None else _UNKNOWN_VALUE + if method == "items": + if owner.entries is None: + return _UNKNOWN_VALUE + return _AbstractValue( + items=tuple(_AbstractValue(items=(_UNKNOWN_VALUE, value)) for value in values) + ) + if method == "keys": + return ( + _AbstractValue(items=tuple(_UNKNOWN_VALUE for _ in owner.entries)) + if owner.entries is not None + else _UNKNOWN_VALUE + ) + + default = self._expression_value(node.args[1]) if len(node.args) >= 2 else _UNKNOWN_VALUE + if not node.args: + return default + key = node.args[0] + if not isinstance(key, ast.Constant): + return _join_values(*values, default) + token = _key_token(key) + selected = self._named_value(owner.entries, token) + if method == "get": + return selected if selected is not None else default + + # ``setdefault`` returns the existing value or inserts and returns the + # default. Mutate a direct name receiver so a later read sees the + # inserted abstract value; aliases remain deliberately bounded. + if selected is not None: + return selected + if owner.entries is not None and isinstance(node.func.value, ast.Name): + entries = (*owner.entries, (token, default)) + self._replace_name_value( + node.func.value.id, + _AbstractValue( + origins=owner.origins, + items=owner.items, + entries=entries, + attributes=owner.attributes, + ), + ) + return default + def _expression_value(self, node: ast.AST) -> _AbstractValue: cached = self._expression_cache.get(id(node)) if cached is not None: @@ -269,6 +334,9 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_CONFIG_ROOT) return _UNKNOWN_VALUE if isinstance(node, ast.Call): + dict_method_value = self._dict_method_value(node) + if dict_method_value is not None: + return dict_method_value callable_name = _callable_name(node.func) if callable_name is not None and _CONFIG_FACTORY.search(callable_name): if isinstance(node.func, ast.Name): @@ -311,6 +379,16 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: items=tuple(items), attributes=tuple(attributes), ) + if isinstance(node.func, ast.Name) and node.func.id == "dict": + entries: list[tuple[str, _AbstractValue]] = [] + if node.args: + entries.extend(self._expression_value(node.args[0]).entries or ()) + entries.extend( + (_key_token(ast.Constant(keyword.arg)), self._expression_value(keyword.value)) + for keyword in node.keywords + if keyword.arg is not None + ) + return _AbstractValue(entries=tuple(entries)) return _UNKNOWN_VALUE if isinstance(node, ast.Subscript): owner = self._expression_value(node.value) @@ -355,6 +433,12 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: ) if isinstance(node, ast.BoolOp): return _join_values(*(self._expression_value(value) for value in node.values)) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + left = self._expression_value(node.left) + right = self._expression_value(node.right) + entries = dict(left.entries or ()) + entries.update(right.entries or ()) + return _AbstractValue(entries=tuple(sorted(entries.items()))) if isinstance(node, ast.NamedExpr): return self._expression_value(node.value) return _UNKNOWN_VALUE @@ -371,6 +455,9 @@ def visit_Attribute(self, node: ast.Attribute) -> None: self.generic_visit(node) def visit_Call(self, node: ast.Call) -> None: + # Evaluate once even when the call is a standalone mutating + # ``setdefault`` expression. + self._expression_value(node) if ( isinstance(node.func, ast.Name) and node.func.id == "getattr" diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index a33671d827..456aa3a60b 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -459,6 +459,90 @@ async def indirect_async_loop(config): ) +def test_runtime_scan_models_standard_dict_consumers_key_sensitively( + contract, tmp_path: Path +) -> None: + (tmp_path / "dict_consumers.py").write_text( + """ +def dict_consumers(config, report, dynamic_key): + sections = { + "primary": config.evaluation, + "unrelated": report.evaluation, + } + for section in sections.values(): + values_read = section.stage1_enabled + for _, section in sections.items(): + items_read = section.stage2_enabled + + primary_read = sections.get("primary").stage3_enabled + primary_with_default = sections.get( + "primary", report.evaluation + ).uncertainty_threshold + dynamic_read = sections.get(dynamic_key, report.evaluation).assertion_extraction_model + missing_read = sections.get("missing").satisfaction_threshold + missing_default = sections.get("missing", config.consensus).models + + for key in sections.keys(): + non_config_key_read = key.satisfaction_threshold + + reports = {"primary": report.evaluation} + report_get = reports.get("primary").satisfaction_threshold + report_dynamic = reports.get(dynamic_key, report.evaluation).satisfaction_threshold + for report_section in reports.values(): + report_value_read = report_section.satisfaction_threshold + + literals = {"primary": "not config"} + literal_read = literals.get("primary").satisfaction_threshold + + copied = sections.copy() + copied_read = copied.get("primary").semantic_model + constructed = dict(sections) + constructed_read = constructed["primary"].stage1_enabled + + unioned = reports | {"primary": config.consensus} + union_read = unioned.get("primary").devil_model + overridden = sections | { + "primary": report.evaluation, + "unrelated": report.evaluation, + } + overridden_read = overridden.get("primary").satisfaction_threshold + + defaults = {} + defaults.setdefault("primary", config.consensus) + inserted_read = defaults.get("primary").judge_model +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "models"), + } + ) + + def test_runtime_scan_comprehension_targets_shadow_outer_aliases_in_evaluation_order( contract, tmp_path: Path ) -> None: From cb8122e87fad84f0c532d8827115a2b9d5ffb389 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 11:17:27 +0900 Subject: [PATCH 05/70] fix(config): propagate mapping mutations across aliases --- scripts/check-config-reference-contract.py | 268 ++++++++++++++++-- .../test_check_config_reference_contract.py | 161 +++++++++++ 2 files changed, 402 insertions(+), 27 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 94721bef4b..12db74f855 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -160,6 +160,8 @@ class _AbstractValue: items: tuple[_AbstractValue, ...] | None = None entries: tuple[tuple[str, _AbstractValue], ...] | None = None attributes: tuple[tuple[str, _AbstractValue], ...] | None = None + identity: frozenset[int] = frozenset() + literal: str | None = None _UNKNOWN_VALUE = _AbstractValue() @@ -215,6 +217,12 @@ def join_named( items=items, entries=join_named(value.entries for value in values), attributes=join_named(value.attributes for value in values), + identity=frozenset().union(*(value.identity for value in values)), + literal=( + values[0].literal + if all(value.literal == values[0].literal for value in values) + else None + ), ) @@ -255,20 +263,134 @@ def _replace_name_value(self, name: str, value: _AbstractValue) -> None: return self._states[-1][name] = value + def _replace_identity( + self, + value: _AbstractValue, + identity: frozenset[int], + replacement: _AbstractValue, + *, + conservative: bool, + ) -> _AbstractValue: + if value.identity & identity: + return _join_values(value, replacement) if conservative else replacement + items = ( + tuple( + self._replace_identity(item, identity, replacement, conservative=conservative) + for item in value.items + ) + if value.items is not None + else None + ) + entries = ( + tuple( + ( + key, + self._replace_identity(item, identity, replacement, conservative=conservative), + ) + for key, item in value.entries + ) + if value.entries is not None + else None + ) + attributes = ( + tuple( + ( + name, + self._replace_identity(item, identity, replacement, conservative=conservative), + ) + for name, item in value.attributes + ) + if value.attributes is not None + else None + ) + if items == value.items and entries == value.entries and attributes == value.attributes: + return value + return _AbstractValue( + origins=value.origins, + items=items, + entries=entries, + attributes=attributes, + identity=value.identity, + literal=value.literal, + ) + + def _replace_shared_value( + self, + owner: _AbstractValue, + replacement: _AbstractValue, + receiver: ast.AST, + ) -> None: + if owner.identity: + for scope in self._states: + for name, value in tuple(scope.items()): + scope[name] = self._replace_identity( + value, + owner.identity, + replacement, + conservative=len(owner.identity) > 1, + ) + elif isinstance(receiver, ast.Name): + self._replace_name_value(receiver.id, replacement) + + @staticmethod + def _mapping_replacement( + owner: _AbstractValue, + entries: Iterable[tuple[str, _AbstractValue]], + ) -> _AbstractValue: + return _AbstractValue( + origins=owner.origins, + items=owner.items, + entries=tuple(entries), + attributes=owner.attributes, + identity=owner.identity, + literal=owner.literal, + ) + @staticmethod def _mapping_values(value: _AbstractValue) -> tuple[_AbstractValue, ...]: return tuple(item for _, item in value.entries or ()) + @staticmethod + def _mapping_source_entries( + source: _AbstractValue, + ) -> tuple[tuple[str, _AbstractValue], ...]: + if source.entries is not None: + return source.entries + if source.items is not None: + entries: list[tuple[str, _AbstractValue]] = [] + for pair in source.items: + if pair.items is not None and len(pair.items) >= 2: + entries.append((pair.items[0].literal or "**", pair.items[1])) + return tuple(entries) + return (("**", _conservative_value(source)),) + def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: if not isinstance(node.func, ast.Attribute): return None method = node.func.attr - if method not in {"copy", "get", "items", "keys", "setdefault", "values"}: + if method not in { + "clear", + "copy", + "get", + "items", + "keys", + "pop", + "setdefault", + "update", + "values", + }: return None owner = self._expression_value(node.func.value) values = self._mapping_values(owner) if method == "copy": - return owner + return _AbstractValue( + origins=owner.origins, + items=owner.items, + entries=owner.entries, + attributes=owner.attributes, + identity=frozenset({id(node)}), + literal=owner.literal, + ) if method == "values": return _AbstractValue(items=values) if owner.entries is not None else _UNKNOWN_VALUE if method == "items": @@ -284,39 +406,88 @@ def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: else _UNKNOWN_VALUE ) + if method == "clear": + replacement = self._mapping_replacement(owner, ()) + self._replace_shared_value(owner, replacement, node.func.value) + return _UNKNOWN_VALUE + if method == "update": + entries = dict(owner.entries or ()) + if node.args: + source = self._expression_value(node.args[0]) + for name, value in self._mapping_source_entries(source): + entries[name] = ( + _join_values(entries.get(name, _UNKNOWN_VALUE), value) + if name == "**" + else value + ) + for keyword in node.keywords: + value = self._expression_value(keyword.value) + if keyword.arg is None: + if value.entries is not None: + entries.update(value.entries) + else: + entries["**"] = _join_values( + entries.get("**", _UNKNOWN_VALUE), + _conservative_value(value), + ) + else: + entries[_key_token(ast.Constant(keyword.arg))] = value + replacement = self._mapping_replacement(owner, sorted(entries.items())) + self._replace_shared_value(owner, replacement, node.func.value) + return _UNKNOWN_VALUE + default = self._expression_value(node.args[1]) if len(node.args) >= 2 else _UNKNOWN_VALUE if not node.args: return default key = node.args[0] if not isinstance(key, ast.Constant): + if method == "setdefault": + return self._dynamic_setdefault_value(node) return _join_values(*values, default) token = _key_token(key) selected = self._named_value(owner.entries, token) if method == "get": return selected if selected is not None else default + if method == "pop": + if selected is not None and owner.entries is not None: + replacement = self._mapping_replacement( + owner, ((name, value) for name, value in owner.entries if name != token) + ) + self._replace_shared_value(owner, replacement, node.func.value) + return selected + return default + # ``setdefault`` returns the existing value or inserts and returns the - # default. Mutate a direct name receiver so a later read sees the - # inserted abstract value; aliases remain deliberately bounded. + # default. A dynamic key may hit any existing entry or create a new + # one, so join the default into every possible target and ``**``. if selected is not None: return selected - if owner.entries is not None and isinstance(node.func.value, ast.Name): - entries = (*owner.entries, (token, default)) - self._replace_name_value( - node.func.value.id, - _AbstractValue( - origins=owner.origins, - items=owner.items, - entries=entries, - attributes=owner.attributes, - ), - ) + if owner.entries is not None: + entries = dict(owner.entries) + entries[token] = default + replacement = self._mapping_replacement(owner, sorted(entries.items())) + self._replace_shared_value(owner, replacement, node.func.value) return default + def _dynamic_setdefault_value(self, node: ast.Call) -> _AbstractValue: + assert isinstance(node.func, ast.Attribute) + owner = self._expression_value(node.func.value) + values = self._mapping_values(owner) + default = self._expression_value(node.args[1]) if len(node.args) >= 2 else _UNKNOWN_VALUE + if owner.entries is not None: + entries = {name: _join_values(value, default) for name, value in owner.entries} + entries["**"] = _join_values(entries.get("**", _UNKNOWN_VALUE), default) + replacement = self._mapping_replacement(owner, sorted(entries.items())) + self._replace_shared_value(owner, replacement, node.func.value) + return _join_values(*values, default) + def _expression_value(self, node: ast.AST) -> _AbstractValue: cached = self._expression_cache.get(id(node)) if cached is not None: return cached + if isinstance(node, ast.Constant): + return _AbstractValue(literal=_key_token(node)) if isinstance(node, ast.Name): return self._name_value(node.id) if isinstance(node, ast.Attribute): @@ -336,6 +507,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if isinstance(node, ast.Call): dict_method_value = self._dict_method_value(node) if dict_method_value is not None: + self._expression_cache[id(node)] = dict_method_value return dict_method_value callable_name = _callable_name(node.func) if callable_name is not None and _CONFIG_FACTORY.search(callable_name): @@ -382,13 +554,17 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if isinstance(node.func, ast.Name) and node.func.id == "dict": entries: list[tuple[str, _AbstractValue]] = [] if node.args: - entries.extend(self._expression_value(node.args[0]).entries or ()) - entries.extend( - (_key_token(ast.Constant(keyword.arg)), self._expression_value(keyword.value)) - for keyword in node.keywords - if keyword.arg is not None - ) - return _AbstractValue(entries=tuple(entries)) + source = self._expression_value(node.args[0]) + entries.extend(self._mapping_source_entries(source)) + for keyword in node.keywords: + value = self._expression_value(keyword.value) + if keyword.arg is not None: + entries.append((_key_token(ast.Constant(keyword.arg)), value)) + elif value.entries is not None: + entries.extend(value.entries) + else: + entries.append(("**", _conservative_value(value))) + return _AbstractValue(entries=tuple(entries), identity=frozenset({id(node)})) return _UNKNOWN_VALUE if isinstance(node, ast.Subscript): owner = self._expression_value(node.value) @@ -426,7 +602,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: entries.extend(value.entries) else: entries.append(("**", _conservative_value(value))) - return _AbstractValue(entries=tuple(entries)) + return _AbstractValue(entries=tuple(entries), identity=frozenset({id(node)})) if isinstance(node, ast.IfExp): return _join_values( self._expression_value(node.body), self._expression_value(node.orelse) @@ -438,7 +614,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: right = self._expression_value(node.right) entries = dict(left.entries or ()) entries.update(right.entries or ()) - return _AbstractValue(entries=tuple(sorted(entries.items()))) + return _AbstractValue( + entries=tuple(sorted(entries.items())), + identity=frozenset({id(node)}), + ) if isinstance(node, ast.NamedExpr): return self._expression_value(node.value) return _UNKNOWN_VALUE @@ -545,11 +724,27 @@ def _visit_store_target(self, target: ast.expr) -> None: elif isinstance(target, ast.Starred): self._visit_store_target(target.value) + def _assign_store_target(self, target: ast.expr, value: _AbstractValue) -> None: + if not isinstance(target, ast.Subscript): + return + owner = self._expression_value(target.value) + if owner.entries is None and not owner.identity: + return + entries = dict(owner.entries or ()) + if isinstance(target.slice, ast.Constant): + entries[_key_token(target.slice)] = value + else: + entries = {name: _join_values(existing, value) for name, existing in entries.items()} + entries["**"] = _join_values(entries.get("**", _UNKNOWN_VALUE), value) + replacement = self._mapping_replacement(owner, sorted(entries.items())) + self._replace_shared_value(owner, replacement, target.value) + def visit_Assign(self, node: ast.Assign) -> None: self.visit(node.value) value = self._expression_value(node.value) for target in node.targets: self._visit_store_target(target) + self._assign_store_target(target, value) self._bind_destructured(target, value) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: @@ -557,15 +752,31 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: if node.value is not None: self.visit(node.value) self._visit_store_target(node.target) - self._bind_destructured(node.target, self._expression_value(node.value)) + value = self._expression_value(node.value) + self._assign_store_target(node.target, value) + self._bind_destructured(node.target, value) def visit_NamedExpr(self, node: ast.NamedExpr) -> None: self.visit(node.value) self._bind_destructured(node.target, self._expression_value(node.value)) def visit_AugAssign(self, node: ast.AugAssign) -> None: + owner = self._expression_value(node.target) self.visit(node.target) self.visit(node.value) + if isinstance(node.op, ast.BitOr): + right = self._expression_value(node.value) + entries = dict(owner.entries or ()) + if right.entries is not None: + entries.update(right.entries) + else: + entries["**"] = _join_values( + entries.get("**", _UNKNOWN_VALUE), + _conservative_value(right), + ) + replacement = self._mapping_replacement(owner, sorted(entries.items())) + self._replace_shared_value(owner, replacement, node.target) + return if isinstance(node.target, ast.Name): self._states[-1][node.target.id] = self._name_value(node.target.id) @@ -739,7 +950,9 @@ def _bind_pattern(self, pattern: ast.pattern, subject: _AbstractValue) -> None: for key, value in subject.entries or () if key not in matched_tokens ) - self._states[-1][pattern.rest] = _AbstractValue(entries=remaining) + self._states[-1][pattern.rest] = _AbstractValue( + entries=remaining, identity=frozenset({id(pattern)}) + ) return if isinstance(pattern, ast.MatchClass): fallback = _conservative_value(subject) @@ -830,7 +1043,8 @@ def _visit_comprehension( self.visit(node.key) self.visit(node.value) result = _AbstractValue( - entries=((_key_token(node.key), self._expression_value(node.value)),) + entries=((_key_token(node.key), self._expression_value(node.value)),), + identity=frozenset({id(node)}), ) else: self.visit(node.elt) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 456aa3a60b..1fc56968cd 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -543,6 +543,167 @@ def dict_consumers(config, report, dynamic_key): ) +def test_runtime_scan_propagates_mutable_mapping_identity_across_aliases( + contract, tmp_path: Path +) -> None: + (tmp_path / "mutable_mapping_aliases.py").write_text( + """ +def dynamic_setdefault(config, key): + sections = {} + sections.setdefault(key, config.evaluation) + for section in sections.values(): + dynamic_read = section.stage1_enabled + +def aliased_setdefault(config): + sections = {} + alias = sections + alias.setdefault("x", config.evaluation) + alias_read = sections["x"].stage2_enabled + +def augmented_union(config): + sections = {} + alias = sections + sections |= {"x": config.evaluation} + augmented_read = alias["x"].stage3_enabled + +def constructors(config): + source = {"x": config.evaluation} + expanded = dict(**source) + expanded_read = expanded["x"].assertion_extraction_model + pairs = dict([("x", config.evaluation)]) + pairs_read = pairs["x"].uncertainty_threshold + keywords = dict(x=config.evaluation) + keyword_read = keywords["x"].semantic_model + +def direct_mutations(config): + sections = {} + alias = sections + alias["x"] = config.evaluation + assigned_read = sections["x"].stage1_enabled + + consensus = {} + consensus_alias = consensus + consensus_alias.update({"x": config.consensus}) + updated_read = consensus["x"].models + + pair_updates = {} + pair_alias = pair_updates + pair_alias.update([("x", config.consensus)]) + pair_update_read = pair_updates["x"].devil_model + +def conditional_alias(config, enabled): + left = {} + right = {} + alias = left if enabled else right + alias.setdefault("x", config.consensus) + possible_left_read = left.get("x").advocate_model + +def dynamic_assignment(config, key): + sections = {} + alias = sections + alias[key] = config.evaluation + for section in sections.values(): + dynamic_assignment_read = section.stage3_enabled + +def provenance_kills(config): + cleared = {"x": config.evaluation} + cleared_alias = cleared + cleared_alias.clear() + cleared_read = cleared.get("x").satisfaction_threshold + + popped = {"x": config.evaluation} + popped.pop("x") + popped_read = popped.get("x").satisfaction_threshold + +def unrelated_mutations(report, key): + sections = {} + alias = sections + alias.setdefault(key, report.evaluation) + alias["x"] = report.evaluation + alias |= {"y": report.evaluation} + alias.update({"z": report.evaluation}) + for section in sections.values(): + unrelated_read = section.satisfaction_threshold + + source = {"x": report.evaluation} + expanded = dict(**source) + pairs = dict([("x", report.evaluation)]) + expanded_read = expanded["x"].satisfaction_threshold + pairs_read = pairs["x"].satisfaction_threshold +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "advocate_model"), + contract.ConfigField("consensus", "devil_model"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "advocate_model"), + contract.ConfigField("consensus", "devil_model"), + } + ) + + +def test_runtime_scan_keeps_provenance_when_mutating_a_may_alias(contract, tmp_path: Path) -> None: + (tmp_path / "may_alias_mutation.py").write_text( + """ +def may_alias_clear(config, report, enabled): + left = {"x": config.evaluation} + right = {"x": report.evaluation} + alias = left if enabled else right + alias.clear() + possible_uncleared_read = left.get("x").stage1_enabled + +def independent_report_maps(report): + left = {"x": report.evaluation} + right = {"x": report.evaluation} + alias = left + alias.clear() + unrelated_read = right.get("x").satisfaction_threshold + +def popped_value(config): + values = {"x": config.evaluation} + popped = values.pop("x") + popped_read = popped.stage2_enabled + removed_read = values.get("x").satisfaction_threshold +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + } + ) + + def test_runtime_scan_comprehension_targets_shadow_outer_aliases_in_evaluation_order( contract, tmp_path: Path ) -> None: From ac1e568c5c54349b69b3a2da77157f79364ca430 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 11:49:01 +0900 Subject: [PATCH 06/70] fix(config): preserve dynamic mapping key collisions Model dynamic mapping keys as ordered wildcard writes so setdefault preserves known values while assignment, update, and union retain possible collisions. Carry possible key absence through control-flow joins so get defaults and subscript reads classify runtime provenance without false positives. This closes the exact false-positive and false-negative gaps found by the independent #1998 verifier while preserving alias-aware mutation and constructor handling. Affected files: - scripts/check-config-reference-contract.py - tests/unit/scripts/test_check_config_reference_contract.py --- scripts/check-config-reference-contract.py | 176 +++++++++++++----- .../test_check_config_reference_contract.py | 98 ++++++++++ 2 files changed, 232 insertions(+), 42 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 12db74f855..3ca9d1a693 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -212,10 +212,32 @@ def join_named( for name in sorted(names) ) + def join_entries( + groups: Iterable[tuple[tuple[str, _AbstractValue], ...] | None], + ) -> tuple[tuple[str, _AbstractValue], ...] | None: + raw = list(groups) + if not any(group is not None for group in raw): + return None + mappings = [dict(group or ()) for group in raw] + names = set().union(*(group.keys() for group in mappings)) + missing = _origin_value(_MAPPING_MISSING) + joined: list[tuple[str, _AbstractValue]] = [] + for name in sorted(names): + possibilities: list[_AbstractValue] = [] + for group in mappings: + if name in group: + possibilities.append(group[name]) + elif name != _DYNAMIC_KEY and _DYNAMIC_KEY in group: + possibilities.append(_join_values(group[_DYNAMIC_KEY], missing)) + else: + possibilities.append(missing) + joined.append((name, _join_values(*possibilities))) + return tuple(joined) + return _AbstractValue( origins=origins, items=items, - entries=join_named(value.entries for value in values), + entries=join_entries(value.entries for value in values), attributes=join_named(value.attributes for value in values), identity=frozenset().union(*(value.identity for value in values)), literal=( @@ -230,6 +252,10 @@ def _key_token(node: ast.AST) -> str: return ast.dump(node, annotate_fields=True, include_attributes=False) +_DYNAMIC_KEY = "**" +_MAPPING_MISSING = "" + + class _RuntimeReadVisitor(ast.NodeVisitor): """Collect config reads with conservative flow- and binding-aware provenance.""" @@ -351,18 +377,62 @@ def _mapping_values(value: _AbstractValue) -> tuple[_AbstractValue, ...]: return tuple(item for _, item in value.entries or ()) @staticmethod + def _add_mapping_entry( + entries: dict[str, _AbstractValue], + key: str, + value: _AbstractValue, + ) -> None: + """Apply one mapping write in evaluation order. + + A dynamic key may collide with every key written before it. A later + literal key, however, deterministically overrides any earlier dynamic + collision for that literal key. + """ + + if key == _DYNAMIC_KEY: + for known in tuple(entries): + if known != _DYNAMIC_KEY: + entries[known] = _join_values(entries[known], value) + entries[_DYNAMIC_KEY] = _join_values(entries.get(_DYNAMIC_KEY, _UNKNOWN_VALUE), value) + return + entries[key] = value + + @classmethod + def _overlay_mapping_entries( + cls, + base: Mapping[str, _AbstractValue], + source: Mapping[str, _AbstractValue], + ) -> dict[str, _AbstractValue]: + """Overlay a normalized source mapping onto a normalized base.""" + + result = dict(base) + source_known = {key for key in source if key != _DYNAMIC_KEY} + wildcard = source.get(_DYNAMIC_KEY) + if wildcard is not None: + for known in tuple(result): + if known != _DYNAMIC_KEY and known not in source_known: + result[known] = _join_values(result[known], wildcard) + result[_DYNAMIC_KEY] = _join_values(result.get(_DYNAMIC_KEY, _UNKNOWN_VALUE), wildcard) + for key in source_known: + result[key] = source[key] + return result + + @classmethod def _mapping_source_entries( + cls, source: _AbstractValue, ) -> tuple[tuple[str, _AbstractValue], ...]: if source.entries is not None: return source.entries if source.items is not None: - entries: list[tuple[str, _AbstractValue]] = [] + entries: dict[str, _AbstractValue] = {} for pair in source.items: if pair.items is not None and len(pair.items) >= 2: - entries.append((pair.items[0].literal or "**", pair.items[1])) - return tuple(entries) - return (("**", _conservative_value(source)),) + cls._add_mapping_entry( + entries, pair.items[0].literal or _DYNAMIC_KEY, pair.items[1] + ) + return tuple(sorted(entries.items())) + return ((_DYNAMIC_KEY, _conservative_value(source)),) def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: if not isinstance(node.func, ast.Attribute): @@ -414,21 +484,18 @@ def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: entries = dict(owner.entries or ()) if node.args: source = self._expression_value(node.args[0]) - for name, value in self._mapping_source_entries(source): - entries[name] = ( - _join_values(entries.get(name, _UNKNOWN_VALUE), value) - if name == "**" - else value - ) + entries = self._overlay_mapping_entries( + entries, dict(self._mapping_source_entries(source)) + ) for keyword in node.keywords: value = self._expression_value(keyword.value) if keyword.arg is None: if value.entries is not None: - entries.update(value.entries) + entries = self._overlay_mapping_entries(entries, dict(value.entries)) else: - entries["**"] = _join_values( - entries.get("**", _UNKNOWN_VALUE), - _conservative_value(value), + entries = self._overlay_mapping_entries( + entries, + {_DYNAMIC_KEY: _conservative_value(value)}, ) else: entries[_key_token(ast.Constant(keyword.arg))] = value @@ -445,9 +512,17 @@ def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: return self._dynamic_setdefault_value(node) return _join_values(*values, default) token = _key_token(key) - selected = self._named_value(owner.entries, token) + entries = dict(owner.entries or ()) + selected = entries.get(token) + wildcard = entries.get(_DYNAMIC_KEY) if method == "get": - return selected if selected is not None else default + if selected is not None: + return ( + _join_values(selected, default) + if _MAPPING_MISSING in selected.origins + else selected + ) + return _join_values(wildcard, default) if wildcard is not None else default if method == "pop": if selected is not None and owner.entries is not None: @@ -461,13 +536,15 @@ def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: # ``setdefault`` returns the existing value or inserts and returns the # default. A dynamic key may hit any existing entry or create a new # one, so join the default into every possible target and ``**``. - if selected is not None: + if selected is not None and _MAPPING_MISSING not in selected.origins: return selected if owner.entries is not None: - entries = dict(owner.entries) - entries[token] = default + prior = selected if selected is not None else wildcard + inserted = _join_values(prior, default) if prior is not None else default + entries[token] = inserted replacement = self._mapping_replacement(owner, sorted(entries.items())) self._replace_shared_value(owner, replacement, node.func.value) + return inserted return default def _dynamic_setdefault_value(self, node: ast.Call) -> _AbstractValue: @@ -476,8 +553,8 @@ def _dynamic_setdefault_value(self, node: ast.Call) -> _AbstractValue: values = self._mapping_values(owner) default = self._expression_value(node.args[1]) if len(node.args) >= 2 else _UNKNOWN_VALUE if owner.entries is not None: - entries = {name: _join_values(value, default) for name, value in owner.entries} - entries["**"] = _join_values(entries.get("**", _UNKNOWN_VALUE), default) + entries = dict(owner.entries) + entries[_DYNAMIC_KEY] = _join_values(entries.get(_DYNAMIC_KEY, _UNKNOWN_VALUE), default) replacement = self._mapping_replacement(owner, sorted(entries.items())) self._replace_shared_value(owner, replacement, node.func.value) return _join_values(*values, default) @@ -552,19 +629,26 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: attributes=tuple(attributes), ) if isinstance(node.func, ast.Name) and node.func.id == "dict": - entries: list[tuple[str, _AbstractValue]] = [] + entries: dict[str, _AbstractValue] = {} if node.args: source = self._expression_value(node.args[0]) - entries.extend(self._mapping_source_entries(source)) + entries = self._overlay_mapping_entries( + entries, dict(self._mapping_source_entries(source)) + ) for keyword in node.keywords: value = self._expression_value(keyword.value) if keyword.arg is not None: - entries.append((_key_token(ast.Constant(keyword.arg)), value)) + entries[_key_token(ast.Constant(keyword.arg))] = value elif value.entries is not None: - entries.extend(value.entries) + entries = self._overlay_mapping_entries(entries, dict(value.entries)) else: - entries.append(("**", _conservative_value(value))) - return _AbstractValue(entries=tuple(entries), identity=frozenset({id(node)})) + entries = self._overlay_mapping_entries( + entries, + {_DYNAMIC_KEY: _conservative_value(value)}, + ) + return _AbstractValue( + entries=tuple(sorted(entries.items())), identity=frozenset({id(node)}) + ) return _UNKNOWN_VALUE if isinstance(node, ast.Subscript): owner = self._expression_value(node.value) @@ -576,9 +660,11 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return owner.items[node.slice.value] except IndexError: return _UNKNOWN_VALUE - entry = self._named_value(owner.entries, _key_token(node.slice)) + entries = dict(owner.entries or ()) + entry = entries.get(_key_token(node.slice)) if entry is not None: return entry + return entries.get(_DYNAMIC_KEY, _UNKNOWN_VALUE) candidates = [*(owner.items or ()), *(value for _, value in owner.entries or ())] return _join_values(*candidates) if isinstance(node, (ast.Tuple, ast.List, ast.Set)): @@ -593,16 +679,22 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: items.append(value) return _AbstractValue(items=tuple(items)) if isinstance(node, ast.Dict): - entries: list[tuple[str, _AbstractValue]] = [] + entries: dict[str, _AbstractValue] = {} for key, item in zip(node.keys, node.values, strict=True): value = self._expression_value(item) if key is not None: - entries.append((_key_token(key), value)) + key_value = self._expression_value(key) + self._add_mapping_entry(entries, key_value.literal or _DYNAMIC_KEY, value) elif value.entries is not None: - entries.extend(value.entries) + entries = self._overlay_mapping_entries(entries, dict(value.entries)) else: - entries.append(("**", _conservative_value(value))) - return _AbstractValue(entries=tuple(entries), identity=frozenset({id(node)})) + entries = self._overlay_mapping_entries( + entries, + {_DYNAMIC_KEY: _conservative_value(value)}, + ) + return _AbstractValue( + entries=tuple(sorted(entries.items())), identity=frozenset({id(node)}) + ) if isinstance(node, ast.IfExp): return _join_values( self._expression_value(node.body), self._expression_value(node.orelse) @@ -612,8 +704,9 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): left = self._expression_value(node.left) right = self._expression_value(node.right) - entries = dict(left.entries or ()) - entries.update(right.entries or ()) + entries = self._overlay_mapping_entries( + dict(left.entries or ()), dict(right.entries or ()) + ) return _AbstractValue( entries=tuple(sorted(entries.items())), identity=frozenset({id(node)}), @@ -734,8 +827,7 @@ def _assign_store_target(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target.slice, ast.Constant): entries[_key_token(target.slice)] = value else: - entries = {name: _join_values(existing, value) for name, existing in entries.items()} - entries["**"] = _join_values(entries.get("**", _UNKNOWN_VALUE), value) + self._add_mapping_entry(entries, _DYNAMIC_KEY, value) replacement = self._mapping_replacement(owner, sorted(entries.items())) self._replace_shared_value(owner, replacement, target.value) @@ -768,11 +860,11 @@ def visit_AugAssign(self, node: ast.AugAssign) -> None: right = self._expression_value(node.value) entries = dict(owner.entries or ()) if right.entries is not None: - entries.update(right.entries) + entries = self._overlay_mapping_entries(entries, dict(right.entries)) else: - entries["**"] = _join_values( - entries.get("**", _UNKNOWN_VALUE), - _conservative_value(right), + entries = self._overlay_mapping_entries( + entries, + {_DYNAMIC_KEY: _conservative_value(right)}, ) replacement = self._mapping_replacement(owner, sorted(entries.items())) self._replace_shared_value(owner, replacement, node.target) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 1fc56968cd..7158ce834b 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -663,6 +663,104 @@ def unrelated_mutations(report, key): ) +def test_runtime_scan_models_dynamic_key_collisions_in_evaluation_order( + contract, tmp_path: Path +) -> None: + (tmp_path / "dynamic_key_collisions.py").write_text( + """ +def dynamic_key_collisions(config, report, key): + preserved = {"known": report.evaluation} + preserved.setdefault(key, config.evaluation) + preserved_known_read = preserved["known"].satisfaction_threshold + for section in preserved.values(): + inserted_default_read = section.stage1_enabled + + later_dynamic = { + "known": report.evaluation, + key: config.evaluation, + } + later_dynamic_read = later_dynamic["known"].stage2_enabled + + later_literal = { + key: config.evaluation, + "known": report.evaluation, + } + later_literal_read = later_literal["known"].satisfaction_threshold + + updated = {"known": report.evaluation} + updated.update({key: config.evaluation}) + updated_read = updated["known"].stage3_enabled + + unioned = {"known": report.evaluation} | {key: config.evaluation} + unioned_read = unioned["known"].uncertainty_threshold + + assigned = {"known": report.evaluation} + assigned[key] = config.evaluation + assigned_read = assigned["known"].assertion_extraction_model + + missing = {"other": config.evaluation} + unreachable_missing_read = missing["known"].semantic_model +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + } + ) + + +def test_runtime_scan_joins_dynamic_mapping_keys_and_missing_defaults( + contract, tmp_path: Path +) -> None: + field = contract.ConfigField("evaluation", "stage1_enabled") + fields = frozenset({field}) + + positive = tmp_path / "positive" + positive.mkdir() + (positive / "conditional_mapping.py").write_text( + """ +def conditional_dynamic(config, report, key, enabled): + sections = {key: config.evaluation} if enabled else {"x": report.evaluation} + return sections["x"].stage1_enabled + +def conditional_default(config, report, enabled): + sections = {} if enabled else {"x": report.evaluation} + return sections.get("x", config.evaluation).stage1_enabled +""", + encoding="utf-8", + ) + assert contract.runtime_reads(positive, fields) == fields + + negative = tmp_path / "negative" + negative.mkdir() + (negative / "missing_or_unrelated.py").write_text( + """ +def conditional_unrelated(report, enabled): + sections = {} if enabled else {"x": report.evaluation} + return sections["x"].stage1_enabled +""", + encoding="utf-8", + ) + assert contract.runtime_reads(negative, fields) == frozenset() + + def test_runtime_scan_keeps_provenance_when_mutating_a_may_alias(contract, tmp_path: Path) -> None: (tmp_path / "may_alias_mutation.py").write_text( """ From 9895bb2738ea192d5a402e5c18764fa956200722 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 12:07:58 +0900 Subject: [PATCH 07/70] fix(config): strongly update conditional receivers Strongly rebind the mutated receiver after propagating conservative changes to conditional source aliases. This prevents impossible config provenance after literal assignment, update, union, clear, and pop while preserving may-alias reads. Affected files: - scripts/check-config-reference-contract.py - tests/unit/scripts/test_check_config_reference_contract.py --- scripts/check-config-reference-contract.py | 6 +++ .../test_check_config_reference_contract.py | 53 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 3ca9d1a693..630d694b45 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -355,6 +355,12 @@ def _replace_shared_value( replacement, conservative=len(owner.identity) > 1, ) + # A joined owner may refer to one of several source objects, so + # those source aliases need conservative updates. The receiver + # itself, however, names whichever object was selected at runtime + # and therefore always observes the mutation strongly. + if isinstance(receiver, ast.Name): + self._replace_name_value(receiver.id, replacement) elif isinstance(receiver, ast.Name): self._replace_name_value(receiver.id, replacement) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 7158ce834b..2019a2be03 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -761,6 +761,59 @@ def conditional_unrelated(report, enabled): assert contract.runtime_reads(negative, fields) == frozenset() +@pytest.mark.parametrize( + ("mutation", "receiver_read"), + [ + ( + 'receiver["known"] = report.evaluation', + 'receiver["known"].satisfaction_threshold', + ), + ( + 'receiver.update({"known": report.evaluation})', + 'receiver["known"].satisfaction_threshold', + ), + ( + 'receiver |= {"known": report.evaluation}', + 'receiver["known"].satisfaction_threshold', + ), + ( + "receiver.clear()", + 'receiver.get("known", report.evaluation).satisfaction_threshold', + ), + ( + 'receiver.pop("known")', + 'receiver.get("known", report.evaluation).satisfaction_threshold', + ), + ], + ids=["assignment", "update", "union", "clear", "pop"], +) +def test_runtime_scan_strongly_mutates_conditional_receiver_but_not_possible_aliases( + contract, + tmp_path: Path, + mutation: str, + receiver_read: str, +) -> None: + (tmp_path / "conditional_receiver.py").write_text( + f""" +def conditional_receiver(config, report, enabled): + left = {{"known": config.evaluation}} + right = {{"known": report.evaluation}} + receiver = left if enabled else right + {mutation} + impossible_receiver_read = {receiver_read} + possible_alias_read = left.get("known", report.evaluation).stage1_enabled +""", + encoding="utf-8", + ) + positive_alias = contract.ConfigField("evaluation", "stage1_enabled") + false_positive = contract.ConfigField("evaluation", "satisfaction_threshold") + + assert contract.runtime_reads( + tmp_path, + frozenset({positive_alias, false_positive}), + ) == frozenset({positive_alias}) + + def test_runtime_scan_keeps_provenance_when_mutating_a_may_alias(contract, tmp_path: Path) -> None: (tmp_path / "may_alias_mutation.py").write_text( """ From 2eb483d47ab57b9f8e498703cf3f89781c241ee6 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 13:19:03 +0900 Subject: [PATCH 08/70] fix(config): respect runtime reachability in contract scan --- scripts/check-config-reference-contract.py | 470 ++++++++++++++++-- .../test_check_config_reference_contract.py | 141 ++++++ 2 files changed, 567 insertions(+), 44 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 630d694b45..ad0fd9f40e 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -131,27 +131,6 @@ def _callable_name(node: ast.AST) -> str | None: return None -def _annotation_names_config(annotation: ast.AST | None) -> bool: - if annotation is None: - return False - return any( - ( - isinstance(node, ast.Name) - and ("config" in node.id.lower() or "settings" in node.id.lower()) - ) - or ( - isinstance(node, ast.Attribute) - and ("config" in node.attr.lower() or "settings" in node.attr.lower()) - ) - or ( - isinstance(node, ast.Constant) - and isinstance(node.value, str) - and ("config" in node.value.lower() or "settings" in node.value.lower()) - ) - for node in ast.walk(annotation) - ) - - @dataclass(frozen=True) class _AbstractValue: """Possible config provenance plus bounded container/object shape.""" @@ -162,15 +141,34 @@ class _AbstractValue: attributes: tuple[tuple[str, _AbstractValue], ...] | None = None identity: frozenset[int] = frozenset() literal: str | None = None + truth: bool | None = None _UNKNOWN_VALUE = _AbstractValue() +_ANNOTATION_MODULE = "" +_TYPE_CHECKING_FALSE = "" +_TYPING_MODULE = "" +_SECTION_ANNOTATIONS: Mapping[str, str] = { + "EvaluationConfig": "evaluation", + "ConsensusConfig": "consensus", +} +_CONFIG_ANNOTATION_MODULES = frozenset( + { + "ouroboros.config", + "ouroboros.config.models", + } +) + def _origin_value(*origins: str) -> _AbstractValue: return _AbstractValue(origins=frozenset(origins)) +def _type_checking_value() -> _AbstractValue: + return _AbstractValue(origins=frozenset({_TYPE_CHECKING_FALSE}), truth=False) + + def _contained_origins(value: _AbstractValue) -> frozenset[str]: origins = set(value.origins) for item in value.items or (): @@ -245,6 +243,9 @@ def join_entries( if all(value.literal == values[0].literal for value in values) else None ), + truth=( + values[0].truth if all(value.truth == values[0].truth for value in values) else None + ), ) @@ -263,6 +264,10 @@ def __init__(self, fields: frozenset[ConfigField]) -> None: self._fields = fields # Explicit unknown values shadow name-based config inference. self._states: list[dict[str, _AbstractValue]] = [{}] + self._annotations: list[dict[str, _AbstractValue]] = [{}] + self._functions: list[dict[str, ast.FunctionDef | ast.AsyncFunctionDef]] = [{}] + self._active_calls: set[int] = set() + self._return_values: list[list[_AbstractValue]] = [] self._expression_cache: dict[int, _AbstractValue] = {} self.reads: set[ConfigField] = set() @@ -274,6 +279,90 @@ def _name_value(self, name: str) -> _AbstractValue: return _origin_value(_CONFIG_ROOT) return _UNKNOWN_VALUE + def _annotation_name_value(self, name: str) -> _AbstractValue: + for scope in reversed(self._annotations): + if name in scope: + return scope[name] + return _UNKNOWN_VALUE + + def _annotation_value(self, annotation: ast.AST | None) -> _AbstractValue: + """Resolve only authoritative config model annotations. + + Merely containing a token such as ``config`` is not enough: wrappers, + unrelated classes, and the runtime evaluator's colliding + ``ConsensusConfig`` must not acquire root provenance. + """ + + if annotation is None: + return _UNKNOWN_VALUE + if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str): + try: + parsed = ast.parse(annotation.value, mode="eval") + except SyntaxError: + return _UNKNOWN_VALUE + return self._annotation_value(parsed.body) + if isinstance(annotation, ast.Name): + return self._annotation_name_value(annotation.id) + if isinstance(annotation, ast.Attribute): + owner = self._annotation_value(annotation.value) + if _ANNOTATION_MODULE in owner.origins: + section = _SECTION_ANNOTATIONS.get(annotation.attr) + if section is not None: + return _origin_value(section) + if annotation.attr == "OuroborosConfig": + return _origin_value(_CONFIG_ROOT) + dotted = self._dotted_name(annotation) + if dotted is not None: + module, _, name = dotted.rpartition(".") + if module in _CONFIG_ANNOTATION_MODULES: + section = _SECTION_ANNOTATIONS.get(name) + if section is not None: + return _origin_value(section) + if name == "OuroborosConfig": + return _origin_value(_CONFIG_ROOT) + return _UNKNOWN_VALUE + if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr): + return _join_values( + self._annotation_value(annotation.left), + self._annotation_value(annotation.right), + ) + if isinstance(annotation, ast.Subscript): + wrapper = _callable_name(annotation.value) + if wrapper in {"Annotated", "Optional"}: + item = annotation.slice + if wrapper == "Annotated" and isinstance(item, ast.Tuple) and item.elts: + item = item.elts[0] + return self._annotation_value(item) + return _UNKNOWN_VALUE + + @staticmethod + def _dotted_name(node: ast.AST) -> str | None: + parts: list[str] = [] + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + if not isinstance(node, ast.Name): + return None + parts.append(node.id) + return ".".join(reversed(parts)) + + def _local_function(self, name: str) -> ast.FunctionDef | ast.AsyncFunctionDef | None: + for scope in reversed(self._functions): + if name in scope: + return scope[name] + return None + + def _call_has_config_provenance(self, node: ast.Call) -> bool: + values = [ + self._expression_value( + argument.value if isinstance(argument, ast.Starred) else argument + ) + for argument in node.args + ] + values.extend(self._expression_value(keyword.value) for keyword in node.keywords) + tracked = TRACKED_SECTIONS | {_CONFIG_ROOT} + return any(_contained_origins(value) & tracked for value in values) + @staticmethod def _named_value( pairs: tuple[tuple[str, _AbstractValue], ...] | None, name: str @@ -338,6 +427,7 @@ def _replace_identity( attributes=attributes, identity=value.identity, literal=value.literal, + truth=value.truth, ) def _replace_shared_value( @@ -369,13 +459,15 @@ def _mapping_replacement( owner: _AbstractValue, entries: Iterable[tuple[str, _AbstractValue]], ) -> _AbstractValue: + normalized = tuple(entries) return _AbstractValue( origins=owner.origins, items=owner.items, - entries=tuple(entries), + entries=normalized, attributes=owner.attributes, identity=owner.identity, literal=owner.literal, + truth=False if not normalized else None, ) @staticmethod @@ -466,6 +558,7 @@ def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: attributes=owner.attributes, identity=frozenset({id(node)}), literal=owner.literal, + truth=owner.truth, ) if method == "values": return _AbstractValue(items=values) if owner.entries is not None else _UNKNOWN_VALUE @@ -565,12 +658,57 @@ def _dynamic_setdefault_value(self, node: ast.Call) -> _AbstractValue: self._replace_shared_value(owner, replacement, node.func.value) return _join_values(*values, default) + def _static_truth(self, node: ast.AST) -> bool | None: + """Return truth only when Python runtime behavior is statically certain.""" + + if isinstance(node, ast.Constant): + return bool(node.value) + if isinstance(node, ast.Dict): + if not node.keys: + return False + return True if any(key is not None for key in node.keys) else None + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + if not node.elts: + return False + return True if any(not isinstance(item, ast.Starred) for item in node.elts) else None + if isinstance(node, (ast.Name, ast.Attribute)): + value = self._expression_value(node) + return value.truth + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): + operand = self._static_truth(node.operand) + return None if operand is None else not operand + if isinstance(node, ast.BoolOp): + truths = [self._static_truth(value) for value in node.values] + if isinstance(node.op, ast.And): + if False in truths: + return False + return True if all(value is True for value in truths) else None + if True in truths: + return True + return False if all(value is False for value in truths) else None + if isinstance(node, ast.IfExp): + test = self._static_truth(node.test) + if test is not None: + return self._static_truth(node.body if test else node.orelse) + return None + + def _reachable_bool_values(self, node: ast.BoolOp) -> tuple[ast.expr, ...]: + reachable: list[ast.expr] = [] + for value in node.values: + reachable.append(value) + truth = self._static_truth(value) + if isinstance(node.op, ast.And) and truth is False: + break + if isinstance(node.op, ast.Or) and truth is True: + break + return tuple(reachable) + def _expression_value(self, node: ast.AST) -> _AbstractValue: cached = self._expression_cache.get(id(node)) if cached is not None: return cached if isinstance(node, ast.Constant): - return _AbstractValue(literal=_key_token(node)) + return _AbstractValue(literal=_key_token(node), truth=bool(node.value)) if isinstance(node, ast.Name): return self._name_value(node.id) if isinstance(node, ast.Attribute): @@ -580,6 +718,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return attribute if node.attr in TRACKED_SECTIONS and _CONFIG_ROOT in owner.origins: return _origin_value(node.attr) + if node.attr == "TYPE_CHECKING" and _TYPING_MODULE in owner.origins: + return _type_checking_value() if _looks_like_config_name(node.attr) and ( _CONFIG_ROOT in owner.origins or isinstance(node.value, ast.Name) @@ -655,6 +795,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue( entries=tuple(sorted(entries.items())), identity=frozenset({id(node)}) ) + if isinstance(node.func, ast.Name): + function = self._local_function(node.func.id) + if function is not None and self._call_has_config_provenance(node): + return self._local_call_value(node, function) return _UNKNOWN_VALUE if isinstance(node, ast.Subscript): owner = self._expression_value(node.value) @@ -683,7 +827,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: items.extend(value.items) else: items.append(value) - return _AbstractValue(items=tuple(items)) + return _AbstractValue(items=tuple(items), truth=self._static_truth(node)) if isinstance(node, ast.Dict): entries: dict[str, _AbstractValue] = {} for key, item in zip(node.keys, node.values, strict=True): @@ -699,14 +843,21 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: {_DYNAMIC_KEY: _conservative_value(value)}, ) return _AbstractValue( - entries=tuple(sorted(entries.items())), identity=frozenset({id(node)}) + entries=tuple(sorted(entries.items())), + identity=frozenset({id(node)}), + truth=self._static_truth(node), ) if isinstance(node, ast.IfExp): + truth = self._static_truth(node.test) + if truth is not None: + return self._expression_value(node.body if truth else node.orelse) return _join_values( self._expression_value(node.body), self._expression_value(node.orelse) ) if isinstance(node, ast.BoolOp): - return _join_values(*(self._expression_value(value) for value in node.values)) + return _join_values( + *(self._expression_value(value) for value in self._reachable_bool_values(node)) + ) if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): left = self._expression_value(node.left) right = self._expression_value(node.right) @@ -732,6 +883,19 @@ def visit_Attribute(self, node: ast.Attribute) -> None: self._record(section, node.attr) self.generic_visit(node) + def visit_IfExp(self, node: ast.IfExp) -> None: + self.visit(node.test) + truth = self._static_truth(node.test) + if truth is None: + self.visit(node.body) + self.visit(node.orelse) + else: + self.visit(node.body if truth else node.orelse) + + def visit_BoolOp(self, node: ast.BoolOp) -> None: + for value in self._reachable_bool_values(node): + self.visit(value) + def visit_Call(self, node: ast.Call) -> None: # Evaluate once even when the call is a standalone mutating # ``setdefault`` expression. @@ -766,6 +930,13 @@ def _bind_target_value(self, target: ast.expr, value: _AbstractValue) -> None: for element in target.elts: self._bind_target_value(element, _conservative_value(value)) + def _bind_annotation_target(self, target: ast.expr, value: _AbstractValue) -> None: + if isinstance(target, ast.Name): + self._annotations[-1][target.id] = value + elif isinstance(target, (ast.Tuple, ast.List)): + for element in target.elts: + self._bind_annotation_target(element, _UNKNOWN_VALUE) + def _bind_destructured(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Name): self._states[-1][target.id] = value @@ -840,10 +1011,12 @@ def _assign_store_target(self, target: ast.expr, value: _AbstractValue) -> None: def visit_Assign(self, node: ast.Assign) -> None: self.visit(node.value) value = self._expression_value(node.value) + annotation_value = self._annotation_value(node.value) for target in node.targets: self._visit_store_target(target) self._assign_store_target(target, value) self._bind_destructured(target, value) + self._bind_annotation_target(target, annotation_value) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: self.visit(node.annotation) @@ -853,6 +1026,7 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: value = self._expression_value(node.value) self._assign_store_target(node.target, value) self._bind_destructured(node.target, value) + self._bind_annotation_target(node.target, self._annotation_value(node.value)) def visit_NamedExpr(self, node: ast.NamedExpr) -> None: self.visit(node.value) @@ -903,6 +1077,14 @@ def _visit_paths( def visit_If(self, node: ast.If) -> None: self.visit(node.test) initial = dict(self._states[-1]) + truth = self._static_truth(node.test) + if truth is not None: + live_branch = node.body if truth else node.orelse + dead_branch = node.orelse if truth else node.body + if self._uses_type_checking(node.test): + self._register_type_only_imports(dead_branch) + self._states[-1] = self._visit_branch(live_branch, initial) + return body_state = self._visit_branch(node.body, initial) else_state = self._visit_branch(node.orelse, initial) if node.orelse else initial self._states[-1] = self._join_states(body_state, else_state) @@ -953,6 +1135,13 @@ def visit_AsyncFor(self, node: ast.AsyncFor) -> None: def visit_While(self, node: ast.While) -> None: entry = dict(self._states[-1]) + self.visit(node.test) + if self._static_truth(node.test) is False: + tested_state = dict(self._states[-1]) + self._states[-1] = ( + self._visit_branch(node.orelse, tested_state) if node.orelse else tested_state + ) + return header = entry while True: self._states[-1] = dict(header) @@ -1115,6 +1304,8 @@ def visit_Match(self, node: ast.Match) -> None: self._bind_pattern(case.pattern, subject) if case.guard is not None: self.visit(case.guard) + if self._static_truth(case.guard) is False: + continue branches.append(self._visit_branch(case.body, dict(self._states[-1]))) self._states[-1] = self._join_states(*branches) @@ -1127,16 +1318,26 @@ def _visit_comprehension( first_value = self._expression_value(first.iter) self._states.append({}) try: - self._bind_iteration_target(first.target, first_value) + if not self._bind_iteration_target(first.target, first_value): + self._expression_cache[id(node)] = _UNKNOWN_VALUE + return for condition in first.ifs: self.visit(condition) + if self._static_truth(condition) is False: + self._expression_cache[id(node)] = _UNKNOWN_VALUE + return for generator in remaining: self.visit(generator.iter) - self._bind_iteration_target( + if not self._bind_iteration_target( generator.target, self._expression_value(generator.iter) - ) + ): + self._expression_cache[id(node)] = _UNKNOWN_VALUE + return for condition in generator.ifs: self.visit(condition) + if self._static_truth(condition) is False: + self._expression_cache[id(node)] = _UNKNOWN_VALUE + return if isinstance(node, ast.DictComp): self.visit(node.key) self.visit(node.value) @@ -1163,14 +1364,67 @@ def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: def visit_DictComp(self, node: ast.DictComp) -> None: self._visit_comprehension(node) - def visit_Import(self, node: ast.Import) -> None: + def _uses_type_checking(self, node: ast.AST) -> bool: + return any( + _TYPE_CHECKING_FALSE in self._expression_value(candidate).origins + for candidate in ast.walk(node) + if isinstance(candidate, (ast.Name, ast.Attribute)) + ) + + def _register_type_only_imports(self, statements: Iterable[ast.stmt]) -> None: + for statement in statements: + if isinstance(statement, ast.Import): + self._bind_import(statement, runtime=False) + elif isinstance(statement, ast.ImportFrom): + self._bind_import_from(statement, runtime=False) + elif isinstance(statement, ast.If): + self._register_type_only_imports(statement.body) + self._register_type_only_imports(statement.orelse) + + @staticmethod + def _imported_annotation(module: str | None, name: str) -> _AbstractValue: + if module in _CONFIG_ANNOTATION_MODULES: + section = _SECTION_ANNOTATIONS.get(name) + if section is not None: + return _origin_value(section) + if name == "OuroborosConfig": + return _origin_value(_CONFIG_ROOT) + if name == "models": + return _origin_value(_ANNOTATION_MODULE) + return _UNKNOWN_VALUE + + def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: for alias in node.names: - self._states[-1][alias.asname or alias.name.partition(".")[0]] = _UNKNOWN_VALUE + bound = alias.asname or alias.name.partition(".")[0] + annotation_value = ( + _origin_value(_ANNOTATION_MODULE) + if alias.name in _CONFIG_ANNOTATION_MODULES + else _UNKNOWN_VALUE + ) + self._annotations[-1][bound] = annotation_value + if runtime: + self._states[-1][bound] = ( + _origin_value(_TYPING_MODULE) if alias.name == "typing" else _UNKNOWN_VALUE + ) - def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: for alias in node.names: - if alias.name != "*": - self._states[-1][alias.asname or alias.name] = _UNKNOWN_VALUE + if alias.name == "*": + continue + bound = alias.asname or alias.name + self._annotations[-1][bound] = self._imported_annotation(node.module, alias.name) + if runtime: + self._states[-1][bound] = ( + _type_checking_value() + if node.module == "typing" and alias.name == "TYPE_CHECKING" + else _UNKNOWN_VALUE + ) + + def visit_Import(self, node: ast.Import) -> None: + self._bind_import(node, runtime=True) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + self._bind_import_from(node, runtime=True) def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: for item in node.items: @@ -1187,11 +1441,10 @@ def visit_With(self, node: ast.With) -> None: def visit_AsyncWith(self, node: ast.AsyncWith) -> None: self._visit_with(node) - @staticmethod - def _argument_value(argument: ast.arg) -> _AbstractValue: - if _looks_like_config_name(argument.arg) or _annotation_names_config(argument.annotation): + def _argument_value(self, argument: ast.arg) -> _AbstractValue: + if _looks_like_config_name(argument.arg): return _origin_value(_CONFIG_ROOT) - return _UNKNOWN_VALUE + return self._annotation_value(argument.annotation) def _scoped_arguments(self, arguments: ast.arguments) -> dict[str, _AbstractValue]: scoped = { @@ -1208,26 +1461,146 @@ def _scoped_arguments(self, arguments: ast.arguments) -> dict[str, _AbstractValu scoped[arguments.kwarg.arg] = self._argument_value(arguments.kwarg) return scoped + @staticmethod + def _declared_functions( + statements: Iterable[ast.stmt], + ) -> dict[str, ast.FunctionDef | ast.AsyncFunctionDef]: + return { + statement.name: statement + for statement in statements + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + def _bound_call_arguments( + self, + call: ast.Call, + function: ast.FunctionDef | ast.AsyncFunctionDef, + ) -> dict[str, _AbstractValue]: + arguments = function.args + scoped = self._scoped_arguments(arguments) + positional_parameters = (*arguments.posonlyargs, *arguments.args) + positional_values: list[_AbstractValue] = [] + unknown_starred: _AbstractValue | None = None + for argument in call.args: + if isinstance(argument, ast.Starred): + expanded = self._expression_value(argument.value) + if expanded.items is not None: + positional_values.extend(expanded.items) + else: + unknown_starred = _conservative_value(expanded) + positional_values.append(unknown_starred) + else: + positional_values.append(self._expression_value(argument)) + + for parameter, value in zip(positional_parameters, positional_values, strict=False): + scoped[parameter.arg] = value + if unknown_starred is not None: + for parameter in positional_parameters[len(positional_values) :]: + scoped[parameter.arg] = _join_values(scoped[parameter.arg], unknown_starred) + if arguments.vararg is not None: + scoped[arguments.vararg.arg] = _AbstractValue( + items=tuple(positional_values[len(positional_parameters) :]) + ) + + named_parameters = { + parameter.arg: parameter for parameter in (*arguments.args, *arguments.kwonlyargs) + } + extra_keywords: list[tuple[str, _AbstractValue]] = [] + for keyword in call.keywords: + value = self._expression_value(keyword.value) + if keyword.arg is None: + if value.entries is not None: + entries = dict(value.entries) + wildcard = entries.get(_DYNAMIC_KEY) + for name in named_parameters: + selected = entries.get(_key_token(ast.Constant(name))) + if selected is not None: + scoped[name] = selected + elif wildcard is not None: + scoped[name] = _join_values(scoped[name], wildcard) + extra_keywords.extend(value.entries) + elif arguments.kwarg is not None: + scoped[arguments.kwarg.arg] = _conservative_value(value) + else: + conservative = _conservative_value(value) + for name in named_parameters: + scoped[name] = _join_values(scoped[name], conservative) + continue + if keyword.arg in named_parameters: + scoped[keyword.arg] = value + else: + extra_keywords.append((_key_token(ast.Constant(keyword.arg)), value)) + if arguments.kwarg is not None and extra_keywords: + scoped[arguments.kwarg.arg] = _AbstractValue(entries=tuple(extra_keywords)) + return scoped + + def _visit_function_body( + self, + node: ast.FunctionDef | ast.AsyncFunctionDef, + scoped: dict[str, _AbstractValue], + ) -> tuple[_AbstractValue, ...]: + self._states.append(scoped) + self._annotations.append({}) + self._functions.append(self._declared_functions(node.body)) + self._return_values.append([]) + try: + for statement in node.body: + self.visit(statement) + return tuple(self._return_values[-1]) + finally: + self._return_values.pop() + self._functions.pop() + self._annotations.pop() + self._states.pop() + + def _local_call_value( + self, + call: ast.Call, + function: ast.FunctionDef | ast.AsyncFunctionDef, + ) -> _AbstractValue: + function_id = id(function) + if function_id in self._active_calls: + return _UNKNOWN_VALUE + self._active_calls.add(function_id) + try: + returned = self._visit_function_body( + function, self._bound_call_arguments(call, function) + ) + finally: + self._active_calls.remove(function_id) + return _join_values(*returned) + + def visit_Return(self, node: ast.Return) -> None: + if node.value is None: + return + self.visit(node.value) + if self._return_values: + self._return_values[-1].append(self._expression_value(node.value)) + def _visit_scoped(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: for decorator in node.decorator_list: self.visit(decorator) for default in (*node.args.defaults, *node.args.kw_defaults): if default is not None: self.visit(default) - self._states.append(self._scoped_arguments(node.args)) - try: - for statement in node.body: - self.visit(statement) - finally: - self._states.pop() + self._visit_function_body(node, self._scoped_arguments(node.args)) def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._functions[-1][node.name] = node self._visit_scoped(node) self._states[-1][node.name] = _UNKNOWN_VALUE + self._annotations[-1][node.name] = _UNKNOWN_VALUE def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._functions[-1][node.name] = node self._visit_scoped(node) self._states[-1][node.name] = _UNKNOWN_VALUE + self._annotations[-1][node.name] = _UNKNOWN_VALUE + + def visit_Module(self, node: ast.Module) -> None: + self._functions[-1].update(self._declared_functions(node.body)) + for statement in node.body: + self.visit(statement) def visit_ClassDef(self, node: ast.ClassDef) -> None: for expression in (*node.decorator_list, *node.bases): @@ -1235,21 +1608,30 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: for keyword in node.keywords: self.visit(keyword.value) self._states.append({}) + self._annotations.append({}) + self._functions.append(self._declared_functions(node.body)) try: for statement in node.body: self.visit(statement) finally: + self._functions.pop() + self._annotations.pop() self._states.pop() self._states[-1][node.name] = _UNKNOWN_VALUE + self._annotations[-1][node.name] = _UNKNOWN_VALUE def visit_Lambda(self, node: ast.Lambda) -> None: for default in (*node.args.defaults, *node.args.kw_defaults): if default is not None: self.visit(default) self._states.append(self._scoped_arguments(node.args)) + self._annotations.append({}) + self._functions.append({}) try: self.visit(node.body) finally: + self._functions.pop() + self._annotations.pop() self._states.pop() diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 2019a2be03..1c8757f661 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -942,6 +942,147 @@ async def async_context_names(manager): assert contract.runtime_reads(tmp_path, fields) == frozenset() +def test_runtime_scan_excludes_static_dead_and_type_only_branches_without_pruning_dynamic( + contract, tmp_path: Path +) -> None: + (tmp_path / "reachable_branches.py").write_text( + """ +from typing import TYPE_CHECKING as CHECKING +import typing as typing_alias + +if CHECKING: + type_only_evaluation = config.evaluation.stage1_enabled +if typing_alias.TYPE_CHECKING: + type_only_consensus = config.consensus.min_models +if False: + dead_literal = config.evaluation.stage2_enabled +if True: + live_literal = config.evaluation.stage3_enabled +else: + dead_else = config.consensus.threshold +if runtime_flag: + dynamic_body = config.evaluation.uncertainty_threshold +else: + dynamic_else = config.consensus.models +maybe_type_checking = CHECKING if runtime_flag else another_runtime_flag +if maybe_type_checking: + dynamic_type_checking_collision = config.consensus.diversity_required + +dead_short_circuit = False and config.evaluation.satisfaction_threshold +live_short_circuit = False or config.consensus.advocate_model +dead_expression = ( + config.consensus.devil_model if False else config.evaluation.semantic_model +) +while False: + dead_loop = config.consensus.judge_model +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "advocate_model"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "diversity_required"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "min_models"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "advocate_model"), + contract.ConfigField("consensus", "diversity_required"), + contract.ConfigField("consensus", "models"), + } + ) + + +def test_runtime_scan_tracks_section_annotations_and_local_call_arguments_without_collisions( + contract, tmp_path: Path +) -> None: + (tmp_path / "section_helpers.py").write_text( + """ +from typing import TYPE_CHECKING + +from ouroboros.config.models import ConsensusConfig, EvaluationConfig +from ouroboros.evaluation.consensus import ConsensusConfig as RuntimeConsensusConfig + +EvaluationAlias = EvaluationConfig + +if TYPE_CHECKING: + from ouroboros.config.models import EvaluationConfig as EvaluationSection + +def typed_evaluation(section: EvaluationConfig): + return section.stage1_enabled + +def typed_consensus(section: ConsensusConfig | None): + return section.models + +def type_only_alias(section: EvaluationSection): + return section.stage2_enabled + +def assigned_alias(section: EvaluationAlias): + return section.assertion_extraction_model + +def untyped_helper(section): + return section.stage3_enabled + +def identity(section): + return section + +def caller(config): + untyped_helper(config.evaluation) + untyped_helper(**{"section": config.evaluation}) + return identity(config.consensus).judge_model + +def colliding_runtime_type(section: RuntimeConsensusConfig): + return section.min_models + +def arbitrary_config_annotation(section: ProjectConfig): + return section.satisfaction_threshold + +def wrapped_section_is_not_the_section(sections: list[EvaluationConfig]): + return sections.uncertainty_threshold +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "min_models"), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "assertion_extraction_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "models"), + } + ) + + def test_every_schema_field_needs_exactly_one_disposition(contract) -> None: active = contract.ConfigField("evaluation", "active") inert = contract.ConfigField("evaluation", "inert") From eb6a9f555174c345125129dedddee931456c7f21 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 13:47:25 +0900 Subject: [PATCH 09/70] fix(config): harden local call provenance --- scripts/check-config-reference-contract.py | 279 ++++++++++++++++-- .../test_check_config_reference_contract.py | 136 +++++++++ 2 files changed, 388 insertions(+), 27 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index ad0fd9f40e..87bce5b7d5 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -255,6 +255,8 @@ def _key_token(node: ast.AST) -> str: _DYNAMIC_KEY = "**" _MAPPING_MISSING = "" +_FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef +_FunctionSet = frozenset[_FunctionNode] class _RuntimeReadVisitor(ast.NodeVisitor): @@ -265,7 +267,7 @@ def __init__(self, fields: frozenset[ConfigField]) -> None: # Explicit unknown values shadow name-based config inference. self._states: list[dict[str, _AbstractValue]] = [{}] self._annotations: list[dict[str, _AbstractValue]] = [{}] - self._functions: list[dict[str, ast.FunctionDef | ast.AsyncFunctionDef]] = [{}] + self._functions: list[dict[str, _FunctionSet]] = [{}] self._active_calls: set[int] = set() self._return_values: list[list[_AbstractValue]] = [] self._expression_cache: dict[int, _AbstractValue] = {} @@ -346,13 +348,27 @@ def _dotted_name(node: ast.AST) -> str | None: parts.append(node.id) return ".".join(reversed(parts)) - def _local_function(self, name: str) -> ast.FunctionDef | ast.AsyncFunctionDef | None: + def _local_functions(self, name: str) -> _FunctionSet: for scope in reversed(self._functions): if name in scope: return scope[name] - return None + return frozenset() + + def _function_value(self, node: ast.AST) -> _FunctionSet: + if isinstance(node, ast.Name): + return self._local_functions(node.id) + if isinstance(node, ast.IfExp): + truth = self._static_truth(node.test) + if truth is not None: + return self._function_value(node.body if truth else node.orelse) + return self._function_value(node.body) | self._function_value(node.orelse) + return frozenset() - def _call_has_config_provenance(self, node: ast.Call) -> bool: + def _call_has_config_provenance( + self, + node: ast.Call, + function: ast.FunctionDef | ast.AsyncFunctionDef, + ) -> bool: values = [ self._expression_value( argument.value if isinstance(argument, ast.Starred) else argument @@ -360,6 +376,7 @@ def _call_has_config_provenance(self, node: ast.Call) -> bool: for argument in node.args ] values.extend(self._expression_value(keyword.value) for keyword in node.keywords) + values.extend(self._scoped_function_arguments(function).values()) tracked = TRACKED_SECTIONS | {_CONFIG_ROOT} return any(_contained_origins(value) & tracked for value in values) @@ -796,9 +813,16 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: entries=tuple(sorted(entries.items())), identity=frozenset({id(node)}) ) if isinstance(node.func, ast.Name): - function = self._local_function(node.func.id) - if function is not None and self._call_has_config_provenance(node): - return self._local_call_value(node, function) + functions = self._local_functions(node.func.id) + values = [ + self._local_call_value(node, function) + for function in functions + if self._call_has_config_provenance(node, function) + ] + if values: + value = _join_values(*values) + self._expression_cache[id(node)] = value + return value return _UNKNOWN_VALUE if isinstance(node, ast.Subscript): owner = self._expression_value(node.value) @@ -921,6 +945,26 @@ def _join_states( for name in names } + @staticmethod + def _join_function_bindings( + *states: Mapping[str, _FunctionSet], + ) -> dict[str, _FunctionSet]: + names = set().union(*(state.keys() for state in states)) + return { + name: frozenset().union(*(state.get(name, frozenset()) for state in states)) + for name in names + } + + @staticmethod + def _join_annotation_bindings( + *states: Mapping[str, _AbstractValue], + ) -> dict[str, _AbstractValue]: + names = set().union(*(state.keys() for state in states)) + return { + name: _join_values(*(state.get(name, _UNKNOWN_VALUE) for state in states)) + for name in names + } + def _bind_target_value(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Name): self._states[-1][target.id] = value @@ -937,6 +981,17 @@ def _bind_annotation_target(self, target: ast.expr, value: _AbstractValue) -> No for element in target.elts: self._bind_annotation_target(element, _UNKNOWN_VALUE) + def _bind_function_target( + self, + target: ast.expr, + functions: _FunctionSet, + ) -> None: + if isinstance(target, ast.Name): + self._functions[-1][target.id] = functions + elif isinstance(target, (ast.Tuple, ast.List)): + for element in target.elts: + self._bind_function_target(element, frozenset()) + def _bind_destructured(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Name): self._states[-1][target.id] = value @@ -1012,14 +1067,17 @@ def visit_Assign(self, node: ast.Assign) -> None: self.visit(node.value) value = self._expression_value(node.value) annotation_value = self._annotation_value(node.value) + function = self._function_value(node.value) for target in node.targets: self._visit_store_target(target) self._assign_store_target(target, value) self._bind_destructured(target, value) self._bind_annotation_target(target, annotation_value) + self._bind_function_target(target, function) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: self.visit(node.annotation) + self._bind_function_target(node.target, frozenset()) if node.value is not None: self.visit(node.value) self._visit_store_target(node.target) @@ -1027,10 +1085,12 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: self._assign_store_target(node.target, value) self._bind_destructured(node.target, value) self._bind_annotation_target(node.target, self._annotation_value(node.value)) + self._bind_function_target(node.target, self._function_value(node.value)) def visit_NamedExpr(self, node: ast.NamedExpr) -> None: self.visit(node.value) self._bind_destructured(node.target, self._expression_value(node.value)) + self._bind_function_target(node.target, self._function_value(node.value)) def visit_AugAssign(self, node: ast.AugAssign) -> None: owner = self._expression_value(node.target) @@ -1051,6 +1111,7 @@ def visit_AugAssign(self, node: ast.AugAssign) -> None: return if isinstance(node.target, ast.Name): self._states[-1][node.target.id] = self._name_value(node.target.id) + self._functions[-1][node.target.id] = frozenset() def _visit_branch( self, @@ -1059,9 +1120,32 @@ def _visit_branch( ) -> dict[str, _AbstractValue]: self._states[-1] = dict(initial) for statement in statements: + terminates = self._statement_always_terminates(statement) self.visit(statement) + if terminates: + break return dict(self._states[-1]) + def _statements_always_terminate(self, statements: Iterable[ast.stmt]) -> bool: + return any(self._statement_always_terminates(statement) for statement in statements) + + def _statement_always_terminates(self, statement: ast.stmt) -> bool: + if isinstance(statement, (ast.Return, ast.Raise)): + return True + if isinstance(statement, ast.If): + truth = self._static_truth(statement.test) + if truth is not None: + branch = statement.body if truth else statement.orelse + return self._statements_always_terminate(branch) + return ( + bool(statement.orelse) + and self._statements_always_terminate(statement.body) + and self._statements_always_terminate(statement.orelse) + ) + if isinstance(statement, (ast.With, ast.AsyncWith)): + return self._statements_always_terminate(statement.body) + return False + def _visit_paths( self, statements: Iterable[ast.stmt], @@ -1070,8 +1154,11 @@ def _visit_paths( self._states[-1] = dict(initial) prefixes = dict(initial) for statement in statements: + terminates = self._statement_always_terminates(statement) self.visit(statement) prefixes = self._join_states(prefixes, self._states[-1]) + if terminates: + break return dict(self._states[-1]), prefixes def visit_If(self, node: ast.If) -> None: @@ -1085,9 +1172,47 @@ def visit_If(self, node: ast.If) -> None: self._register_type_only_imports(dead_branch) self._states[-1] = self._visit_branch(live_branch, initial) return + initial_functions = dict(self._functions[-1]) + initial_annotations = dict(self._annotations[-1]) + body_terminates = self._statements_always_terminate(node.body) + else_terminates = bool(node.orelse) and self._statements_always_terminate(node.orelse) body_state = self._visit_branch(node.body, initial) + body_functions = dict(self._functions[-1]) + body_annotations = dict(self._annotations[-1]) + self._functions[-1] = dict(initial_functions) + self._annotations[-1] = dict(initial_annotations) else_state = self._visit_branch(node.orelse, initial) if node.orelse else initial - self._states[-1] = self._join_states(body_state, else_state) + else_functions = dict(self._functions[-1]) + else_annotations = dict(self._annotations[-1]) + state_paths = [ + state + for state, terminates in ( + (body_state, body_terminates), + (else_state, else_terminates), + ) + if not terminates + ] + function_paths = [ + state + for state, terminates in ( + (body_functions, body_terminates), + (else_functions, else_terminates), + ) + if not terminates + ] + annotation_paths = [ + state + for state, terminates in ( + (body_annotations, body_terminates), + (else_annotations, else_terminates), + ) + if not terminates + ] + self._states[-1] = self._join_states(*(state_paths or [initial])) + self._functions[-1] = self._join_function_bindings(*(function_paths or [initial_functions])) + self._annotations[-1] = self._join_annotation_bindings( + *(annotation_paths or [initial_annotations]) + ) @staticmethod def _iteration_values(value: _AbstractValue) -> tuple[_AbstractValue, ...]: @@ -1106,6 +1231,7 @@ def _bind_iteration_target(self, target: ast.expr, value: _AbstractValue) -> boo for candidate in candidates: self._states[-1] = dict(initial) self._bind_destructured(target, candidate) + self._bind_function_target(target, frozenset()) candidate_states.append(dict(self._states[-1])) self._states[-1] = self._join_states(*candidate_states) return True @@ -1403,6 +1529,7 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: ) self._annotations[-1][bound] = annotation_value if runtime: + self._functions[-1][bound] = frozenset() self._states[-1][bound] = ( _origin_value(_TYPING_MODULE) if alias.name == "typing" else _UNKNOWN_VALUE ) @@ -1414,6 +1541,7 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: bound = alias.asname or alias.name self._annotations[-1][bound] = self._imported_annotation(node.module, alias.name) if runtime: + self._functions[-1][bound] = frozenset() self._states[-1][bound] = ( _type_checking_value() if node.module == "typing" and alias.name == "TYPE_CHECKING" @@ -1461,12 +1589,49 @@ def _scoped_arguments(self, arguments: ast.arguments) -> dict[str, _AbstractValu scoped[arguments.kwarg.arg] = self._argument_value(arguments.kwarg) return scoped + def _default_argument_values( + self, node: ast.FunctionDef | ast.AsyncFunctionDef + ) -> dict[str, _AbstractValue]: + previous_cache = self._expression_cache + self._expression_cache = {} + try: + positional = (*node.args.posonlyargs, *node.args.args) + defaulted = positional[-len(node.args.defaults) :] if node.args.defaults else () + values = { + argument.arg: self._expression_value(default) + for argument, default in zip( + defaulted, + node.args.defaults, + strict=True, + ) + } + values.update( + { + argument.arg: self._expression_value(default) + for argument, default in zip( + node.args.kwonlyargs, node.args.kw_defaults, strict=True + ) + if default is not None + } + ) + return values + finally: + self._expression_cache = previous_cache + + def _scoped_function_arguments( + self, node: ast.FunctionDef | ast.AsyncFunctionDef + ) -> dict[str, _AbstractValue]: + scoped = self._scoped_arguments(node.args) + for name, value in self._default_argument_values(node).items(): + scoped[name] = _join_values(scoped[name], value) + return scoped + @staticmethod def _declared_functions( statements: Iterable[ast.stmt], - ) -> dict[str, ast.FunctionDef | ast.AsyncFunctionDef]: + ) -> dict[str, _FunctionSet]: return { - statement.name: statement + statement.name: frozenset({statement}) for statement in statements if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) } @@ -1478,28 +1643,68 @@ def _bound_call_arguments( ) -> dict[str, _AbstractValue]: arguments = function.args scoped = self._scoped_arguments(arguments) + scoped.update(self._default_argument_values(function)) positional_parameters = (*arguments.posonlyargs, *arguments.args) - positional_values: list[_AbstractValue] = [] - unknown_starred: _AbstractValue | None = None + positional_states: list[ + tuple[int, dict[str, _AbstractValue], tuple[_AbstractValue, ...]] + ] = [(0, {}, ())] + + def bind_positional( + states: list[tuple[int, dict[str, _AbstractValue], tuple[_AbstractValue, ...]]], + value: _AbstractValue, + ) -> list[tuple[int, dict[str, _AbstractValue], tuple[_AbstractValue, ...]]]: + bound: list[tuple[int, dict[str, _AbstractValue], tuple[_AbstractValue, ...]]] = [] + for index, bindings, extras in states: + if index < len(positional_parameters): + updated = dict(bindings) + updated[positional_parameters[index].arg] = value + bound.append((index + 1, updated, extras)) + elif arguments.vararg is not None: + bound.append((index, bindings, (*extras, value))) + return bound + for argument in call.args: if isinstance(argument, ast.Starred): expanded = self._expression_value(argument.value) if expanded.items is not None: - positional_values.extend(expanded.items) + for value in expanded.items: + positional_states = bind_positional(positional_states, value) else: - unknown_starred = _conservative_value(expanded) - positional_values.append(unknown_starred) + conservative = _conservative_value(expanded) + expanded_states: list[ + tuple[ + int, + dict[str, _AbstractValue], + tuple[_AbstractValue, ...], + ] + ] = [] + for state in positional_states: + alternatives = [state] + current = [state] + while current and current[0][0] < len(positional_parameters): + current = bind_positional(current, conservative) + alternatives.extend(current) + if arguments.vararg is not None and current: + alternatives.extend(bind_positional(current, conservative)) + expanded_states.extend(alternatives) + positional_states = expanded_states else: - positional_values.append(self._expression_value(argument)) + positional_states = bind_positional( + positional_states, self._expression_value(argument) + ) - for parameter, value in zip(positional_parameters, positional_values, strict=False): - scoped[parameter.arg] = value - if unknown_starred is not None: - for parameter in positional_parameters[len(positional_values) :]: - scoped[parameter.arg] = _join_values(scoped[parameter.arg], unknown_starred) - if arguments.vararg is not None: + for parameter in positional_parameters: + scoped[parameter.arg] = _join_values( + *( + bindings.get(parameter.arg, scoped[parameter.arg]) + for _, bindings, _ in positional_states + ) + ) + if arguments.vararg is not None and positional_states: scoped[arguments.vararg.arg] = _AbstractValue( - items=tuple(positional_values[len(positional_parameters) :]) + items=( + _join_values(*(item for _, _, extras in positional_states for item in extras)), + ) ) named_parameters = { @@ -1541,13 +1746,32 @@ def _visit_function_body( ) -> tuple[_AbstractValue, ...]: self._states.append(scoped) self._annotations.append({}) - self._functions.append(self._declared_functions(node.body)) + function_bindings: dict[str, _FunctionSet] = { + argument.arg: frozenset() + for argument in ( + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ) + } + if node.args.vararg is not None: + function_bindings[node.args.vararg.arg] = frozenset() + if node.args.kwarg is not None: + function_bindings[node.args.kwarg.arg] = frozenset() + function_bindings.update(self._declared_functions(node.body)) + self._functions.append(function_bindings) self._return_values.append([]) + previous_cache = self._expression_cache + self._expression_cache = {} try: for statement in node.body: + terminates = self._statement_always_terminates(statement) self.visit(statement) + if terminates: + break return tuple(self._return_values[-1]) finally: + self._expression_cache = previous_cache self._return_values.pop() self._functions.pop() self._annotations.pop() @@ -1583,16 +1807,16 @@ def _visit_scoped(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: for default in (*node.args.defaults, *node.args.kw_defaults): if default is not None: self.visit(default) - self._visit_function_body(node, self._scoped_arguments(node.args)) + self._visit_function_body(node, self._scoped_function_arguments(node)) def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - self._functions[-1][node.name] = node + self._functions[-1][node.name] = frozenset({node}) self._visit_scoped(node) self._states[-1][node.name] = _UNKNOWN_VALUE self._annotations[-1][node.name] = _UNKNOWN_VALUE def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: - self._functions[-1][node.name] = node + self._functions[-1][node.name] = frozenset({node}) self._visit_scoped(node) self._states[-1][node.name] = _UNKNOWN_VALUE self._annotations[-1][node.name] = _UNKNOWN_VALUE @@ -1619,6 +1843,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: self._states.pop() self._states[-1][node.name] = _UNKNOWN_VALUE self._annotations[-1][node.name] = _UNKNOWN_VALUE + self._functions[-1][node.name] = frozenset() def visit_Lambda(self, node: ast.Lambda) -> None: for default in (*node.args.defaults, *node.args.kw_defaults): diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 1c8757f661..654fb5bfe4 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1083,6 +1083,142 @@ def wrapped_section_is_not_the_section(sections: list[EvaluationConfig]): ) +def test_runtime_scan_keeps_local_call_cache_context_and_section_defaults( + contract, tmp_path: Path +) -> None: + (tmp_path / "call_context.py").write_text( + """ +def identity(value): + return {"value": value}.get("value") + +cache_context_read = identity(config.evaluation).stage1_enabled + +def default_identity(value=config.evaluation): + return value + +default_read = default_identity().stage2_enabled +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_tracks_local_function_aliases_and_unknown_leading_star_args_but_rebinding( + contract, tmp_path: Path +) -> None: + (tmp_path / "call_bindings.py").write_text( + """ +def aliased_reader(value): + return value.judge_model + +reader = aliased_reader +aliased_reader = external +alias_read = reader(config.consensus) + +def second_reader(prefix, value): + return value.models + +starred_read = second_reader(*unknown_args, config.consensus) + +def rebound_reader(value): + return value.min_models + +rebound_reader = external +rebound_reader(config.consensus) + +def dynamic_reader(value): + return value.devil_model + +maybe_reader = external +if runtime_flag: + maybe_reader = dynamic_reader +maybe_reader(config.consensus) + +def statically_dead_reader(value): + return value.advocate_model + +dead_reader = external +if False: + dead_reader = statically_dead_reader +dead_reader(config.consensus) +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "advocate_model"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "min_models"), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "models"), + } + ) + + +def test_runtime_scan_stops_after_unconditional_exit_but_keeps_dynamic_fallthrough( + contract, tmp_path: Path +) -> None: + (tmp_path / "terminators.py").write_text( + """ +def direct_return(config): + return None + dead_read = config.evaluation.satisfaction_threshold + +def direct_raise(config): + raise RuntimeError + dead_read = config.consensus.min_models + +def both_branches_exit(config, enabled): + if enabled: + return None + else: + raise RuntimeError + dead_read = config.consensus.threshold + +def dynamic_fallthrough(config, enabled): + if enabled: + return None + live_read = config.evaluation.uncertainty_threshold + +def terminating_alias_path(config, report, enabled): + section = report.evaluation + if enabled: + section = config.evaluation + return None + unrelated_fallthrough = section.stage1_enabled +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + contract.ConfigField("consensus", "min_models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "uncertainty_threshold")} + ) + + def test_every_schema_field_needs_exactly_one_disposition(contract) -> None: active = contract.ConfigField("evaluation", "active") inert = contract.ConfigField("evaluation", "inert") From 51f57db0c2e801d814e468f67d10914a5e412693 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 14:30:41 +0900 Subject: [PATCH 10/70] fix(config): join callable control-flow bindings --- scripts/check-config-reference-contract.py | 208 ++++++++++++++---- .../test_check_config_reference_contract.py | 129 +++++++++++ 2 files changed, 290 insertions(+), 47 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 87bce5b7d5..fd18cb396d 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -257,6 +257,11 @@ def _key_token(node: ast.AST) -> str: _MAPPING_MISSING = "" _FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef _FunctionSet = frozenset[_FunctionNode] +_BindingSnapshot = tuple[ + dict[str, _AbstractValue], + dict[str, _FunctionSet], + dict[str, _AbstractValue], +] class _RuntimeReadVisitor(ast.NodeVisitor): @@ -965,6 +970,56 @@ def _join_annotation_bindings( for name in names } + def _binding_snapshot(self) -> _BindingSnapshot: + return ( + dict(self._states[-1]), + dict(self._functions[-1]), + dict(self._annotations[-1]), + ) + + def _restore_bindings(self, snapshot: _BindingSnapshot) -> None: + state, functions, annotations = snapshot + self._states[-1] = dict(state) + self._functions[-1] = dict(functions) + self._annotations[-1] = dict(annotations) + + def _join_binding_snapshots(self, *snapshots: _BindingSnapshot) -> _BindingSnapshot: + return ( + self._join_states(*(snapshot[0] for snapshot in snapshots)), + self._join_function_bindings(*(snapshot[1] for snapshot in snapshots)), + self._join_annotation_bindings(*(snapshot[2] for snapshot in snapshots)), + ) + + def _visit_binding_branch( + self, + statements: Iterable[ast.stmt], + initial: _BindingSnapshot, + ) -> _BindingSnapshot: + self._restore_bindings(initial) + for statement in statements: + terminates = self._statement_always_terminates(statement) + self.visit(statement) + if terminates: + break + return self._binding_snapshot() + + def _visit_binding_paths( + self, + statements: Iterable[ast.stmt], + initial: _BindingSnapshot, + ) -> tuple[_BindingSnapshot, _BindingSnapshot]: + """Return normal completion and every feasible exception prefix.""" + + self._restore_bindings(initial) + prefixes = initial + for statement in statements: + terminates = self._statement_always_terminates(statement) + self.visit(statement) + prefixes = self._join_binding_snapshots(prefixes, self._binding_snapshot()) + if terminates: + break + return self._binding_snapshot(), prefixes + def _bind_target_value(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Name): self._states[-1][target.id] = value @@ -1144,22 +1199,28 @@ def _statement_always_terminates(self, statement: ast.stmt) -> bool: ) if isinstance(statement, (ast.With, ast.AsyncWith)): return self._statements_always_terminate(statement.body) + if isinstance(statement, (ast.Try, ast.TryStar)): + if statement.finalbody and self._statements_always_terminate(statement.finalbody): + return True + normal_terminates = self._statements_always_terminate(statement.body) or ( + bool(statement.orelse) and self._statements_always_terminate(statement.orelse) + ) + return normal_terminates and all( + self._statements_always_terminate(handler.body) for handler in statement.handlers + ) + if isinstance(statement, ast.Match): + return self._match_is_exhaustive(statement) and all( + self._statements_always_terminate(case.body) for case in statement.cases + ) return False - def _visit_paths( - self, - statements: Iterable[ast.stmt], - initial: dict[str, _AbstractValue], - ) -> tuple[dict[str, _AbstractValue], dict[str, _AbstractValue]]: - self._states[-1] = dict(initial) - prefixes = dict(initial) - for statement in statements: - terminates = self._statement_always_terminates(statement) - self.visit(statement) - prefixes = self._join_states(prefixes, self._states[-1]) - if terminates: - break - return dict(self._states[-1]), prefixes + def _match_is_exhaustive(self, node: ast.Match) -> bool: + return any( + isinstance(case.pattern, ast.MatchAs) + and case.pattern.pattern is None + and (case.guard is None or self._static_truth(case.guard) is True) + for case in node.cases + ) def visit_If(self, node: ast.If) -> None: self.visit(node.test) @@ -1232,6 +1293,7 @@ def _bind_iteration_target(self, target: ast.expr, value: _AbstractValue) -> boo self._states[-1] = dict(initial) self._bind_destructured(target, candidate) self._bind_function_target(target, frozenset()) + self._bind_annotation_target(target, _UNKNOWN_VALUE) candidate_states.append(dict(self._states[-1])) self._states[-1] = self._join_states(*candidate_states) return True @@ -1239,65 +1301,99 @@ def _bind_iteration_target(self, target: ast.expr, value: _AbstractValue) -> boo def visit_For(self, node: ast.For) -> None: self.visit(node.iter) iterable = self._expression_value(node.iter) - entry = dict(self._states[-1]) - self._states[-1] = dict(entry) + zero_iterations_possible = self._static_truth(node.iter) is not True + entry = self._binding_snapshot() + self._restore_bindings(entry) if not self._bind_iteration_target(node.target, iterable): - self._states[-1] = self._visit_branch(node.orelse, entry) if node.orelse else entry + completed = self._visit_binding_branch(node.orelse, entry) if node.orelse else entry + self._restore_bindings(completed) return header = entry + body_terminates = self._statements_always_terminate(node.body) while True: - self._states[-1] = dict(header) + self._restore_bindings(header) self._bind_iteration_target(node.target, iterable) - body_state = self._visit_branch(node.body, dict(self._states[-1])) - joined = self._join_states(entry, body_state) + body_state = self._visit_binding_branch(node.body, self._binding_snapshot()) + paths = ([] if not zero_iterations_possible else [entry]) + ( + [] if body_terminates else [body_state] + ) + joined = self._join_binding_snapshots(*(paths or [body_state])) if joined == header: break header = joined - else_state = self._visit_branch(node.orelse, header) if node.orelse else header - self._states[-1] = self._join_states(header, body_state, else_state) + else_state = self._visit_binding_branch(node.orelse, header) if node.orelse else header + self._restore_bindings(else_state) def visit_AsyncFor(self, node: ast.AsyncFor) -> None: self.visit_For(node) def visit_While(self, node: ast.While) -> None: - entry = dict(self._states[-1]) + entry = self._binding_snapshot() self.visit(node.test) - if self._static_truth(node.test) is False: - tested_state = dict(self._states[-1]) - self._states[-1] = ( - self._visit_branch(node.orelse, tested_state) if node.orelse else tested_state + truth = self._static_truth(node.test) + if truth is False: + tested_state = self._binding_snapshot() + completed = ( + self._visit_binding_branch(node.orelse, tested_state) + if node.orelse + else tested_state ) + self._restore_bindings(completed) return header = entry + body_terminates = self._statements_always_terminate(node.body) while True: - self._states[-1] = dict(header) + self._restore_bindings(header) self.visit(node.test) - tested_state = dict(self._states[-1]) - body_state = self._visit_branch(node.body, tested_state) - joined = self._join_states(entry, body_state) + tested_state = self._binding_snapshot() + body_state = self._visit_binding_branch(node.body, tested_state) + paths = ([] if truth is True else [entry]) + ([] if body_terminates else [body_state]) + joined = self._join_binding_snapshots(*(paths or [body_state])) if joined == header: break header = joined - else_state = self._visit_branch(node.orelse, tested_state) if node.orelse else tested_state - self._states[-1] = self._join_states(tested_state, body_state, else_state) + self._restore_bindings(header) + self.visit(node.test) + tested_state = self._binding_snapshot() + else_state = ( + self._visit_binding_branch(node.orelse, tested_state) + if node.orelse and truth is not True + else tested_state + ) + self._restore_bindings(else_state) def _visit_try(self, node: ast.Try | ast.TryStar) -> None: - entry = dict(self._states[-1]) - body_state, exception_states = self._visit_paths(node.body, entry) - normal_state = self._visit_branch(node.orelse, body_state) if node.orelse else body_state - completed = [normal_state] + entry = self._binding_snapshot() + body_state, exception_states = self._visit_binding_paths(node.body, entry) + normal_state = ( + self._visit_binding_branch(node.orelse, body_state) if node.orelse else body_state + ) + normal_terminates = self._statements_always_terminate(node.body) or ( + bool(node.orelse) and self._statements_always_terminate(node.orelse) + ) + completed = [] if normal_terminates else [normal_state] for handler in node.handlers: - self._states[-1] = dict(exception_states) + self._restore_bindings(exception_states) if handler.type is not None: self.visit(handler.type) if handler.name is not None: self._states[-1][handler.name] = _UNKNOWN_VALUE - completed.append(self._visit_branch(handler.body, dict(self._states[-1]))) + self._functions[-1][handler.name] = frozenset() + self._annotations[-1][handler.name] = _UNKNOWN_VALUE + handler_state = self._visit_binding_branch(handler.body, self._binding_snapshot()) + if not self._statements_always_terminate(handler.body): + completed.append(handler_state) if node.finalbody: - incoming = self._join_states(exception_states, *completed) - self._states[-1] = self._visit_branch(node.finalbody, incoming) + incoming = self._join_binding_snapshots(exception_states, *completed) + all_final_state = self._visit_binding_branch(node.finalbody, incoming) + final_state = ( + self._visit_binding_branch(node.finalbody, self._join_binding_snapshots(*completed)) + if completed + else all_final_state + ) + self._restore_bindings(final_state) else: - self._states[-1] = self._join_states(*completed) + self._restore_bindings(self._join_binding_snapshots(*(completed or [entry]))) def visit_Try(self, node: ast.Try) -> None: self._visit_try(node) @@ -1332,10 +1428,14 @@ def _bind_pattern(self, pattern: ast.pattern, subject: _AbstractValue) -> None: self._bind_pattern(pattern.pattern, subject) if pattern.name is not None: self._states[-1][pattern.name] = subject + self._functions[-1][pattern.name] = frozenset() + self._annotations[-1][pattern.name] = _UNKNOWN_VALUE return if isinstance(pattern, ast.MatchStar): if pattern.name is not None: self._states[-1][pattern.name] = subject + self._functions[-1][pattern.name] = frozenset() + self._annotations[-1][pattern.name] = _UNKNOWN_VALUE return if isinstance(pattern, ast.MatchOr): initial = dict(self._states[-1]) @@ -1366,6 +1466,8 @@ def _bind_pattern(self, pattern: ast.pattern, subject: _AbstractValue) -> None: self._states[-1][pattern.rest] = _AbstractValue( entries=remaining, identity=frozenset({id(pattern)}) ) + self._functions[-1][pattern.rest] = frozenset() + self._annotations[-1][pattern.rest] = _UNKNOWN_VALUE return if isinstance(pattern, ast.MatchClass): fallback = _conservative_value(subject) @@ -1422,18 +1524,30 @@ def _bind_sequence_pattern(self, pattern: ast.MatchSequence, subject: _AbstractV def visit_Match(self, node: ast.Match) -> None: self.visit(node.subject) subject = self._expression_value(node.subject) - initial = dict(self._states[-1]) - branches = [initial] + initial = self._binding_snapshot() + branches: list[_BindingSnapshot] = [] + unmatched = True for case in node.cases: - self._states[-1] = dict(initial) + self._restore_bindings(initial) self._visit_pattern_reads(case.pattern) self._bind_pattern(case.pattern, subject) if case.guard is not None: self.visit(case.guard) if self._static_truth(case.guard) is False: continue - branches.append(self._visit_branch(case.body, dict(self._states[-1]))) - self._states[-1] = self._join_states(*branches) + branch = self._visit_binding_branch(case.body, self._binding_snapshot()) + if not self._statements_always_terminate(case.body): + branches.append(branch) + if ( + isinstance(case.pattern, ast.MatchAs) + and case.pattern.pattern is None + and (case.guard is None or self._static_truth(case.guard) is True) + ): + unmatched = False + break + if unmatched: + branches.append(initial) + self._restore_bindings(self._join_binding_snapshots(*(branches or [initial]))) def _visit_comprehension( self, diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 654fb5bfe4..ac9a1fc2b5 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1170,6 +1170,135 @@ def statically_dead_reader(value): ) +def test_runtime_scan_joins_callable_bindings_across_compound_statement_paths( + contract, tmp_path: Path +) -> None: + (tmp_path / "callable_control_flow.py").write_text( + """ +def for_reader(value): + return value.judge_model + +def for_zero_iteration(config, unknown_items): + handler = for_reader + for item in unknown_items: + handler = external + return handler(config.consensus) + +def while_reader(value): + return value.models + +def while_zero_iteration(config, enabled): + handler = while_reader + while enabled: + handler = external + return handler(config.consensus) + +def try_reader(value): + return value.devil_model + +def try_success(config, risky): + handler = try_reader + try: + risky() + except RuntimeError: + handler = external + return handler(config.consensus) + +def match_reader(value): + return value.advocate_model + +def match_unmatched(config, selector): + handler = match_reader + match selector: + case 0: + handler = external + return handler(config.consensus) + +def unreachable_reader(value): + return value.min_models + +def terminating_paths_do_not_escape(config, report, selector, risky): + handler = external + for _ in unknown_items: + handler = unreachable_reader + return None + handler(config.consensus) + + try: + risky() + except RuntimeError: + handler = unreachable_reader + return None + handler(config.consensus) + + match selector: + case 0: + handler = unreachable_reader + return None + case _: + handler = external + handler(config.consensus) + +def exhaustive_shadowing(config, selector): + handler = unreachable_reader + match selector: + case 0: + handler = external + case _: + handler = external + return handler(config.consensus) + +def exhaustive_termination(config, selector): + handler = unreachable_reader + match selector: + case 0: + return None + case _ if True: + raise RuntimeError + handler(config.consensus) + +def try_termination(config, risky): + handler = unreachable_reader + try: + return risky() + except RuntimeError: + raise + handler(config.consensus) + +def guaranteed_iteration_shadowing(config): + handler = unreachable_reader + for _ in [1]: + handler = external + handler(config.consensus) + + handler = unreachable_reader + while True: + handler = external + break + handler(config.consensus) +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("consensus", "advocate_model"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "min_models"), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("consensus", "advocate_model"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "models"), + } + ) + + def test_runtime_scan_stops_after_unconditional_exit_but_keeps_dynamic_fallthrough( contract, tmp_path: Path ) -> None: From e5e33ae85ce68ad7acf49f54f81c928f3d0ca753 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 14:53:35 +0900 Subject: [PATCH 11/70] fix(config): preserve abrupt control-flow bindings --- scripts/check-config-reference-contract.py | 426 ++++++++++-------- .../test_check_config_reference_contract.py | 132 ++++++ 2 files changed, 377 insertions(+), 181 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index fd18cb396d..5c3ec3ac32 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -262,6 +262,15 @@ def _key_token(node: ast.AST) -> str: dict[str, _FunctionSet], dict[str, _AbstractValue], ] +_AbruptPath = tuple[str, _BindingSnapshot] + + +@dataclass(frozen=True) +class _FlowResult: + """Binding paths leaving one statement suite.""" + + fallthrough: _BindingSnapshot | None + abrupt: tuple[_AbruptPath, ...] = () class _RuntimeReadVisitor(ast.NodeVisitor): @@ -276,6 +285,8 @@ def __init__(self, fields: frozenset[ConfigField]) -> None: self._active_calls: set[int] = set() self._return_values: list[list[_AbstractValue]] = [] self._expression_cache: dict[int, _AbstractValue] = {} + self._flow_abrupts: list[list[_AbruptPath]] = [[]] + self._path_reachable = True self.reads: set[ConfigField] = set() def _name_value(self, name: str) -> _AbstractValue: @@ -847,32 +858,36 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: candidates = [*(owner.items or ()), *(value for _, value in owner.entries or ())] return _join_values(*candidates) if isinstance(node, (ast.Tuple, ast.List, ast.Set)): - items: list[_AbstractValue] = [] + sequence_items: list[_AbstractValue] = [] for element in node.elts: value = self._expression_value( element.value if isinstance(element, ast.Starred) else element ) if isinstance(element, ast.Starred) and value.items is not None: - items.extend(value.items) + sequence_items.extend(value.items) else: - items.append(value) - return _AbstractValue(items=tuple(items), truth=self._static_truth(node)) + sequence_items.append(value) + return _AbstractValue(items=tuple(sequence_items), truth=self._static_truth(node)) if isinstance(node, ast.Dict): - entries: dict[str, _AbstractValue] = {} + literal_entries: dict[str, _AbstractValue] = {} for key, item in zip(node.keys, node.values, strict=True): value = self._expression_value(item) if key is not None: key_value = self._expression_value(key) - self._add_mapping_entry(entries, key_value.literal or _DYNAMIC_KEY, value) + self._add_mapping_entry( + literal_entries, key_value.literal or _DYNAMIC_KEY, value + ) elif value.entries is not None: - entries = self._overlay_mapping_entries(entries, dict(value.entries)) + literal_entries = self._overlay_mapping_entries( + literal_entries, dict(value.entries) + ) else: - entries = self._overlay_mapping_entries( - entries, + literal_entries = self._overlay_mapping_entries( + literal_entries, {_DYNAMIC_KEY: _conservative_value(value)}, ) return _AbstractValue( - entries=tuple(sorted(entries.items())), + entries=tuple(sorted(literal_entries.items())), identity=frozenset({id(node)}), truth=self._static_truth(node), ) @@ -990,35 +1005,79 @@ def _join_binding_snapshots(self, *snapshots: _BindingSnapshot) -> _BindingSnaps self._join_annotation_bindings(*(snapshot[2] for snapshot in snapshots)), ) + def _join_optional_bindings( + self, snapshots: Iterable[_BindingSnapshot] + ) -> _BindingSnapshot | None: + paths = tuple(snapshots) + return self._join_binding_snapshots(*paths) if paths else None + + def _apply_flow_result(self, result: _FlowResult) -> None: + self._flow_abrupts[-1].extend(result.abrupt) + self._path_reachable = result.fallthrough is not None + if result.fallthrough is not None: + self._restore_bindings(result.fallthrough) + def _visit_binding_branch( self, statements: Iterable[ast.stmt], initial: _BindingSnapshot, - ) -> _BindingSnapshot: + ) -> _FlowResult: + previous_reachable = self._path_reachable + previous_cache = self._expression_cache self._restore_bindings(initial) - for statement in statements: - terminates = self._statement_always_terminates(statement) - self.visit(statement) - if terminates: - break - return self._binding_snapshot() + self._path_reachable = True + self._flow_abrupts.append([]) + self._expression_cache = {} + try: + for statement in statements: + self.visit(statement) + if not self._path_reachable: + break + return _FlowResult( + self._binding_snapshot() if self._path_reachable else None, + tuple(self._flow_abrupts[-1]), + ) + finally: + self._flow_abrupts.pop() + self._expression_cache = previous_cache + self._path_reachable = previous_reachable def _visit_binding_paths( self, statements: Iterable[ast.stmt], initial: _BindingSnapshot, - ) -> tuple[_BindingSnapshot, _BindingSnapshot]: + ) -> tuple[_FlowResult, _BindingSnapshot]: """Return normal completion and every feasible exception prefix.""" + previous_reachable = self._path_reachable + previous_cache = self._expression_cache self._restore_bindings(initial) + self._path_reachable = True + self._flow_abrupts.append([]) + self._expression_cache = {} prefixes = initial - for statement in statements: - terminates = self._statement_always_terminates(statement) - self.visit(statement) - prefixes = self._join_binding_snapshots(prefixes, self._binding_snapshot()) - if terminates: - break - return self._binding_snapshot(), prefixes + try: + for statement in statements: + self.visit(statement) + current_abrupt = self._flow_abrupts[-1] + observed = [snapshot for _, snapshot in current_abrupt] + if self._path_reachable: + observed.append(self._binding_snapshot()) + if observed: + prefixes = self._join_binding_snapshots(prefixes, *observed) + if not self._path_reachable: + break + return ( + _FlowResult( + self._binding_snapshot() if self._path_reachable else None, + tuple(self._flow_abrupts[-1]), + ), + prefixes, + ) + finally: + self._flow_abrupts.pop() + self._expression_cache = previous_cache + self._path_reachable = previous_reachable def _bind_target_value(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Name): @@ -1168,111 +1227,33 @@ def visit_AugAssign(self, node: ast.AugAssign) -> None: self._states[-1][node.target.id] = self._name_value(node.target.id) self._functions[-1][node.target.id] = frozenset() - def _visit_branch( - self, - statements: Iterable[ast.stmt], - initial: dict[str, _AbstractValue], - ) -> dict[str, _AbstractValue]: - self._states[-1] = dict(initial) - for statement in statements: - terminates = self._statement_always_terminates(statement) - self.visit(statement) - if terminates: - break - return dict(self._states[-1]) - - def _statements_always_terminate(self, statements: Iterable[ast.stmt]) -> bool: - return any(self._statement_always_terminates(statement) for statement in statements) - - def _statement_always_terminates(self, statement: ast.stmt) -> bool: - if isinstance(statement, (ast.Return, ast.Raise)): - return True - if isinstance(statement, ast.If): - truth = self._static_truth(statement.test) - if truth is not None: - branch = statement.body if truth else statement.orelse - return self._statements_always_terminate(branch) - return ( - bool(statement.orelse) - and self._statements_always_terminate(statement.body) - and self._statements_always_terminate(statement.orelse) - ) - if isinstance(statement, (ast.With, ast.AsyncWith)): - return self._statements_always_terminate(statement.body) - if isinstance(statement, (ast.Try, ast.TryStar)): - if statement.finalbody and self._statements_always_terminate(statement.finalbody): - return True - normal_terminates = self._statements_always_terminate(statement.body) or ( - bool(statement.orelse) and self._statements_always_terminate(statement.orelse) - ) - return normal_terminates and all( - self._statements_always_terminate(handler.body) for handler in statement.handlers - ) - if isinstance(statement, ast.Match): - return self._match_is_exhaustive(statement) and all( - self._statements_always_terminate(case.body) for case in statement.cases - ) - return False - - def _match_is_exhaustive(self, node: ast.Match) -> bool: - return any( - isinstance(case.pattern, ast.MatchAs) - and case.pattern.pattern is None - and (case.guard is None or self._static_truth(case.guard) is True) - for case in node.cases - ) - def visit_If(self, node: ast.If) -> None: self.visit(node.test) - initial = dict(self._states[-1]) + initial = self._binding_snapshot() truth = self._static_truth(node.test) if truth is not None: live_branch = node.body if truth else node.orelse dead_branch = node.orelse if truth else node.body if self._uses_type_checking(node.test): self._register_type_only_imports(dead_branch) - self._states[-1] = self._visit_branch(live_branch, initial) + initial = self._binding_snapshot() + self._apply_flow_result(self._visit_binding_branch(live_branch, initial)) return - initial_functions = dict(self._functions[-1]) - initial_annotations = dict(self._annotations[-1]) - body_terminates = self._statements_always_terminate(node.body) - else_terminates = bool(node.orelse) and self._statements_always_terminate(node.orelse) - body_state = self._visit_branch(node.body, initial) - body_functions = dict(self._functions[-1]) - body_annotations = dict(self._annotations[-1]) - self._functions[-1] = dict(initial_functions) - self._annotations[-1] = dict(initial_annotations) - else_state = self._visit_branch(node.orelse, initial) if node.orelse else initial - else_functions = dict(self._functions[-1]) - else_annotations = dict(self._annotations[-1]) - state_paths = [ - state - for state, terminates in ( - (body_state, body_terminates), - (else_state, else_terminates), - ) - if not terminates - ] - function_paths = [ - state - for state, terminates in ( - (body_functions, body_terminates), - (else_functions, else_terminates), - ) - if not terminates - ] - annotation_paths = [ - state - for state, terminates in ( - (body_annotations, body_terminates), - (else_annotations, else_terminates), + body_result = self._visit_binding_branch(node.body, initial) + else_result = ( + self._visit_binding_branch(node.orelse, initial) + if node.orelse + else _FlowResult(initial) + ) + self._apply_flow_result( + _FlowResult( + self._join_optional_bindings( + state + for state in (body_result.fallthrough, else_result.fallthrough) + if state is not None + ), + (*body_result.abrupt, *else_result.abrupt), ) - if not terminates - ] - self._states[-1] = self._join_states(*(state_paths or [initial])) - self._functions[-1] = self._join_function_bindings(*(function_paths or [initial_functions])) - self._annotations[-1] = self._join_annotation_bindings( - *(annotation_paths or [initial_annotations]) ) @staticmethod @@ -1305,24 +1286,46 @@ def visit_For(self, node: ast.For) -> None: entry = self._binding_snapshot() self._restore_bindings(entry) if not self._bind_iteration_target(node.target, iterable): - completed = self._visit_binding_branch(node.orelse, entry) if node.orelse else entry - self._restore_bindings(completed) + completed = ( + self._visit_binding_branch(node.orelse, entry) + if node.orelse + else _FlowResult(entry) + ) + self._apply_flow_result(completed) return header = entry - body_terminates = self._statements_always_terminate(node.body) while True: self._restore_bindings(header) self._bind_iteration_target(node.target, iterable) - body_state = self._visit_binding_branch(node.body, self._binding_snapshot()) - paths = ([] if not zero_iterations_possible else [entry]) + ( - [] if body_terminates else [body_state] - ) - joined = self._join_binding_snapshots(*(paths or [body_state])) + body_result = self._visit_binding_branch(node.body, self._binding_snapshot()) + back_edges = [snapshot for kind, snapshot in body_result.abrupt if kind == "continue"] + if body_result.fallthrough is not None: + back_edges.append(body_result.fallthrough) + joined = self._join_binding_snapshots(entry, *back_edges) if joined == header: break header = joined - else_state = self._visit_binding_branch(node.orelse, header) if node.orelse else header - self._restore_bindings(else_state) + + normal_paths = ([] if not zero_iterations_possible else [entry]) + back_edges + normal_entry = self._join_optional_bindings(normal_paths) + normal_result = ( + self._visit_binding_branch(node.orelse, normal_entry) + if node.orelse and normal_entry is not None + else _FlowResult(normal_entry) + ) + break_paths = [snapshot for kind, snapshot in body_result.abrupt if kind == "break"] + outer_abrupt = tuple( + path for path in body_result.abrupt if path[0] not in {"break", "continue"} + ) + post_paths = [*break_paths] + if normal_result.fallthrough is not None: + post_paths.append(normal_result.fallthrough) + self._apply_flow_result( + _FlowResult( + self._join_optional_bindings(post_paths), + (*outer_abrupt, *normal_result.abrupt), + ) + ) def visit_AsyncFor(self, node: ast.AsyncFor) -> None: self.visit_For(node) @@ -1336,42 +1339,57 @@ def visit_While(self, node: ast.While) -> None: completed = ( self._visit_binding_branch(node.orelse, tested_state) if node.orelse - else tested_state + else _FlowResult(tested_state) ) - self._restore_bindings(completed) + self._apply_flow_result(completed) return header = entry - body_terminates = self._statements_always_terminate(node.body) while True: self._restore_bindings(header) self.visit(node.test) tested_state = self._binding_snapshot() - body_state = self._visit_binding_branch(node.body, tested_state) - paths = ([] if truth is True else [entry]) + ([] if body_terminates else [body_state]) - joined = self._join_binding_snapshots(*(paths or [body_state])) + body_result = self._visit_binding_branch(node.body, tested_state) + back_edges = [snapshot for kind, snapshot in body_result.abrupt if kind == "continue"] + if body_result.fallthrough is not None: + back_edges.append(body_result.fallthrough) + joined = self._join_binding_snapshots(entry, *back_edges) if joined == header: break header = joined - self._restore_bindings(header) - self.visit(node.test) - tested_state = self._binding_snapshot() - else_state = ( - self._visit_binding_branch(node.orelse, tested_state) - if node.orelse and truth is not True - else tested_state + + normal_entry: _BindingSnapshot | None = None + if truth is not True: + self._restore_bindings(header) + self.visit(node.test) + normal_entry = self._binding_snapshot() + normal_result = ( + self._visit_binding_branch(node.orelse, normal_entry) + if node.orelse and normal_entry is not None + else _FlowResult(normal_entry) + ) + break_paths = [snapshot for kind, snapshot in body_result.abrupt if kind == "break"] + outer_abrupt = tuple( + path for path in body_result.abrupt if path[0] not in {"break", "continue"} + ) + post_paths = [*break_paths] + if normal_result.fallthrough is not None: + post_paths.append(normal_result.fallthrough) + self._apply_flow_result( + _FlowResult( + self._join_optional_bindings(post_paths), + (*outer_abrupt, *normal_result.abrupt), + ) ) - self._restore_bindings(else_state) def _visit_try(self, node: ast.Try | ast.TryStar) -> None: entry = self._binding_snapshot() - body_state, exception_states = self._visit_binding_paths(node.body, entry) - normal_state = ( - self._visit_binding_branch(node.orelse, body_state) if node.orelse else body_state + body_result, exception_states = self._visit_binding_paths(node.body, entry) + normal_result = ( + self._visit_binding_branch(node.orelse, body_result.fallthrough) + if node.orelse and body_result.fallthrough is not None + else _FlowResult(body_result.fallthrough) ) - normal_terminates = self._statements_always_terminate(node.body) or ( - bool(node.orelse) and self._statements_always_terminate(node.orelse) - ) - completed = [] if normal_terminates else [normal_state] + handler_results: list[_FlowResult] = [] for handler in node.handlers: self._restore_bindings(exception_states) if handler.type is not None: @@ -1380,20 +1398,47 @@ def _visit_try(self, node: ast.Try | ast.TryStar) -> None: self._states[-1][handler.name] = _UNKNOWN_VALUE self._functions[-1][handler.name] = frozenset() self._annotations[-1][handler.name] = _UNKNOWN_VALUE - handler_state = self._visit_binding_branch(handler.body, self._binding_snapshot()) - if not self._statements_always_terminate(handler.body): - completed.append(handler_state) - if node.finalbody: - incoming = self._join_binding_snapshots(exception_states, *completed) - all_final_state = self._visit_binding_branch(node.finalbody, incoming) - final_state = ( - self._visit_binding_branch(node.finalbody, self._join_binding_snapshots(*completed)) - if completed - else all_final_state + handler_results.append( + self._visit_binding_branch(handler.body, self._binding_snapshot()) ) - self._restore_bindings(final_state) - else: - self._restore_bindings(self._join_binding_snapshots(*(completed or [entry]))) + + fallthrough_paths = [ + state + for state in ( + normal_result.fallthrough, + *(result.fallthrough for result in handler_results), + ) + if state is not None + ] + abrupt = [ + *body_result.abrupt, + *normal_result.abrupt, + *(path for result in handler_results for path in result.abrupt), + ("raise", exception_states), + ] + if not node.finalbody: + self._apply_flow_result( + _FlowResult(self._join_optional_bindings(fallthrough_paths), tuple(abrupt)) + ) + return + + final_fallthrough: _BindingSnapshot | None = None + final_abrupt: list[_AbruptPath] = [] + if fallthrough_paths: + completed_final = self._visit_binding_branch( + node.finalbody, self._join_binding_snapshots(*fallthrough_paths) + ) + final_fallthrough = completed_final.fallthrough + final_abrupt.extend(completed_final.abrupt) + for kind in {path_kind for path_kind, _ in abrupt}: + incoming = self._join_binding_snapshots( + *(snapshot for path_kind, snapshot in abrupt if path_kind == kind) + ) + abrupt_final = self._visit_binding_branch(node.finalbody, incoming) + if abrupt_final.fallthrough is not None: + final_abrupt.append((kind, abrupt_final.fallthrough)) + final_abrupt.extend(abrupt_final.abrupt) + self._apply_flow_result(_FlowResult(final_fallthrough, tuple(final_abrupt))) def visit_Try(self, node: ast.Try) -> None: self._visit_try(node) @@ -1525,7 +1570,7 @@ def visit_Match(self, node: ast.Match) -> None: self.visit(node.subject) subject = self._expression_value(node.subject) initial = self._binding_snapshot() - branches: list[_BindingSnapshot] = [] + branches: list[_FlowResult] = [] unmatched = True for case in node.cases: self._restore_bindings(initial) @@ -1535,9 +1580,7 @@ def visit_Match(self, node: ast.Match) -> None: self.visit(case.guard) if self._static_truth(case.guard) is False: continue - branch = self._visit_binding_branch(case.body, self._binding_snapshot()) - if not self._statements_always_terminate(case.body): - branches.append(branch) + branches.append(self._visit_binding_branch(case.body, self._binding_snapshot())) if ( isinstance(case.pattern, ast.MatchAs) and case.pattern.pattern is None @@ -1546,8 +1589,15 @@ def visit_Match(self, node: ast.Match) -> None: unmatched = False break if unmatched: - branches.append(initial) - self._restore_bindings(self._join_binding_snapshots(*(branches or [initial]))) + branches.append(_FlowResult(initial)) + self._apply_flow_result( + _FlowResult( + self._join_optional_bindings( + result.fallthrough for result in branches if result.fallthrough is not None + ), + tuple(path for result in branches for path in result.abrupt), + ) + ) def _visit_comprehension( self, @@ -1674,8 +1724,9 @@ def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: if item.optional_vars is not None: self._visit_store_target(item.optional_vars) self._bind_target_value(item.optional_vars, _UNKNOWN_VALUE) - for statement in node.body: - self.visit(statement) + self._bind_function_target(item.optional_vars, frozenset()) + self._bind_annotation_target(item.optional_vars, _UNKNOWN_VALUE) + self._apply_flow_result(self._visit_binding_branch(node.body, self._binding_snapshot())) def visit_With(self, node: ast.With) -> None: self._visit_with(node) @@ -1878,11 +1929,7 @@ def _visit_function_body( previous_cache = self._expression_cache self._expression_cache = {} try: - for statement in node.body: - terminates = self._statement_always_terminates(statement) - self.visit(statement) - if terminates: - break + self._visit_binding_branch(node.body, self._binding_snapshot()) return tuple(self._return_values[-1]) finally: self._expression_cache = previous_cache @@ -1909,11 +1956,28 @@ def _local_call_value( return _join_values(*returned) def visit_Return(self, node: ast.Return) -> None: - if node.value is None: - return - self.visit(node.value) - if self._return_values: + if node.value is not None: + self.visit(node.value) + if node.value is not None and self._return_values: self._return_values[-1].append(self._expression_value(node.value)) + self._flow_abrupts[-1].append(("return", self._binding_snapshot())) + self._path_reachable = False + + def visit_Raise(self, node: ast.Raise) -> None: + if node.exc is not None: + self.visit(node.exc) + if node.cause is not None: + self.visit(node.cause) + self._flow_abrupts[-1].append(("raise", self._binding_snapshot())) + self._path_reachable = False + + def visit_Break(self, node: ast.Break) -> None: + self._flow_abrupts[-1].append(("break", self._binding_snapshot())) + self._path_reachable = False + + def visit_Continue(self, node: ast.Continue) -> None: + self._flow_abrupts[-1].append(("continue", self._binding_snapshot())) + self._path_reachable = False def _visit_scoped(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: for decorator in node.decorator_list: diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index ac9a1fc2b5..ef800f659f 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1299,6 +1299,138 @@ def guaranteed_iteration_shadowing(config): ) +def test_runtime_scan_keeps_for_break_path_separate_from_loop_else( + contract, tmp_path: Path +) -> None: + (tmp_path / "for_break_else.py").write_text( + """ +def reader(value): + return value.min_models + +def scan(config, items): + handler = external + for _ in items: + handler = reader + break + else: + handler = external + return handler(config.consensus) +""", + encoding="utf-8", + ) + field = contract.ConfigField("consensus", "min_models") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_keeps_while_break_path_separate_from_loop_else( + contract, tmp_path: Path +) -> None: + (tmp_path / "while_break_else.py").write_text( + """ +def reader(value): + return value.threshold + +def scan(config, enabled): + handler = external + while enabled: + handler = reader + break + else: + handler = external + return handler(config.consensus) +""", + encoding="utf-8", + ) + field = contract.ConfigField("consensus", "threshold") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_runs_finally_with_terminating_try_else_bindings( + contract, tmp_path: Path +) -> None: + (tmp_path / "try_else_finally.py").write_text( + """ +def reader(value): + return value.diversity_required + +def scan(config, risky): + handler = external + try: + risky() + except LookupError: + handler = external + else: + handler = reader + raise RuntimeError + finally: + handler(config.consensus) +""", + encoding="utf-8", + ) + field = contract.ConfigField("consensus", "diversity_required") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_distinguishes_loop_control_and_nested_break_paths( + contract, tmp_path: Path +) -> None: + (tmp_path / "loop_control_neighbors.py").write_text( + """ +def nested_reader(value): + return value.satisfaction_threshold + +def unreachable_reader(value): + return value.uncertainty_threshold + +def nested_break(config, outer_items, inner_items): + handler = external + for _ in outer_items: + for _ in inner_items: + handler = nested_reader + break + else: + handler = external + break + else: + handler = external + handler(config.evaluation) + +def continue_exhausts_into_else(config, items): + handler = external + for _ in items: + handler = unreachable_reader + continue + else: + handler = external + handler(config.evaluation) + +def return_and_raise_do_not_reach_after_loop(config, items, enabled): + handler = external + for _ in items: + handler = unreachable_reader + return None + else: + handler = external + handler(config.evaluation) + + while enabled: + handler = unreachable_reader + raise RuntimeError + else: + handler = external + handler(config.evaluation) +""", + encoding="utf-8", + ) + nested = contract.ConfigField("evaluation", "satisfaction_threshold") + unreachable = contract.ConfigField("evaluation", "uncertainty_threshold") + + assert contract.runtime_reads(tmp_path, frozenset({nested, unreachable})) == frozenset({nested}) + + def test_runtime_scan_stops_after_unconditional_exit_but_keeps_dynamic_fallthrough( contract, tmp_path: Path ) -> None: From 0e8f01852c1fcda72f8a1f6d5b920f219f7f54a8 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 15:24:17 +0900 Subject: [PATCH 12/70] fix(config): distinguish abrupt exception paths --- scripts/check-config-reference-contract.py | 237 +++++++++++++----- .../test_check_config_reference_contract.py | 179 +++++++++++++ 2 files changed, 350 insertions(+), 66 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 5c3ec3ac32..4d8ff38ba6 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -262,7 +262,16 @@ def _key_token(node: ast.AST) -> str: dict[str, _FunctionSet], dict[str, _AbstractValue], ] -_AbruptPath = tuple[str, _BindingSnapshot] + + +@dataclass(frozen=True) +class _AbruptPath: + """One non-fallthrough control path and its path-local payload.""" + + kind: str + bindings: _BindingSnapshot + return_value: _AbstractValue | None = None + exception_type: str | None = None @dataclass(frozen=True) @@ -283,10 +292,10 @@ def __init__(self, fields: frozenset[ConfigField]) -> None: self._annotations: list[dict[str, _AbstractValue]] = [{}] self._functions: list[dict[str, _FunctionSet]] = [{}] self._active_calls: set[int] = set() - self._return_values: list[list[_AbstractValue]] = [] self._expression_cache: dict[int, _AbstractValue] = {} self._flow_abrupts: list[list[_AbruptPath]] = [[]] self._path_reachable = True + self._exception_capture_depth = 0 self.reads: set[ConfigField] = set() def _name_value(self, name: str) -> _AbstractValue: @@ -921,11 +930,27 @@ def _record(self, section: str, name: str) -> None: if field in self._fields: self.reads.add(field) + def _record_possible_exception(self, before: _BindingSnapshot) -> None: + """Preserve a feasible exception edge without ending normal evaluation.""" + + if self._exception_capture_depth == 0: + return + self._append_abrupt( + self._flow_abrupts[-1], + _AbruptPath( + "raise", + self._join_binding_snapshots(before, self._binding_snapshot()), + ), + ) + def visit_Attribute(self, node: ast.Attribute) -> None: + before = self._binding_snapshot() if isinstance(node.ctx, ast.Load): for section in self._expression_value(node.value).origins & TRACKED_SECTIONS: self._record(section, node.attr) self.generic_visit(node) + if isinstance(node.ctx, ast.Load): + self._record_possible_exception(before) def visit_IfExp(self, node: ast.IfExp) -> None: self.visit(node.test) @@ -941,6 +966,7 @@ def visit_BoolOp(self, node: ast.BoolOp) -> None: self.visit(value) def visit_Call(self, node: ast.Call) -> None: + before = self._binding_snapshot() # Evaluate once even when the call is a standalone mutating # ``setdefault`` expression. self._expression_value(node) @@ -954,6 +980,13 @@ def visit_Call(self, node: ast.Call) -> None: for section in self._expression_value(node.args[0]).origins & TRACKED_SECTIONS: self._record(section, node.args[1].value) self.generic_visit(node) + self._record_possible_exception(before) + + def visit_Subscript(self, node: ast.Subscript) -> None: + before = self._binding_snapshot() + self.generic_visit(node) + if isinstance(node.ctx, ast.Load): + self._record_possible_exception(before) @staticmethod def _join_states( @@ -1011,8 +1044,28 @@ def _join_optional_bindings( paths = tuple(snapshots) return self._join_binding_snapshots(*paths) if paths else None + def _append_abrupt(self, target: list[_AbruptPath], path: _AbruptPath) -> None: + for index, existing in enumerate(target): + if ( + existing.kind == path.kind + and existing.return_value == path.return_value + and existing.exception_type == path.exception_type + ): + target[index] = _AbruptPath( + path.kind, + self._join_binding_snapshots(existing.bindings, path.bindings), + path.return_value, + path.exception_type, + ) + return + target.append(path) + + def _extend_abrupts(self, target: list[_AbruptPath], paths: Iterable[_AbruptPath]) -> None: + for path in paths: + self._append_abrupt(target, path) + def _apply_flow_result(self, result: _FlowResult) -> None: - self._flow_abrupts[-1].extend(result.abrupt) + self._extend_abrupts(self._flow_abrupts[-1], result.abrupt) self._path_reachable = result.fallthrough is not None if result.fallthrough is not None: self._restore_bindings(result.fallthrough) @@ -1042,43 +1095,6 @@ def _visit_binding_branch( self._expression_cache = previous_cache self._path_reachable = previous_reachable - def _visit_binding_paths( - self, - statements: Iterable[ast.stmt], - initial: _BindingSnapshot, - ) -> tuple[_FlowResult, _BindingSnapshot]: - """Return normal completion and every feasible exception prefix.""" - - previous_reachable = self._path_reachable - previous_cache = self._expression_cache - self._restore_bindings(initial) - self._path_reachable = True - self._flow_abrupts.append([]) - self._expression_cache = {} - prefixes = initial - try: - for statement in statements: - self.visit(statement) - current_abrupt = self._flow_abrupts[-1] - observed = [snapshot for _, snapshot in current_abrupt] - if self._path_reachable: - observed.append(self._binding_snapshot()) - if observed: - prefixes = self._join_binding_snapshots(prefixes, *observed) - if not self._path_reachable: - break - return ( - _FlowResult( - self._binding_snapshot() if self._path_reachable else None, - tuple(self._flow_abrupts[-1]), - ), - prefixes, - ) - finally: - self._flow_abrupts.pop() - self._expression_cache = previous_cache - self._path_reachable = previous_reachable - def _bind_target_value(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Name): self._states[-1][target.id] = value @@ -1298,7 +1314,7 @@ def visit_For(self, node: ast.For) -> None: self._restore_bindings(header) self._bind_iteration_target(node.target, iterable) body_result = self._visit_binding_branch(node.body, self._binding_snapshot()) - back_edges = [snapshot for kind, snapshot in body_result.abrupt if kind == "continue"] + back_edges = [path.bindings for path in body_result.abrupt if path.kind == "continue"] if body_result.fallthrough is not None: back_edges.append(body_result.fallthrough) joined = self._join_binding_snapshots(entry, *back_edges) @@ -1313,9 +1329,9 @@ def visit_For(self, node: ast.For) -> None: if node.orelse and normal_entry is not None else _FlowResult(normal_entry) ) - break_paths = [snapshot for kind, snapshot in body_result.abrupt if kind == "break"] + break_paths = [path.bindings for path in body_result.abrupt if path.kind == "break"] outer_abrupt = tuple( - path for path in body_result.abrupt if path[0] not in {"break", "continue"} + path for path in body_result.abrupt if path.kind not in {"break", "continue"} ) post_paths = [*break_paths] if normal_result.fallthrough is not None: @@ -1349,7 +1365,7 @@ def visit_While(self, node: ast.While) -> None: self.visit(node.test) tested_state = self._binding_snapshot() body_result = self._visit_binding_branch(node.body, tested_state) - back_edges = [snapshot for kind, snapshot in body_result.abrupt if kind == "continue"] + back_edges = [path.bindings for path in body_result.abrupt if path.kind == "continue"] if body_result.fallthrough is not None: back_edges.append(body_result.fallthrough) joined = self._join_binding_snapshots(entry, *back_edges) @@ -1367,9 +1383,9 @@ def visit_While(self, node: ast.While) -> None: if node.orelse and normal_entry is not None else _FlowResult(normal_entry) ) - break_paths = [snapshot for kind, snapshot in body_result.abrupt if kind == "break"] + break_paths = [path.bindings for path in body_result.abrupt if path.kind == "break"] outer_abrupt = tuple( - path for path in body_result.abrupt if path[0] not in {"break", "continue"} + path for path in body_result.abrupt if path.kind not in {"break", "continue"} ) post_paths = [*break_paths] if normal_result.fallthrough is not None: @@ -1381,9 +1397,53 @@ def visit_While(self, node: ast.While) -> None: ) ) + @classmethod + def _exception_name(cls, node: ast.AST | None) -> str | None: + if isinstance(node, ast.Call): + return cls._exception_name(node.func) + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return cls._dotted_name(node) + return None + + @classmethod + def _handler_exception_names(cls, node: ast.AST | None) -> frozenset[str]: + if node is None: + return frozenset({"BaseException"}) + if isinstance(node, ast.Tuple): + return frozenset().union(*(cls._handler_exception_names(item) for item in node.elts)) + name = cls._exception_name(node) + return frozenset({name.rsplit(".", 1)[-1]}) if name is not None else frozenset() + + @classmethod + def _handler_may_catch(cls, path: _AbruptPath, handler: ast.ExceptHandler) -> bool: + if path.kind != "raise": + return False + names = cls._handler_exception_names(handler.type) + if "BaseException" in names: + return True + if path.exception_type is None: + return True + raised = path.exception_type.rsplit(".", 1)[-1] + if raised in names: + return True + return "Exception" in names and raised not in { + "SystemExit", + "KeyboardInterrupt", + "GeneratorExit", + } + def _visit_try(self, node: ast.Try | ast.TryStar) -> None: + self._exception_capture_depth += 1 + try: + self._visit_try_paths(node) + finally: + self._exception_capture_depth -= 1 + + def _visit_try_paths(self, node: ast.Try | ast.TryStar) -> None: entry = self._binding_snapshot() - body_result, exception_states = self._visit_binding_paths(node.body, entry) + body_result = self._visit_binding_branch(node.body, entry) normal_result = ( self._visit_binding_branch(node.orelse, body_result.fallthrough) if node.orelse and body_result.fallthrough is not None @@ -1391,7 +1451,12 @@ def _visit_try(self, node: ast.Try | ast.TryStar) -> None: ) handler_results: list[_FlowResult] = [] for handler in node.handlers: - self._restore_bindings(exception_states) + caught = [path for path in body_result.abrupt if self._handler_may_catch(path, handler)] + if not caught: + continue + self._restore_bindings( + self._join_binding_snapshots(*(path.bindings for path in caught)) + ) if handler.type is not None: self.visit(handler.type) if handler.name is not None: @@ -1414,7 +1479,6 @@ def _visit_try(self, node: ast.Try | ast.TryStar) -> None: *body_result.abrupt, *normal_result.abrupt, *(path for result in handler_results for path in result.abrupt), - ("raise", exception_states), ] if not node.finalbody: self._apply_flow_result( @@ -1430,13 +1494,17 @@ def _visit_try(self, node: ast.Try | ast.TryStar) -> None: ) final_fallthrough = completed_final.fallthrough final_abrupt.extend(completed_final.abrupt) - for kind in {path_kind for path_kind, _ in abrupt}: - incoming = self._join_binding_snapshots( - *(snapshot for path_kind, snapshot in abrupt if path_kind == kind) - ) - abrupt_final = self._visit_binding_branch(node.finalbody, incoming) + for path in abrupt: + abrupt_final = self._visit_binding_branch(node.finalbody, path.bindings) if abrupt_final.fallthrough is not None: - final_abrupt.append((kind, abrupt_final.fallthrough)) + final_abrupt.append( + _AbruptPath( + path.kind, + abrupt_final.fallthrough, + path.return_value, + path.exception_type, + ) + ) final_abrupt.extend(abrupt_final.abrupt) self._apply_flow_result(_FlowResult(final_fallthrough, tuple(final_abrupt))) @@ -1925,15 +1993,17 @@ def _visit_function_body( function_bindings[node.args.kwarg.arg] = frozenset() function_bindings.update(self._declared_functions(node.body)) self._functions.append(function_bindings) - self._return_values.append([]) previous_cache = self._expression_cache self._expression_cache = {} try: - self._visit_binding_branch(node.body, self._binding_snapshot()) - return tuple(self._return_values[-1]) + result = self._visit_binding_branch(node.body, self._binding_snapshot()) + return tuple( + path.return_value or _UNKNOWN_VALUE + for path in result.abrupt + if path.kind == "return" + ) finally: self._expression_cache = previous_cache - self._return_values.pop() self._functions.pop() self._annotations.pop() self._states.pop() @@ -1958,25 +2028,60 @@ def _local_call_value( def visit_Return(self, node: ast.Return) -> None: if node.value is not None: self.visit(node.value) - if node.value is not None and self._return_values: - self._return_values[-1].append(self._expression_value(node.value)) - self._flow_abrupts[-1].append(("return", self._binding_snapshot())) + value = self._expression_value(node.value) if node.value is not None else _UNKNOWN_VALUE + self._append_abrupt( + self._flow_abrupts[-1], + _AbruptPath("return", self._binding_snapshot(), return_value=value), + ) self._path_reachable = False def visit_Raise(self, node: ast.Raise) -> None: if node.exc is not None: - self.visit(node.exc) + exception_name = self._exception_name(node.exc) + leaf = exception_name.rsplit(".", 1)[-1] if exception_name else "" + if isinstance(node.exc, ast.Call) and ( + leaf.endswith(("Error", "Exception")) + or leaf + in { + "BaseException", + "GeneratorExit", + "KeyboardInterrupt", + "StopAsyncIteration", + "StopIteration", + "SystemExit", + } + ): + self.visit(node.exc.func) + for argument in node.exc.args: + self.visit(argument) + for keyword in node.exc.keywords: + self.visit(keyword.value) + else: + self.visit(node.exc) if node.cause is not None: self.visit(node.cause) - self._flow_abrupts[-1].append(("raise", self._binding_snapshot())) + self._append_abrupt( + self._flow_abrupts[-1], + _AbruptPath( + "raise", + self._binding_snapshot(), + exception_type=self._exception_name(node.exc), + ), + ) self._path_reachable = False def visit_Break(self, node: ast.Break) -> None: - self._flow_abrupts[-1].append(("break", self._binding_snapshot())) + self._append_abrupt( + self._flow_abrupts[-1], + _AbruptPath("break", self._binding_snapshot()), + ) self._path_reachable = False def visit_Continue(self, node: ast.Continue) -> None: - self._flow_abrupts[-1].append(("continue", self._binding_snapshot())) + self._append_abrupt( + self._flow_abrupts[-1], + _AbruptPath("continue", self._binding_snapshot()), + ) self._path_reachable = False def _visit_scoped(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index ef800f659f..323a23e62b 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1431,6 +1431,185 @@ def return_and_raise_do_not_reach_after_loop(config, items, enabled): assert contract.runtime_reads(tmp_path, frozenset({nested, unreachable})) == frozenset({nested}) +def test_runtime_scan_does_not_route_return_or_continue_into_except_handlers( + contract, tmp_path: Path +) -> None: + (tmp_path / "non_exception_abrupts.py").write_text( + """ +def unreachable_reader(value): + return value.min_models + +def bare_return(config): + handler = unreachable_reader + try: + return + except Exception: + pass + handler(config.consensus) + +def for_continue(config, items): + handler = external + for _ in items: + try: + handler = unreachable_reader + continue + except Exception: + break + else: + handler = external + else: + handler = external + handler(config.consensus) + +def while_continue(config, enabled): + handler = external + while enabled: + try: + handler = unreachable_reader + continue + except Exception: + break + else: + handler = external + else: + handler = external + handler(config.consensus) +""", + encoding="utf-8", + ) + field = contract.ConfigField("consensus", "min_models") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +def test_runtime_scan_finally_return_overrides_prior_return_provenance( + contract, tmp_path: Path +) -> None: + (tmp_path / "finally_return_authority.py").write_text( + """ +def choose(config, report): + try: + return config.consensus + finally: + return report.consensus + +overridden_read = choose(config, report).min_models + +def choose_from_except(config, report, risky): + try: + risky() + except Exception: + return config.consensus + else: + return config.consensus + finally: + return report.consensus + +overridden_except_read = choose_from_except(config, report, risky).threshold + +def preserved(config): + try: + return config.consensus + finally: + pass + +preserved_read = preserved(config).models + +def nested_override(config, report): + try: + try: + return config.consensus + finally: + return report.consensus + finally: + pass + +nested_overridden_read = nested_override(config, report).diversity_required +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("consensus", "diversity_required"), + contract.ConfigField("consensus", "min_models"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("consensus", "models")} + ) + + +def test_runtime_scan_keeps_raise_break_and_system_exit_abrupt_kinds_distinct( + contract, tmp_path: Path +) -> None: + (tmp_path / "abrupt_kinds.py").write_text( + """ +def raised_reader(value): + return value.devil_model + +def break_reader(value): + return value.judge_model + +def unreachable_reader(value): + return value.advocate_model + +def explicit_raise(config): + handler = external + try: + raise RuntimeError + except Exception: + handler = raised_reader + handler(config.consensus) + +def break_skips_handler_and_else(config, items): + handler = external + for _ in items: + try: + handler = break_reader + break + except Exception: + handler = external + else: + handler = external + handler(config.consensus) + +def system_exit_is_not_exception(config): + handler = external + try: + raise SystemExit + except Exception: + handler = unreachable_reader + handler(config.consensus) + +def constructed_system_exit_is_not_exception(config): + handler = external + try: + raise SystemExit() + except Exception: + handler = unreachable_reader + handler(config.consensus) +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("consensus", "advocate_model"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "judge_model"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "judge_model"), + } + ) + + def test_runtime_scan_stops_after_unconditional_exit_but_keeps_dynamic_fallthrough( contract, tmp_path: Path ) -> None: From 92714e8adeea822fc19e234200117a569591e871 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 16:16:51 +0900 Subject: [PATCH 13/70] fix(config): consume ordered exception handlers --- scripts/check-config-reference-contract.py | 206 +++++++++++-- .../test_check_config_reference_contract.py | 270 ++++++++++++++++++ 2 files changed, 446 insertions(+), 30 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 4d8ff38ba6..256bd2238f 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -19,6 +19,7 @@ from __future__ import annotations import ast +import builtins from collections.abc import Iterable, Mapping from dataclasses import dataclass import json @@ -272,6 +273,8 @@ class _AbruptPath: bindings: _BindingSnapshot return_value: _AbstractValue | None = None exception_type: str | None = None + exception_exclusions: frozenset[str] = frozenset() + exception_upper_bound: str | None = None @dataclass(frozen=True) @@ -296,6 +299,7 @@ def __init__(self, fields: frozenset[ConfigField]) -> None: self._flow_abrupts: list[list[_AbruptPath]] = [[]] self._path_reachable = True self._exception_capture_depth = 0 + self._caught_exception_stack: list[tuple[_AbruptPath, ...]] = [] self.reads: set[ConfigField] = set() def _name_value(self, name: str) -> _AbstractValue: @@ -940,6 +944,7 @@ def _record_possible_exception(self, before: _BindingSnapshot) -> None: _AbruptPath( "raise", self._join_binding_snapshots(before, self._binding_snapshot()), + exception_upper_bound="BaseException", ), ) @@ -1050,12 +1055,16 @@ def _append_abrupt(self, target: list[_AbruptPath], path: _AbruptPath) -> None: existing.kind == path.kind and existing.return_value == path.return_value and existing.exception_type == path.exception_type + and existing.exception_exclusions == path.exception_exclusions + and existing.exception_upper_bound == path.exception_upper_bound ): target[index] = _AbruptPath( path.kind, self._join_binding_snapshots(existing.bindings, path.bindings), path.return_value, path.exception_type, + path.exception_exclusions, + path.exception_upper_bound, ) return target.append(path) @@ -1416,23 +1425,121 @@ def _handler_exception_names(cls, node: ast.AST | None) -> frozenset[str]: name = cls._exception_name(node) return frozenset({name.rsplit(".", 1)[-1]}) if name is not None else frozenset() + @staticmethod + def _builtin_exception_type(name: str) -> type[BaseException] | None: + candidate = getattr(builtins, name.rsplit(".", 1)[-1], None) + if isinstance(candidate, type) and issubclass(candidate, BaseException): + return candidate + return None + @classmethod - def _handler_may_catch(cls, path: _AbruptPath, handler: ast.ExceptHandler) -> bool: - if path.kind != "raise": - return False - names = cls._handler_exception_names(handler.type) - if "BaseException" in names: - return True - if path.exception_type is None: + def _known_exception_subclass(cls, child: str, parent: str) -> bool | None: + child = child.rsplit(".", 1)[-1] + parent = parent.rsplit(".", 1)[-1] + if child == parent: return True - raised = path.exception_type.rsplit(".", 1)[-1] - if raised in names: + if parent == "BaseException": return True - return "Exception" in names and raised not in { - "SystemExit", - "KeyboardInterrupt", - "GeneratorExit", - } + child_type = cls._builtin_exception_type(child) + parent_type = cls._builtin_exception_type(parent) + if child_type is None or parent_type is None: + return None + return issubclass(child_type, parent_type) + + @classmethod + def _exception_domain_intersection(cls, left: str, right: str) -> str | None: + if cls._known_exception_subclass(left, right) is True: + return left + if cls._known_exception_subclass(right, left) is True: + return right + left_type = cls._builtin_exception_type(left) + right_type = cls._builtin_exception_type(right) + if left_type is not None and right_type is not None: + return None + return right + + @classmethod + def _exception_domain_excluded(cls, domain: str, exclusions: frozenset[str]) -> bool: + return any( + cls._known_exception_subclass(domain, excluded) is True for excluded in exclusions + ) + + @classmethod + def _partition_exception_path( + cls, path: _AbruptPath, handler: ast.ExceptHandler + ) -> tuple[tuple[_AbruptPath, ...], tuple[_AbruptPath, ...]]: + """Split one raise into this handler's caught and still-unmatched domains.""" + + names = cls._handler_exception_names(handler.type) + if not names: + return (path,), (path,) + if path.exception_type is not None: + raised = path.exception_type.rsplit(".", 1)[-1] + relations = { + name: cls._known_exception_subclass(raised, name) + for name in names + if name not in path.exception_exclusions + } + if any(relation is True for relation in relations.values()): + return (path,), () + possible = frozenset(name for name, relation in relations.items() if relation is None) + if not possible: + return (), (path,) + remaining = _AbruptPath( + path.kind, + path.bindings, + path.return_value, + path.exception_type, + path.exception_exclusions | possible, + path.exception_upper_bound, + ) + return (path,), (remaining,) + + upper_bound = path.exception_upper_bound or "BaseException" + caught_domains: list[str] = [] + feasible_names: set[str] = set() + fully_consumed = False + for name in names: + domain = cls._exception_domain_intersection(upper_bound, name) + if domain is None or cls._exception_domain_excluded(domain, path.exception_exclusions): + continue + feasible_names.add(name) + if cls._known_exception_subclass(upper_bound, name) is True: + fully_consumed = True + if any( + cls._known_exception_subclass(domain, existing) is True + for existing in caught_domains + ): + continue + caught_domains = [ + existing + for existing in caught_domains + if cls._known_exception_subclass(existing, domain) is not True + ] + caught_domains.append(domain) + if not caught_domains: + return (), (path,) + caught = tuple( + _AbruptPath( + "raise", + path.bindings, + exception_exclusions=path.exception_exclusions, + exception_upper_bound=domain, + ) + for domain in caught_domains + ) + if fully_consumed: + return caught, () + remaining_exclusions = path.exception_exclusions | feasible_names + if cls._exception_domain_excluded(upper_bound, remaining_exclusions): + return caught, () + remaining = _AbruptPath( + "raise", + path.bindings, + exception_exclusions=remaining_exclusions, + exception_upper_bound=upper_bound, + ) + return caught, (remaining,) def _visit_try(self, node: ast.Try | ast.TryStar) -> None: self._exception_capture_depth += 1 @@ -1450,22 +1557,42 @@ def _visit_try_paths(self, node: ast.Try | ast.TryStar) -> None: else _FlowResult(body_result.fallthrough) ) handler_results: list[_FlowResult] = [] + remaining_raises = [path for path in body_result.abrupt if path.kind == "raise"] for handler in node.handlers: - caught = [path for path in body_result.abrupt if self._handler_may_catch(path, handler)] + caught: list[_AbruptPath] = [] + still_unmatched: list[_AbruptPath] = [] + for path in remaining_raises: + caught_paths, remaining_paths = self._partition_exception_path(path, handler) + self._extend_abrupts(caught, caught_paths) + self._extend_abrupts(still_unmatched, remaining_paths) + remaining_raises = still_unmatched if not caught: continue - self._restore_bindings( - self._join_binding_snapshots(*(path.bindings for path in caught)) - ) + handler_entries = [path.bindings for path in caught] + if isinstance(node, ast.TryStar): + handler_entries.extend( + state + for result in handler_results + for state in ( + result.fallthrough, + *(path.bindings for path in result.abrupt), + ) + if state is not None + ) + self._restore_bindings(self._join_binding_snapshots(*handler_entries)) if handler.type is not None: self.visit(handler.type) if handler.name is not None: self._states[-1][handler.name] = _UNKNOWN_VALUE self._functions[-1][handler.name] = frozenset() self._annotations[-1][handler.name] = _UNKNOWN_VALUE - handler_results.append( - self._visit_binding_branch(handler.body, self._binding_snapshot()) - ) + self._caught_exception_stack.append(tuple(caught)) + try: + handler_results.append( + self._visit_binding_branch(handler.body, self._binding_snapshot()) + ) + finally: + self._caught_exception_stack.pop() fallthrough_paths = [ state @@ -1476,7 +1603,8 @@ def _visit_try_paths(self, node: ast.Try | ast.TryStar) -> None: if state is not None ] abrupt = [ - *body_result.abrupt, + *(path for path in body_result.abrupt if path.kind != "raise"), + *remaining_raises, *normal_result.abrupt, *(path for result in handler_results for path in result.abrupt), ] @@ -1503,6 +1631,8 @@ def _visit_try_paths(self, node: ast.Try | ast.TryStar) -> None: abrupt_final.fallthrough, path.return_value, path.exception_type, + path.exception_exclusions, + path.exception_upper_bound, ) ) final_abrupt.extend(abrupt_final.abrupt) @@ -2060,14 +2190,30 @@ def visit_Raise(self, node: ast.Raise) -> None: self.visit(node.exc) if node.cause is not None: self.visit(node.cause) - self._append_abrupt( - self._flow_abrupts[-1], - _AbruptPath( - "raise", - self._binding_snapshot(), - exception_type=self._exception_name(node.exc), - ), - ) + bindings = self._binding_snapshot() + if node.exc is None and self._caught_exception_stack: + for caught in self._caught_exception_stack[-1]: + self._append_abrupt( + self._flow_abrupts[-1], + _AbruptPath( + "raise", + bindings, + exception_type=caught.exception_type, + exception_exclusions=caught.exception_exclusions, + exception_upper_bound=caught.exception_upper_bound, + ), + ) + else: + exception_type = self._exception_name(node.exc) + self._append_abrupt( + self._flow_abrupts[-1], + _AbruptPath( + "raise", + bindings, + exception_type=exception_type, + exception_upper_bound=("BaseException" if exception_type is None else None), + ), + ) self._path_reachable = False def visit_Break(self, node: ast.Break) -> None: diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 323a23e62b..874c66bad0 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1610,6 +1610,276 @@ def constructed_system_exit_is_not_exception(config): ) +def test_runtime_scan_consumes_known_exceptions_in_first_matching_handler( + contract, tmp_path: Path +) -> None: + (tmp_path / "ordered_known_handlers.py").write_text( + """ +def value_reader(value): + return value.devil_model + +def exception_reader(value): + return value.judge_model + +def tuple_reader(value): + return value.models + +def key_reader(value): + return value.threshold + +def outer_reader(value): + return value.diversity_required + +def unreachable_reader(value): + return value.min_models + +class CustomError(Exception): + pass + +def nested_inner_consumes(config): + handler = external + try: + try: + raise ValueError + except Exception: + pass + except BaseException: + handler = unreachable_reader + handler(config.consensus) + +def custom_error_is_consumed_by_base_exception(config): + handler = external + try: + try: + raise CustomError + except BaseException: + pass + except CustomError: + handler = unreachable_reader + handler(config.consensus) + +def custom_error_is_consumed_by_bare_handler(config): + handler = external + try: + try: + raise CustomError + except: + pass + except CustomError: + handler = unreachable_reader + handler(config.consensus) + +def value_error_uses_first_match(config): + handler = external + try: + raise ValueError + except ValueError: + handler = value_reader + except Exception: + handler = unreachable_reader + except: + handler = unreachable_reader + handler(config.consensus) + +def broader_handler_shadows_later_subclass(config): + handler = external + try: + raise ValueError + except Exception: + handler = exception_reader + except ValueError: + handler = unreachable_reader + handler(config.consensus) + +def tuple_uses_static_subclass_match(config): + handler = external + try: + raise KeyError + except (ValueError, LookupError): + handler = tuple_reader + except KeyError: + handler = unreachable_reader + except Exception: + handler = unreachable_reader + handler(config.consensus) + +def bare_reraise_retains_caught_type(config): + handler = external + try: + try: + raise KeyError + except (ValueError, LookupError): + raise + except KeyError: + handler = key_reader + except LookupError: + handler = unreachable_reader + handler(config.consensus) + +def handler_raise_bypasses_later_sibling(config): + handler = external + try: + try: + raise ValueError + except ValueError: + raise TypeError + except TypeError: + handler = unreachable_reader + except TypeError: + handler = outer_reader + handler(config.consensus) +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "diversity_required"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "min_models"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "diversity_required"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + +def test_runtime_scan_partitions_unknown_exceptions_without_shadowed_handlers( + contract, tmp_path: Path +) -> None: + (tmp_path / "ordered_unknown_handlers.py").write_text( + """ +def value_reader(value): + return value.devil_model + +def exception_reader(value): + return value.judge_model + +def bare_reader(value): + return value.advocate_model + +def tuple_reader(value): + return value.models + +def key_reader(value): + return value.threshold + +def lookup_reader(value): + return value.diversity_required + +def unreachable_reader(value): + return value.min_models + +def partitions_all_domains(config, risky): + handler = external + try: + risky() + except ValueError: + handler = value_reader + except Exception: + handler = exception_reader + except: + handler = bare_reader + handler(config.consensus) + +def exception_shadows_value_error(config, risky): + handler = external + try: + risky() + except Exception: + handler = exception_reader + except ValueError: + handler = unreachable_reader + except: + handler = bare_reader + handler(config.consensus) + +def tuple_shadows_key_error(config, risky): + handler = external + try: + risky() + except (LookupError, ValueError): + handler = tuple_reader + except KeyError: + handler = unreachable_reader + except Exception: + handler = exception_reader + handler(config.consensus) + +def unknown_bare_reraise_keeps_caught_domain(config, risky): + handler = external + try: + try: + risky() + except LookupError: + raise + except KeyError: + handler = key_reader + except LookupError: + handler = lookup_reader + except Exception: + handler = exception_reader + handler(config.consensus) +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("consensus", "advocate_model"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "diversity_required"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "min_models"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("consensus", "advocate_model"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "diversity_required"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + +def test_runtime_scan_threads_bindings_through_except_star_subgroups( + contract, tmp_path: Path +) -> None: + (tmp_path / "except_star_subgroups.py").write_text( + """ +def reader(value): + return value.min_models + +def sequential_subgroups(config, risky): + handler = external + try: + risky() + except* ValueError: + handler = reader + except* TypeError: + handler(config.consensus) +""", + encoding="utf-8", + ) + field = contract.ConfigField("consensus", "min_models") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + def test_runtime_scan_stops_after_unconditional_exit_but_keeps_dynamic_fallthrough( contract, tmp_path: Path ) -> None: From a11399402b13ba041ef087538cf30ae1129f68ba Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 16:27:02 +0900 Subject: [PATCH 14/70] fix(config): subtract prior handler coverage --- scripts/check-config-reference-contract.py | 2 +- .../scripts/test_check_config_reference_contract.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 256bd2238f..46c5983f8e 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -1478,7 +1478,7 @@ def _partition_exception_path( relations = { name: cls._known_exception_subclass(raised, name) for name in names - if name not in path.exception_exclusions + if not cls._exception_domain_excluded(name, path.exception_exclusions) } if any(relation is True for relation in relations.values()): return (path,), () diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 874c66bad0..d889013cd5 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1691,6 +1691,18 @@ def broader_handler_shadows_later_subclass(config): handler = unreachable_reader handler(config.consensus) +def broader_handler_subtracts_unknown_exact_domain(config, error_type): + handler = external + try: + raise error_type + except Exception: + handler = exception_reader + except ValueError: + handler = unreachable_reader + except: + handler = outer_reader + handler(config.consensus) + def tuple_uses_static_subclass_match(config): handler = external try: From 45c1016379e97de2b3302c92418eede26d668b5f Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 16:36:07 +0900 Subject: [PATCH 15/70] fix(config): preserve caught exception domains --- scripts/check-config-reference-contract.py | 57 +++++++++++++++---- .../test_check_config_reference_contract.py | 13 +++++ 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 46c5983f8e..d2dbe7168c 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -1475,25 +1475,58 @@ def _partition_exception_path( return (path,), (path,) if path.exception_type is not None: raised = path.exception_type.rsplit(".", 1)[-1] - relations = { - name: cls._known_exception_subclass(raised, name) - for name in names - if not cls._exception_domain_excluded(name, path.exception_exclusions) - } - if any(relation is True for relation in relations.values()): - return (path,), () - possible = frozenset(name for name, relation in relations.items() if relation is None) - if not possible: + upper_bound = path.exception_upper_bound or "BaseException" + exact_caught_domains: list[str] = [] + exact_feasible_names: set[str] = set() + exact_fully_consumed = False + for name in names: + relation = cls._known_exception_subclass(raised, name) + domain = cls._exception_domain_intersection(upper_bound, name) + if ( + relation is False + or domain is None + or cls._exception_domain_excluded(domain, path.exception_exclusions) + ): + continue + exact_feasible_names.add(name) + if relation is True or cls._known_exception_subclass(upper_bound, name) is True: + exact_fully_consumed = True + if any( + cls._known_exception_subclass(domain, existing) is True + for existing in exact_caught_domains + ): + continue + exact_caught_domains = [ + existing + for existing in exact_caught_domains + if cls._known_exception_subclass(existing, domain) is not True + ] + exact_caught_domains.append(domain) + if not exact_caught_domains: return (), (path,) + if exact_fully_consumed: + return (path,), () + caught = tuple( + _AbruptPath( + path.kind, + path.bindings, + path.return_value, + path.exception_type, + path.exception_exclusions, + domain, + ) + for domain in exact_caught_domains + ) + remaining_exclusions = path.exception_exclusions | exact_feasible_names remaining = _AbruptPath( path.kind, path.bindings, path.return_value, path.exception_type, - path.exception_exclusions | possible, - path.exception_upper_bound, + remaining_exclusions, + upper_bound, ) - return (path,), (remaining,) + return caught, (remaining,) upper_bound = path.exception_upper_bound or "BaseException" caught_domains: list[str] = [] diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index d889013cd5..521efe14ae 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1703,6 +1703,19 @@ def broader_handler_subtracts_unknown_exact_domain(config, error_type): handler = outer_reader handler(config.consensus) +def unknown_exact_reraise_keeps_handler_domain(config, error_type): + handler = external + try: + try: + raise error_type + except Exception: + raise + except: + pass + except SystemExit: + handler = unreachable_reader + handler(config.consensus) + def tuple_uses_static_subclass_match(config): handler = external try: From 9a4bc03775ed6bd43d2ed034d9d7dee03ad2252e Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 17:33:22 +0900 Subject: [PATCH 16/70] fix(config): trace invoked callable boundaries --- scripts/check-config-reference-contract.py | 292 +++++++++++++++--- .../test_check_config_reference_contract.py | 98 ++++++ 2 files changed, 347 insertions(+), 43 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index d2dbe7168c..072e2a77b9 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -143,6 +143,8 @@ class _AbstractValue: identity: frozenset[int] = frozenset() literal: str | None = None truth: bool | None = None + classes: frozenset[ast.ClassDef] = frozenset() + modules: frozenset[str] = frozenset() _UNKNOWN_VALUE = _AbstractValue() @@ -182,7 +184,11 @@ def _contained_origins(value: _AbstractValue) -> frozenset[str]: def _conservative_value(value: _AbstractValue) -> _AbstractValue: - return _AbstractValue(origins=_contained_origins(value)) + return _AbstractValue( + origins=_contained_origins(value), + classes=value.classes, + modules=value.modules, + ) def _join_values(*values: _AbstractValue) -> _AbstractValue: @@ -247,6 +253,8 @@ def join_entries( truth=( values[0].truth if all(value.truth == values[0].truth for value in values) else None ), + classes=frozenset().union(*(value.classes for value in values)), + modules=frozenset().union(*(value.modules for value in values)), ) @@ -256,7 +264,7 @@ def _key_token(node: ast.AST) -> str: _DYNAMIC_KEY = "**" _MAPPING_MISSING = "" -_FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef +_FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda _FunctionSet = frozenset[_FunctionNode] _BindingSnapshot = tuple[ dict[str, _AbstractValue], @@ -265,6 +273,92 @@ def _key_token(node: ast.AST) -> str: ] +@dataclass(frozen=True) +class _IndexedModule: + """One parsed source module and its statically declared callables.""" + + name: str + package: str + tree: ast.Module + functions: Mapping[str, _FunctionSet] + classes: Mapping[str, frozenset[ast.ClassDef]] + + +class _SourceIndex: + """Resolve only explicit imports and class receivers inside the scanned tree.""" + + def __init__(self, source_root: Path, trees: Mapping[Path, ast.Module]) -> None: + self._root_name = source_root.name + self._aliases: dict[str, _IndexedModule] = {} + self._owners: dict[int, _IndexedModule] = {} + self._paths: dict[Path, _IndexedModule] = {} + for path, tree in trees.items(): + relative = path.relative_to(source_root).with_suffix("") + parts = list(relative.parts) + is_package = bool(parts and parts[-1] == "__init__") + if is_package: + parts.pop() + name = ".".join(parts) or self._root_name + package = name if is_package else name.rpartition(".")[0] + functions = { + statement.name: frozenset({statement}) + for statement in tree.body + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + classes = { + statement.name: frozenset({statement}) + for statement in tree.body + if isinstance(statement, ast.ClassDef) + } + indexed = _IndexedModule(name, package, tree, functions, classes) + self._paths[path] = indexed + aliases = {name} + if name != self._root_name and not name.startswith(f"{self._root_name}."): + aliases.add(f"{self._root_name}.{name}") + for alias in aliases: + self._aliases[alias] = indexed + for candidate in ast.walk(tree): + if isinstance( + candidate, + (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef), + ): + self._owners[id(candidate)] = indexed + + def module_for_path(self, path: Path) -> _IndexedModule: + return self._paths[path] + + def owner(self, node: ast.AST) -> _IndexedModule | None: + return self._owners.get(id(node)) + + def resolve_module( + self, + module: str | None, + current: _IndexedModule, + level: int = 0, + ) -> _IndexedModule | None: + if level: + package = current.package.split(".") if current.package else [] + keep = max(0, len(package) - level + 1) + parts = [*package[:keep], *(module.split(".") if module else [])] + name = ".".join(parts) + else: + name = module or "" + direct = self._aliases.get(name) + if direct is not None: + return direct + return None + + @staticmethod + def methods(classes: Iterable[ast.ClassDef], name: str) -> _FunctionSet: + return frozenset( + statement + for class_node in classes + for statement in class_node.body + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) + and statement.name == name + ) + + @dataclass(frozen=True) class _AbruptPath: """One non-fallthrough control path and its path-local payload.""" @@ -288,8 +382,15 @@ class _FlowResult: class _RuntimeReadVisitor(ast.NodeVisitor): """Collect config reads with conservative flow- and binding-aware provenance.""" - def __init__(self, fields: frozenset[ConfigField]) -> None: + def __init__( + self, + fields: frozenset[ConfigField], + source_index: _SourceIndex, + module: _IndexedModule, + ) -> None: self._fields = fields + self._source_index = source_index + self._module = module # Explicit unknown values shadow name-based config inference. self._states: list[dict[str, _AbstractValue]] = [{}] self._annotations: list[dict[str, _AbstractValue]] = [{}] @@ -386,17 +487,66 @@ def _local_functions(self, name: str) -> _FunctionSet: def _function_value(self, node: ast.AST) -> _FunctionSet: if isinstance(node, ast.Name): return self._local_functions(node.id) + if isinstance(node, ast.Lambda): + return frozenset({node}) if isinstance(node, ast.IfExp): truth = self._static_truth(node.test) if truth is not None: return self._function_value(node.body if truth else node.orelse) return self._function_value(node.body) | self._function_value(node.orelse) + if isinstance(node, ast.Attribute): + owner = self._expression_value(node.value) + imported = frozenset( + function + for module_name in owner.modules + if (module := self._source_index.resolve_module(module_name, self._module)) + is not None + for function in module.functions.get(node.attr, frozenset()) + ) + return imported | self._source_index.methods(owner.classes, node.attr) return frozenset() + @staticmethod + def _method_is_static(function: _FunctionNode) -> bool: + return isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)) and any( + _callable_name(decorator) == "staticmethod" for decorator in function.decorator_list + ) + + def _call_targets( + self, node: ast.AST + ) -> tuple[tuple[_FunctionNode, _AbstractValue | None], ...]: + if isinstance(node, ast.IfExp): + truth = self._static_truth(node.test) + branches = ( + (node.body if truth else node.orelse,) + if truth is not None + else ( + node.body, + node.orelse, + ) + ) + return tuple(target for branch in branches for target in self._call_targets(branch)) + if isinstance(node, ast.Attribute): + owner = self._expression_value(node.value) + imported = [ + (function, None) + for module_name in owner.modules + if (module := self._source_index.resolve_module(module_name, self._module)) + is not None + for function in module.functions.get(node.attr, frozenset()) + ] + methods = [ + (function, None if self._method_is_static(function) else owner) + for function in self._source_index.methods(owner.classes, node.attr) + ] + return (*imported, *methods) + return tuple((function, None) for function in self._function_value(node)) + def _call_has_config_provenance( self, node: ast.Call, - function: ast.FunctionDef | ast.AsyncFunctionDef, + function: _FunctionNode, + bound_receiver: _AbstractValue | None = None, ) -> bool: values = [ self._expression_value( @@ -406,6 +556,8 @@ def _call_has_config_provenance( ] values.extend(self._expression_value(keyword.value) for keyword in node.keywords) values.extend(self._scoped_function_arguments(function).values()) + if bound_receiver is not None: + values.append(bound_receiver) tracked = TRACKED_SECTIONS | {_CONFIG_ROOT} return any(_contained_origins(value) & tracked for value in values) @@ -762,6 +914,28 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: attribute = self._named_value(owner.attributes, node.attr) if attribute is not None: return attribute + resolved_modules = { + child.name + for module_name in owner.modules + if ( + child := self._source_index.resolve_module( + f"{module_name}.{node.attr}", self._module + ) + ) + is not None + } + resolved_classes = frozenset( + class_node + for module_name in owner.modules + if (module := self._source_index.resolve_module(module_name, self._module)) + is not None + for class_node in module.classes.get(node.attr, frozenset()) + ) + if resolved_modules or resolved_classes: + return _AbstractValue( + modules=frozenset(resolved_modules), + classes=resolved_classes, + ) if node.attr in TRACKED_SECTIONS and _CONFIG_ROOT in owner.origins: return _origin_value(node.attr) if node.attr == "TYPE_CHECKING" and _TYPING_MODULE in owner.origins: @@ -797,7 +971,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: and _CONFIG_ROOT in self._expression_value(node.args[0]).origins ): return _origin_value(node.args[1].value) - if callable_name is not None and callable_name[:1].isupper(): + constructor_classes = self._expression_value(node.func).classes + if constructor_classes or callable_name is not None and callable_name[:1].isupper(): items: list[_AbstractValue] = [] for argument in node.args: if isinstance(argument, ast.Starred): @@ -819,6 +994,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue( items=tuple(items), attributes=tuple(attributes), + classes=constructor_classes, ) if isinstance(node.func, ast.Name) and node.func.id == "dict": entries: dict[str, _AbstractValue] = {} @@ -841,17 +1017,15 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue( entries=tuple(sorted(entries.items())), identity=frozenset({id(node)}) ) - if isinstance(node.func, ast.Name): - functions = self._local_functions(node.func.id) - values = [ - self._local_call_value(node, function) - for function in functions - if self._call_has_config_provenance(node, function) - ] - if values: - value = _join_values(*values) - self._expression_cache[id(node)] = value - return value + values = [ + self._local_call_value(node, function, bound_receiver) + for function, bound_receiver in self._call_targets(node.func) + if self._call_has_config_provenance(node, function, bound_receiver) + ] + if values: + value = _join_values(*values) + self._expression_cache[id(node)] = value + return value return _UNKNOWN_VALUE if isinstance(node, ast.Subscript): owner = self._expression_value(node.value) @@ -1925,22 +2099,50 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: self._annotations[-1][bound] = annotation_value if runtime: self._functions[-1][bound] = frozenset() + imported_name = alias.name if alias.asname else alias.name.partition(".")[0] + module = self._source_index.resolve_module(imported_name, self._module) self._states[-1][bound] = ( - _origin_value(_TYPING_MODULE) if alias.name == "typing" else _UNKNOWN_VALUE + _origin_value(_TYPING_MODULE) + if alias.name == "typing" + else _AbstractValue( + modules=frozenset({module.name}) if module is not None else frozenset() + ) ) def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: + module = self._source_index.resolve_module(node.module, self._module, node.level) for alias in node.names: if alias.name == "*": continue bound = alias.asname or alias.name self._annotations[-1][bound] = self._imported_annotation(node.module, alias.name) if runtime: - self._functions[-1][bound] = frozenset() + functions = ( + module.functions.get(alias.name, frozenset()) + if module is not None + else frozenset() + ) + classes = ( + module.classes.get(alias.name, frozenset()) + if module is not None + else frozenset() + ) + imported_module = self._source_index.resolve_module( + f"{module.name}.{alias.name}" if module is not None else alias.name, + self._module, + ) + self._functions[-1][bound] = functions self._states[-1][bound] = ( _type_checking_value() if node.module == "typing" and alias.name == "TYPE_CHECKING" - else _UNKNOWN_VALUE + else _AbstractValue( + classes=classes, + modules=( + frozenset({imported_module.name}) + if imported_module is not None + else frozenset() + ), + ) ) def visit_Import(self, node: ast.Import) -> None: @@ -1985,9 +2187,7 @@ def _scoped_arguments(self, arguments: ast.arguments) -> dict[str, _AbstractValu scoped[arguments.kwarg.arg] = self._argument_value(arguments.kwarg) return scoped - def _default_argument_values( - self, node: ast.FunctionDef | ast.AsyncFunctionDef - ) -> dict[str, _AbstractValue]: + def _default_argument_values(self, node: _FunctionNode) -> dict[str, _AbstractValue]: previous_cache = self._expression_cache self._expression_cache = {} try: @@ -2014,9 +2214,7 @@ def _default_argument_values( finally: self._expression_cache = previous_cache - def _scoped_function_arguments( - self, node: ast.FunctionDef | ast.AsyncFunctionDef - ) -> dict[str, _AbstractValue]: + def _scoped_function_arguments(self, node: _FunctionNode) -> dict[str, _AbstractValue]: scoped = self._scoped_arguments(node.args) for name, value in self._default_argument_values(node).items(): scoped[name] = _join_values(scoped[name], value) @@ -2035,7 +2233,8 @@ def _declared_functions( def _bound_call_arguments( self, call: ast.Call, - function: ast.FunctionDef | ast.AsyncFunctionDef, + function: _FunctionNode, + bound_receiver: _AbstractValue | None = None, ) -> dict[str, _AbstractValue]: arguments = function.args scoped = self._scoped_arguments(arguments) @@ -2059,6 +2258,9 @@ def bind_positional( bound.append((index, bindings, (*extras, value))) return bound + if bound_receiver is not None: + positional_states = bind_positional(positional_states, bound_receiver) + for argument in call.args: if isinstance(argument, ast.Starred): expanded = self._expression_value(argument.value) @@ -2137,7 +2339,7 @@ def bind_positional( def _visit_function_body( self, - node: ast.FunctionDef | ast.AsyncFunctionDef, + node: _FunctionNode, scoped: dict[str, _AbstractValue], ) -> tuple[_AbstractValue, ...]: self._states.append(scoped) @@ -2154,11 +2356,17 @@ def _visit_function_body( function_bindings[node.args.vararg.arg] = frozenset() if node.args.kwarg is not None: function_bindings[node.args.kwarg.arg] = frozenset() - function_bindings.update(self._declared_functions(node.body)) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + function_bindings.update(self._declared_functions(node.body)) self._functions.append(function_bindings) previous_cache = self._expression_cache + previous_module = self._module + self._module = self._source_index.owner(node) or self._module self._expression_cache = {} try: + if isinstance(node, ast.Lambda): + self.visit(node.body) + return (self._expression_value(node.body),) result = self._visit_binding_branch(node.body, self._binding_snapshot()) return tuple( path.return_value or _UNKNOWN_VALUE @@ -2167,6 +2375,7 @@ def _visit_function_body( ) finally: self._expression_cache = previous_cache + self._module = previous_module self._functions.pop() self._annotations.pop() self._states.pop() @@ -2174,7 +2383,8 @@ def _visit_function_body( def _local_call_value( self, call: ast.Call, - function: ast.FunctionDef | ast.AsyncFunctionDef, + function: _FunctionNode, + bound_receiver: _AbstractValue | None = None, ) -> _AbstractValue: function_id = id(function) if function_id in self._active_calls: @@ -2182,7 +2392,8 @@ def _local_call_value( self._active_calls.add(function_id) try: returned = self._visit_function_body( - function, self._bound_call_arguments(call, function) + function, + self._bound_call_arguments(call, function, bound_receiver), ) finally: self._active_calls.remove(function_id) @@ -2303,7 +2514,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: self._functions.pop() self._annotations.pop() self._states.pop() - self._states[-1][node.name] = _UNKNOWN_VALUE + self._states[-1][node.name] = _AbstractValue(classes=frozenset({node})) self._annotations[-1][node.name] = _UNKNOWN_VALUE self._functions[-1][node.name] = frozenset() @@ -2311,24 +2522,19 @@ def visit_Lambda(self, node: ast.Lambda) -> None: for default in (*node.args.defaults, *node.args.kw_defaults): if default is not None: self.visit(default) - self._states.append(self._scoped_arguments(node.args)) - self._annotations.append({}) - self._functions.append({}) - try: - self.visit(node.body) - finally: - self._functions.pop() - self._annotations.pop() - self._states.pop() def runtime_reads(source_root: Path, fields: frozenset[ConfigField]) -> frozenset[ConfigField]: """Return config fields loaded from their named sections in production Python.""" reads: set[ConfigField] = set() - for path in sorted(source_root.rglob("*.py")): - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - visitor = _RuntimeReadVisitor(fields) + trees = { + path: ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for path in sorted(source_root.rglob("*.py")) + } + source_index = _SourceIndex(source_root, trees) + for path, tree in trees.items(): + visitor = _RuntimeReadVisitor(fields, source_index, source_index.module_for_path(path)) visitor.visit(tree) reads.update(visitor.reads) return frozenset(reads) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 521efe14ae..fa6a2da110 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1170,6 +1170,104 @@ def statically_dead_reader(value): ) +def test_runtime_scan_propagates_method_and_imported_helper_arguments( + contract, tmp_path: Path +) -> None: + (tmp_path / "helpers.py").write_text( + """ +def read_stage(section): + return section.stage2_enabled + +def read_semantic(section): + return section.semantic_model + +def unused_helper(section): + return section.uncertainty_threshold +""", + encoding="utf-8", + ) + (tmp_path / "callers.py").write_text( + """ +from helpers import read_stage +import helpers + +class Reader: + def read(self, section): + return section.stage1_enabled + +class BoundReader: + def read(self, section): + return section.stage3_enabled + +class UnusedReader: + def read(self, section): + return section.satisfaction_threshold + +direct_method = Reader().read(config.evaluation) +reader = BoundReader() +bound_method = reader.read(config.evaluation) +imported_helper = read_stage(config.evaluation) +module_helper = helpers.read_semantic(config.evaluation) +unknown_receiver = report.read(config.evaluation) +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + } + ) + + +def test_runtime_scan_defers_lambda_bodies_until_tracked_invocation( + contract, tmp_path: Path +) -> None: + (tmp_path / "lambda_calls.py").write_text( + """ +lambda section: section.stage1_enabled +unused = lambda section: section.stage2_enabled + +called = lambda section: section.stage3_enabled +called(config.evaluation) + +immediate = (lambda section: section.semantic_model)(config.evaluation) +default_is_eager = lambda value=config.evaluation.uncertainty_threshold: value +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + } + ) + + def test_runtime_scan_joins_callable_bindings_across_compound_statement_paths( contract, tmp_path: Path ) -> None: From c856f60356ef3e1386564d5f4eccdde484d262bb Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 17:41:23 +0900 Subject: [PATCH 17/70] test(config): cover callable resolution adversaries --- .../test_check_config_reference_contract.py | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index fa6a2da110..2307075592 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1183,12 +1183,23 @@ def read_semantic(section): def unused_helper(section): return section.uncertainty_threshold + +def read_threshold(section): + return section.satisfaction_threshold +""", + encoding="utf-8", + ) + (tmp_path / "vendor").mkdir() + (tmp_path / "vendor" / "helpers.py").write_text( + """ +def read_stage(section): + return section.uncertainty_threshold """, encoding="utf-8", ) (tmp_path / "callers.py").write_text( """ -from helpers import read_stage +from helpers import read_stage, read_threshold import helpers class Reader: @@ -1203,12 +1214,20 @@ class UnusedReader: def read(self, section): return section.satisfaction_threshold +class ReboundReader: + def read(self, section): + return section.uncertainty_threshold + direct_method = Reader().read(config.evaluation) reader = BoundReader() bound_method = reader.read(config.evaluation) imported_helper = read_stage(config.evaluation) module_helper = helpers.read_semantic(config.evaluation) unknown_receiver = report.read(config.evaluation) +unrelated_argument = read_threshold(report.evaluation) +rebound = ReboundReader() +rebound = report +rebound_method = rebound.read(config.evaluation) """, encoding="utf-8", ) @@ -1246,12 +1265,24 @@ def test_runtime_scan_defers_lambda_bodies_until_tracked_invocation( immediate = (lambda section: section.semantic_model)(config.evaluation) default_is_eager = lambda value=config.evaluation.uncertainty_threshold: value + +aliased = lambda section: section.satisfaction_threshold +alias = aliased +alias(config.evaluation) + +left = lambda section: section.stage1_enabled +right = lambda section: section.satisfaction_threshold +selected = left +if runtime_condition: + selected = right +selected(config.evaluation) """, encoding="utf-8", ) fields = frozenset( { contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "satisfaction_threshold"), contract.ConfigField("evaluation", "stage1_enabled"), contract.ConfigField("evaluation", "stage2_enabled"), contract.ConfigField("evaluation", "stage3_enabled"), @@ -1262,6 +1293,8 @@ def test_runtime_scan_defers_lambda_bodies_until_tracked_invocation( assert contract.runtime_reads(tmp_path, fields) == frozenset( { contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "stage1_enabled"), contract.ConfigField("evaluation", "stage3_enabled"), contract.ConfigField("evaluation", "uncertainty_threshold"), } From 36f3aaf7b502a0b2c3632bd1ecb388fa0266de3e Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 18:31:25 +0900 Subject: [PATCH 18/70] fix(config): preserve callable object provenance --- scripts/check-config-reference-contract.py | 390 +++++++++++++++--- .../test_check_config_reference_contract.py | 195 +++++++++ 2 files changed, 517 insertions(+), 68 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 072e2a77b9..1c74dbdf40 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -132,6 +132,9 @@ def _callable_name(node: ast.AST) -> str | None: return None +_FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda + + @dataclass(frozen=True) class _AbstractValue: """Possible config provenance plus bounded container/object shape.""" @@ -145,6 +148,15 @@ class _AbstractValue: truth: bool | None = None classes: frozenset[ast.ClassDef] = frozenset() modules: frozenset[str] = frozenset() + callables: tuple[_CallableTarget, ...] = () + + +@dataclass(frozen=True) +class _CallableTarget: + """A tracked callable plus the receiver captured by attribute binding.""" + + function: _FunctionNode + receiver: _AbstractValue | None = None _UNKNOWN_VALUE = _AbstractValue() @@ -188,6 +200,7 @@ def _conservative_value(value: _AbstractValue) -> _AbstractValue: origins=_contained_origins(value), classes=value.classes, modules=value.modules, + callables=value.callables, ) @@ -239,6 +252,12 @@ def join_entries( joined.append((name, _join_values(*possibilities))) return tuple(joined) + callables: list[_CallableTarget] = [] + for value in values: + for target in value.callables: + if target not in callables: + callables.append(target) + return _AbstractValue( origins=origins, items=items, @@ -255,6 +274,7 @@ def join_entries( ), classes=frozenset().union(*(value.classes for value in values)), modules=frozenset().union(*(value.modules for value in values)), + callables=tuple(callables), ) @@ -264,7 +284,6 @@ def _key_token(node: ast.AST) -> str: _DYNAMIC_KEY = "**" _MAPPING_MISSING = "" -_FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda _FunctionSet = frozenset[_FunctionNode] _BindingSnapshot = tuple[ dict[str, _AbstractValue], @@ -280,8 +299,8 @@ class _IndexedModule: name: str package: str tree: ast.Module - functions: Mapping[str, _FunctionSet] - classes: Mapping[str, frozenset[ast.ClassDef]] + functions: dict[str, _FunctionSet] + classes: dict[str, frozenset[ast.ClassDef]] class _SourceIndex: @@ -292,6 +311,8 @@ def __init__(self, source_root: Path, trees: Mapping[Path, ast.Module]) -> None: self._aliases: dict[str, _IndexedModule] = {} self._owners: dict[int, _IndexedModule] = {} self._paths: dict[Path, _IndexedModule] = {} + self._reexports: dict[tuple[str, str], tuple[_IndexedModule, str]] = {} + self._class_bases: dict[int, tuple[ast.ClassDef, ...]] = {} for path, tree in trees.items(): relative = path.relative_to(source_root).with_suffix("") parts = list(relative.parts) @@ -323,6 +344,153 @@ def __init__(self, source_root: Path, trees: Mapping[Path, ast.Module]) -> None: (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef), ): self._owners[id(candidate)] = indexed + for indexed in self._paths.values(): + self._index_exports(indexed) + for indexed in self._paths.values(): + self._index_class_bases(indexed) + + @staticmethod + def _assigned_names(target: ast.AST) -> frozenset[str]: + if isinstance(target, ast.Name): + return frozenset({target.id}) + if isinstance(target, (ast.Tuple, ast.List)): + return frozenset().union( + *(_SourceIndex._assigned_names(element) for element in target.elts) + ) + if isinstance(target, ast.Starred): + return _SourceIndex._assigned_names(target.value) + return frozenset() + + def _clear_export(self, module: _IndexedModule, name: str) -> None: + module.functions.pop(name, None) + module.classes.pop(name, None) + self._reexports.pop((module.name, name), None) + + def _index_exports(self, module: _IndexedModule) -> None: + """Index final explicit module bindings without suffix/name guessing.""" + + module.functions.clear() + module.classes.clear() + for statement in module.tree.body: + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + self._clear_export(module, statement.name) + module.functions[statement.name] = frozenset({statement}) + elif isinstance(statement, ast.ClassDef): + self._clear_export(module, statement.name) + module.classes[statement.name] = frozenset({statement}) + elif isinstance(statement, ast.ImportFrom): + target = self.resolve_module(statement.module, module, statement.level) + for alias in statement.names: + if alias.name == "*": + continue + bound = alias.asname or alias.name + self._clear_export(module, bound) + if target is not None: + self._reexports[(module.name, bound)] = (target, alias.name) + elif isinstance(statement, ast.Import): + for alias in statement.names: + self._clear_export(module, alias.asname or alias.name.partition(".")[0]) + elif isinstance(statement, (ast.Assign, ast.AnnAssign, ast.AugAssign)): + targets = ( + statement.targets if isinstance(statement, ast.Assign) else (statement.target,) + ) + for target in targets: + for name in self._assigned_names(target): + self._clear_export(module, name) + + def resolve_functions( + self, + module: _IndexedModule, + name: str, + seen: frozenset[tuple[str, str]] = frozenset(), + ) -> _FunctionSet: + key = (module.name, name) + if key in seen: + return frozenset() + direct = module.functions.get(name) + if direct is not None: + return direct + edge = self._reexports.get(key) + if edge is None: + return frozenset() + target, target_name = edge + return self.resolve_functions(target, target_name, seen | {key}) + + def resolve_classes( + self, + module: _IndexedModule, + name: str, + seen: frozenset[tuple[str, str]] = frozenset(), + ) -> frozenset[ast.ClassDef]: + key = (module.name, name) + if key in seen: + return frozenset() + direct = module.classes.get(name) + if direct is not None: + return direct + edge = self._reexports.get(key) + if edge is None: + return frozenset() + target, target_name = edge + return self.resolve_classes(target, target_name, seen | {key}) + + def _index_class_bases(self, module: _IndexedModule) -> None: + class_bindings: dict[str, frozenset[ast.ClassDef]] = {} + module_bindings: dict[str, _IndexedModule] = {} + for statement in module.tree.body: + if isinstance(statement, ast.Import): + for alias in statement.names: + bound = alias.asname or alias.name.partition(".")[0] + imported_name = alias.name if alias.asname else alias.name.partition(".")[0] + imported = self.resolve_module(imported_name, module) + class_bindings.pop(bound, None) + if imported is not None: + module_bindings[bound] = imported + elif isinstance(statement, ast.ImportFrom): + imported = self.resolve_module(statement.module, module, statement.level) + for alias in statement.names: + if alias.name == "*": + continue + bound = alias.asname or alias.name + module_bindings.pop(bound, None) + class_bindings[bound] = ( + self.resolve_classes(imported, alias.name) + if imported is not None + else frozenset() + ) + child = self.resolve_module( + f"{imported.name}.{alias.name}" if imported is not None else alias.name, + module, + ) + if child is not None: + module_bindings[bound] = child + elif isinstance(statement, ast.ClassDef): + bases: list[ast.ClassDef] = [] + for base in statement.bases: + if isinstance(base, ast.Name): + for resolved in class_bindings.get(base.id, frozenset()): + if resolved not in bases: + bases.append(resolved) + elif isinstance(base, ast.Attribute) and isinstance(base.value, ast.Name): + imported = module_bindings.get(base.value.id) + if imported is not None: + for resolved in self.resolve_classes(imported, base.attr): + if resolved not in bases: + bases.append(resolved) + self._class_bases[id(statement)] = tuple(bases) + class_bindings[statement.name] = frozenset({statement}) + module_bindings.pop(statement.name, None) + elif isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + class_bindings.pop(statement.name, None) + module_bindings.pop(statement.name, None) + elif isinstance(statement, (ast.Assign, ast.AnnAssign, ast.AugAssign)): + targets = ( + statement.targets if isinstance(statement, ast.Assign) else (statement.target,) + ) + for target in targets: + for name in self._assigned_names(target): + class_bindings.pop(name, None) + module_bindings.pop(name, None) def module_for_path(self, path: Path) -> _IndexedModule: return self._paths[path] @@ -348,15 +516,27 @@ def resolve_module( return direct return None - @staticmethod - def methods(classes: Iterable[ast.ClassDef], name: str) -> _FunctionSet: - return frozenset( - statement - for class_node in classes - for statement in class_node.body - if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) - and statement.name == name - ) + def methods(self, classes: Iterable[ast.ClassDef], name: str) -> _FunctionSet: + """Resolve the first known implementation along each exact class lineage.""" + + def inherited(class_node: ast.ClassDef, seen: frozenset[int]) -> _FunctionSet: + if id(class_node) in seen: + return frozenset() + direct = frozenset( + statement + for statement in class_node.body + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) + and statement.name == name + ) + if direct: + return direct + for base in self._class_bases.get(id(class_node), ()): + resolved = inherited(base, seen | {id(class_node)}) + if resolved: + return resolved + return frozenset() + + return frozenset().union(*(inherited(class_node, frozenset()) for class_node in classes)) @dataclass(frozen=True) @@ -485,6 +665,9 @@ def _local_functions(self, name: str) -> _FunctionSet: return frozenset() def _function_value(self, node: ast.AST) -> _FunctionSet: + value = self._expression_value(node) + if value.callables: + return frozenset(target.function for target in value.callables) if isinstance(node, ast.Name): return self._local_functions(node.id) if isinstance(node, ast.Lambda): @@ -494,16 +677,6 @@ def _function_value(self, node: ast.AST) -> _FunctionSet: if truth is not None: return self._function_value(node.body if truth else node.orelse) return self._function_value(node.body) | self._function_value(node.orelse) - if isinstance(node, ast.Attribute): - owner = self._expression_value(node.value) - imported = frozenset( - function - for module_name in owner.modules - if (module := self._source_index.resolve_module(module_name, self._module)) - is not None - for function in module.functions.get(node.attr, frozenset()) - ) - return imported | self._source_index.methods(owner.classes, node.attr) return frozenset() @staticmethod @@ -515,39 +688,19 @@ def _method_is_static(function: _FunctionNode) -> bool: def _call_targets( self, node: ast.AST ) -> tuple[tuple[_FunctionNode, _AbstractValue | None], ...]: - if isinstance(node, ast.IfExp): - truth = self._static_truth(node.test) - branches = ( - (node.body if truth else node.orelse,) - if truth is not None - else ( - node.body, - node.orelse, - ) - ) - return tuple(target for branch in branches for target in self._call_targets(branch)) - if isinstance(node, ast.Attribute): - owner = self._expression_value(node.value) - imported = [ - (function, None) - for module_name in owner.modules - if (module := self._source_index.resolve_module(module_name, self._module)) - is not None - for function in module.functions.get(node.attr, frozenset()) - ] - methods = [ - (function, None if self._method_is_static(function) else owner) - for function in self._source_index.methods(owner.classes, node.attr) - ] - return (*imported, *methods) + value = self._expression_value(node) + if value.callables: + return tuple((target.function, target.receiver) for target in value.callables) return tuple((function, None) for function in self._function_value(node)) - def _call_has_config_provenance( + def _call_has_relevant_provenance( self, node: ast.Call, function: _FunctionNode, - bound_receiver: _AbstractValue | None = None, + bound_receiver: _AbstractValue | None, ) -> bool: + if isinstance(function, ast.Lambda): + return True values = [ self._expression_value( argument.value if isinstance(argument, ast.Starred) else argument @@ -555,11 +708,11 @@ def _call_has_config_provenance( for argument in node.args ] values.extend(self._expression_value(keyword.value) for keyword in node.keywords) - values.extend(self._scoped_function_arguments(function).values()) - if bound_receiver is not None: - values.append(bound_receiver) + values.extend(self._default_argument_values(function).values()) tracked = TRACKED_SECTIONS | {_CONFIG_ROOT} - return any(_contained_origins(value) & tracked for value in values) + if any(_contained_origins(value) & tracked or value.callables for value in values): + return True + return bound_receiver is not None and bool(_contained_origins(bound_receiver) & tracked) @staticmethod def _named_value( @@ -626,6 +779,9 @@ def _replace_identity( identity=value.identity, literal=value.literal, truth=value.truth, + classes=value.classes, + modules=value.modules, + callables=value.callables, ) def _replace_shared_value( @@ -666,6 +822,45 @@ def _mapping_replacement( identity=owner.identity, literal=owner.literal, truth=False if not normalized else None, + classes=owner.classes, + modules=owner.modules, + callables=owner.callables, + ) + + @staticmethod + def _sequence_replacement( + owner: _AbstractValue, + items: tuple[_AbstractValue, ...], + ) -> _AbstractValue: + return _AbstractValue( + origins=owner.origins, + items=items, + entries=owner.entries, + attributes=owner.attributes, + identity=owner.identity, + literal=owner.literal, + truth=False if not items else None, + classes=owner.classes, + modules=owner.modules, + callables=owner.callables, + ) + + @staticmethod + def _attribute_replacement( + owner: _AbstractValue, + attributes: tuple[tuple[str, _AbstractValue], ...], + ) -> _AbstractValue: + return _AbstractValue( + origins=owner.origins, + items=owner.items, + entries=owner.entries, + attributes=attributes, + identity=owner.identity, + literal=owner.literal, + truth=owner.truth, + classes=owner.classes, + modules=owner.modules, + callables=owner.callables, ) @staticmethod @@ -908,7 +1103,20 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if isinstance(node, ast.Constant): return _AbstractValue(literal=_key_token(node), truth=bool(node.value)) if isinstance(node, ast.Name): - return self._name_value(node.id) + value = self._name_value(node.id) + if value.callables: + return value + functions = self._local_functions(node.id) + if functions: + return _join_values( + value, + _AbstractValue( + callables=tuple(_CallableTarget(function) for function in functions) + ), + ) + return value + if isinstance(node, ast.Lambda): + return _AbstractValue(callables=(_CallableTarget(node),)) if isinstance(node, ast.Attribute): owner = self._expression_value(node.value) attribute = self._named_value(owner.attributes, node.attr) @@ -929,12 +1137,31 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: for module_name in owner.modules if (module := self._source_index.resolve_module(module_name, self._module)) is not None - for class_node in module.classes.get(node.attr, frozenset()) + for class_node in self._source_index.resolve_classes(module, node.attr) + ) + imported_functions = frozenset( + function + for module_name in owner.modules + if (module := self._source_index.resolve_module(module_name, self._module)) + is not None + for function in self._source_index.resolve_functions(module, node.attr) ) - if resolved_modules or resolved_classes: + methods = self._source_index.methods(owner.classes, node.attr) + if resolved_modules or resolved_classes or imported_functions or methods: + callable_targets = [ + *(_CallableTarget(function) for function in imported_functions), + *( + _CallableTarget( + function, + None if self._method_is_static(function) else owner, + ) + for function in methods + ), + ] return _AbstractValue( modules=frozenset(resolved_modules), classes=resolved_classes, + callables=tuple(callable_targets), ) if node.attr in TRACKED_SECTIONS and _CONFIG_ROOT in owner.origins: return _origin_value(node.attr) @@ -971,6 +1198,15 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: and _CONFIG_ROOT in self._expression_value(node.args[0]).origins ): return _origin_value(node.args[1].value) + values = [ + self._local_call_value(node, function, bound_receiver) + for function, bound_receiver in self._call_targets(node.func) + if self._call_has_relevant_provenance(node, function, bound_receiver) + ] + if values: + value = _join_values(*values) + self._expression_cache[id(node)] = value + return value constructor_classes = self._expression_value(node.func).classes if constructor_classes or callable_name is not None and callable_name[:1].isupper(): items: list[_AbstractValue] = [] @@ -994,6 +1230,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue( items=tuple(items), attributes=tuple(attributes), + identity=frozenset({id(node)}), classes=constructor_classes, ) if isinstance(node.func, ast.Name) and node.func.id == "dict": @@ -1017,15 +1254,6 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue( entries=tuple(sorted(entries.items())), identity=frozenset({id(node)}) ) - values = [ - self._local_call_value(node, function, bound_receiver) - for function, bound_receiver in self._call_targets(node.func) - if self._call_has_config_provenance(node, function, bound_receiver) - ] - if values: - value = _join_values(*values) - self._expression_cache[id(node)] = value - return value return _UNKNOWN_VALUE if isinstance(node, ast.Subscript): owner = self._expression_value(node.value) @@ -1054,7 +1282,11 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: sequence_items.extend(value.items) else: sequence_items.append(value) - return _AbstractValue(items=tuple(sequence_items), truth=self._static_truth(node)) + return _AbstractValue( + items=tuple(sequence_items), + identity=(frozenset({id(node)}) if isinstance(node, ast.List) else frozenset()), + truth=self._static_truth(node), + ) if isinstance(node, ast.Dict): literal_entries: dict[str, _AbstractValue] = {} for key, item in zip(node.keys, node.values, strict=True): @@ -1363,11 +1595,33 @@ def _visit_store_target(self, target: ast.expr) -> None: self._visit_store_target(target.value) def _assign_store_target(self, target: ast.expr, value: _AbstractValue) -> None: + if isinstance(target, ast.Attribute): + owner = self._expression_value(target.value) + if owner.attributes is None and not owner.identity: + return + attributes = dict(owner.attributes or ()) + attributes[target.attr] = value + replacement = self._attribute_replacement(owner, tuple(sorted(attributes.items()))) + self._replace_shared_value(owner, replacement, target.value) + return if not isinstance(target, ast.Subscript): return owner = self._expression_value(target.value) if owner.entries is None and not owner.identity: return + if owner.items is not None: + items = list(owner.items) + if isinstance(target.slice, ast.Constant) and isinstance(target.slice.value, int): + index = target.slice.value + if -len(items) <= index < len(items): + items[index] = value + else: + return + else: + items = [_join_values(item, value) for item in items] + replacement = self._sequence_replacement(owner, tuple(items)) + self._replace_shared_value(owner, replacement, target.value) + return entries = dict(owner.entries or ()) if isinstance(target.slice, ast.Constant): entries[_key_token(target.slice)] = value @@ -2118,12 +2372,12 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: self._annotations[-1][bound] = self._imported_annotation(node.module, alias.name) if runtime: functions = ( - module.functions.get(alias.name, frozenset()) + self._source_index.resolve_functions(module, alias.name) if module is not None else frozenset() ) classes = ( - module.classes.get(alias.name, frozenset()) + self._source_index.resolve_classes(module, alias.name) if module is not None else frozenset() ) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 2307075592..698910bfc8 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1301,6 +1301,201 @@ def test_runtime_scan_defers_lambda_bodies_until_tracked_invocation( ) +def test_runtime_scan_preserves_callable_provenance_across_runtime_boundaries( + contract, tmp_path: Path +) -> None: + package = tmp_path / "readers" + package.mkdir() + (package / "__init__.py").write_text( + "from .public import read_semantic\n", + encoding="utf-8", + ) + (package / "public.py").write_text( + "from .helpers import read_semantic\n", + encoding="utf-8", + ) + (package / "helpers.py").write_text( + """ +def read_semantic(section): + return section.semantic_model +""", + encoding="utf-8", + ) + (tmp_path / "base_reader.py").write_text( + """ +class BaseReader: + def read(self, section): + return section.satisfaction_threshold +""", + encoding="utf-8", + ) + (tmp_path / "callable_boundaries.py").write_text( + """ +from base_reader import BaseReader +from readers import read_semantic + +class Reader: + def read(self, section): + return section.stage1_enabled + +class InheritedReader(BaseReader): + pass + +class Holder: + pass + +reader = Reader() +callback = reader.read +reader = report +callback(config.evaluation) + +callbacks = {"stage": lambda section: section.stage2_enabled} +callbacks["stage"](config.evaluation) +listed_callbacks = [lambda section: section.stage2_enabled] +listed_callbacks[0](config.evaluation) +holder = Holder(callback=lambda section: section.stage2_enabled) +holder.callback(config.evaluation) + +def invoke(callback, section): + return callback(section) + +invoke(lambda section: section.stage3_enabled, config.evaluation) + +def Choose(callback): + return callback + +chosen = Choose(lambda section: section.stage3_enabled) +chosen(config.evaluation) +read_semantic(config.evaluation) +InheritedReader().read(config.evaluation) + +def invoke_without_arguments(callback): + return callback() + +invoke_without_arguments(lambda: config.evaluation.uncertainty_threshold) +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "satisfaction_threshold"), + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "uncertainty_threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_rejects_callable_binding_overreach(contract, tmp_path: Path) -> None: + package = tmp_path / "overwritten" + package.mkdir() + (package / "__init__.py").write_text( + """ +from .helpers import read_judge +read_judge = external +""", + encoding="utf-8", + ) + (package / "helpers.py").write_text( + """ +def read_judge(section): + return section.judge_model +""", + encoding="utf-8", + ) + (tmp_path / "callable_negatives.py").write_text( + """ +from overwritten import read_judge + +class Reader: + def read(self, section): + return section.models + +reader = Reader() +reader = report +unknown_method = reader.read +unknown_method(config.consensus) + +callbacks = { + "unused": lambda section: section.min_models, + "selected": external, +} +callbacks["selected"](config.consensus) +callbacks["unused"] = external +callbacks["unused"](config.consensus) +callbacks.get("missing", external)(config.consensus) + +handlers = [lambda section: section.threshold] +alias = handlers +alias[0] = external +handlers[0](config.consensus) + +class Holder: + pass + +holder = Holder(callback=lambda section: section.models) +holder.callback = external +holder.callback(config.consensus) + +def invoke(callback, section): + return callback(section) + +invoke(lambda section: section.diversity_required, report.consensus) +read_judge(config.consensus) + +class BaseReader: + def read(self, section): + return section.advocate_model + +class OverrideReader(BaseReader): + def read(self, section): + return None + +OverrideReader().read(config.consensus) + +class FirstReader: + def read(self, section): + return None + +class SecondReader: + def read(self, section): + return section.devil_model + +class OrderedReader(FirstReader, SecondReader): + pass + +OrderedReader().read(config.consensus) + +class ExternalReader(external.BaseReader): + pass + +class CoincidentalReader: + def read(self, section): + return section.models + +ExternalReader().read(config.consensus) +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("consensus", "advocate_model"), + contract.ConfigField("consensus", "devil_model"), + contract.ConfigField("consensus", "diversity_required"), + contract.ConfigField("consensus", "judge_model"), + contract.ConfigField("consensus", "min_models"), + contract.ConfigField("consensus", "models"), + contract.ConfigField("consensus", "threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset() + + def test_runtime_scan_joins_callable_bindings_across_compound_statement_paths( contract, tmp_path: Path ) -> None: From 72051523a462936692be73ebd9a5ff61bcc866f7 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 10 Aug 2026 21:28:21 +0900 Subject: [PATCH 19/70] fix(config): close reflective scanner gaps --- scripts/check-config-reference-contract.py | 195 ++++++++++++++++-- .../test_check_config_reference_contract.py | 125 +++++++++++ 2 files changed, 307 insertions(+), 13 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 1c74dbdf40..6649b0731e 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -147,8 +147,11 @@ class _AbstractValue: literal: str | None = None truth: bool | None = None classes: frozenset[ast.ClassDef] = frozenset() + instance_classes: frozenset[ast.ClassDef] = frozenset() modules: frozenset[str] = frozenset() callables: tuple[_CallableTarget, ...] = () + serialized_sections: frozenset[str] = frozenset() + accessed_attributes: frozenset[str] = frozenset() @dataclass(frozen=True) @@ -164,6 +167,8 @@ class _CallableTarget: _ANNOTATION_MODULE = "" _TYPE_CHECKING_FALSE = "" _TYPING_MODULE = "" +_OPERATOR_MODULE = "" +_ATTRGETTER_FACTORY = "" _SECTION_ANNOTATIONS: Mapping[str, str] = { "EvaluationConfig": "evaluation", "ConsensusConfig": "consensus", @@ -199,8 +204,11 @@ def _conservative_value(value: _AbstractValue) -> _AbstractValue: return _AbstractValue( origins=_contained_origins(value), classes=value.classes, + instance_classes=value.instance_classes, modules=value.modules, callables=value.callables, + serialized_sections=value.serialized_sections, + accessed_attributes=value.accessed_attributes, ) @@ -273,8 +281,11 @@ def join_entries( values[0].truth if all(value.truth == values[0].truth for value in values) else None ), classes=frozenset().union(*(value.classes for value in values)), + instance_classes=frozenset().union(*(value.instance_classes for value in values)), modules=frozenset().union(*(value.modules for value in values)), callables=tuple(callables), + serialized_sections=frozenset().union(*(value.serialized_sections for value in values)), + accessed_attributes=frozenset().union(*(value.accessed_attributes for value in values)), ) @@ -581,6 +592,7 @@ def __init__( self._path_reachable = True self._exception_capture_depth = 0 self._caught_exception_stack: list[tuple[_AbruptPath, ...]] = [] + self._function_body_depth = 0 self.reads: set[ConfigField] = set() def _name_value(self, name: str) -> _AbstractValue: @@ -591,6 +603,11 @@ def _name_value(self, name: str) -> _AbstractValue: return _origin_value(_CONFIG_ROOT) return _UNKNOWN_VALUE + def _name_is_bound(self, name: str) -> bool: + return any(name in scope for scope in self._states) or any( + name in scope for scope in self._functions + ) + def _annotation_name_value(self, name: str) -> _AbstractValue: for scope in reversed(self._annotations): if name in scope: @@ -685,13 +702,24 @@ def _method_is_static(function: _FunctionNode) -> bool: _callable_name(decorator) == "staticmethod" for decorator in function.decorator_list ) + @staticmethod + def _method_is_property(function: _FunctionNode) -> bool: + return isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)) and any( + _callable_name(decorator) == "property" for decorator in function.decorator_list + ) + def _call_targets( self, node: ast.AST ) -> tuple[tuple[_FunctionNode, _AbstractValue | None], ...]: value = self._expression_value(node) - if value.callables: - return tuple((target.function, target.receiver) for target in value.callables) - return tuple((function, None) for function in self._function_value(node)) + targets = [(target.function, target.receiver) for target in value.callables] + for function in self._source_index.methods(value.instance_classes, "__call__"): + target = (function, None if self._method_is_static(function) else value) + if target not in targets: + targets.append(target) + if not targets: + targets.extend((function, None) for function in self._function_value(node)) + return tuple(targets) def _call_has_relevant_provenance( self, @@ -780,8 +808,11 @@ def _replace_identity( literal=value.literal, truth=value.truth, classes=value.classes, + instance_classes=value.instance_classes, modules=value.modules, callables=value.callables, + serialized_sections=value.serialized_sections, + accessed_attributes=value.accessed_attributes, ) def _replace_shared_value( @@ -823,8 +854,11 @@ def _mapping_replacement( literal=owner.literal, truth=False if not normalized else None, classes=owner.classes, + instance_classes=owner.instance_classes, modules=owner.modules, callables=owner.callables, + serialized_sections=owner.serialized_sections, + accessed_attributes=owner.accessed_attributes, ) @staticmethod @@ -841,8 +875,11 @@ def _sequence_replacement( literal=owner.literal, truth=False if not items else None, classes=owner.classes, + instance_classes=owner.instance_classes, modules=owner.modules, callables=owner.callables, + serialized_sections=owner.serialized_sections, + accessed_attributes=owner.accessed_attributes, ) @staticmethod @@ -859,8 +896,11 @@ def _attribute_replacement( literal=owner.literal, truth=owner.truth, classes=owner.classes, + instance_classes=owner.instance_classes, modules=owner.modules, callables=owner.callables, + serialized_sections=owner.serialized_sections, + accessed_attributes=owner.accessed_attributes, ) @staticmethod @@ -1122,6 +1162,20 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: attribute = self._named_value(owner.attributes, node.attr) if attribute is not None: return attribute + property_getters = tuple( + function + for function in self._source_index.methods(owner.instance_classes, node.attr) + if self._method_is_property(function) + ) + if property_getters: + return _join_values( + *( + self._local_direct_call_value(function, (owner,)) + for function in property_getters + ) + ) + if _OPERATOR_MODULE in owner.origins and node.attr == "attrgetter": + return _origin_value(_ATTRGETTER_FACTORY) resolved_modules = { child.name for module_name in owner.modules @@ -1179,6 +1233,28 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if dict_method_value is not None: self._expression_cache[id(node)] = dict_method_value return dict_method_value + function_value = self._expression_value(node.func) + if _ATTRGETTER_FACTORY in function_value.origins: + return _AbstractValue( + accessed_attributes=frozenset( + argument.value + for argument in node.args + if isinstance(argument, ast.Constant) and isinstance(argument.value, str) + ) + ) + if isinstance(node.func, ast.Attribute) and node.func.attr == "model_dump": + sections = self._expression_value(node.func.value).origins & TRACKED_SECTIONS + if sections: + return _AbstractValue(serialized_sections=sections) + if ( + isinstance(node.func, ast.Name) + and node.func.id == "vars" + and not self._name_is_bound("vars") + and node.args + ): + sections = self._expression_value(node.args[0]).origins & TRACKED_SECTIONS + if sections: + return _AbstractValue(serialized_sections=sections) callable_name = _callable_name(node.func) if callable_name is not None and _CONFIG_FACTORY.search(callable_name): if isinstance(node.func, ast.Name): @@ -1232,6 +1308,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: attributes=tuple(attributes), identity=frozenset({id(node)}), classes=constructor_classes, + instance_classes=constructor_classes, ) if isinstance(node.func, ast.Name) and node.func.id == "dict": entries: dict[str, _AbstractValue] = {} @@ -1381,6 +1458,15 @@ def visit_Call(self, node: ast.Call) -> None: # Evaluate once even when the call is a standalone mutating # ``setdefault`` expression. self._expression_value(node) + accessor = self._expression_value(node.func) + if accessor.accessed_attributes: + for argument in node.args: + value = self._expression_value( + argument.value if isinstance(argument, ast.Starred) else argument + ) + for section in value.origins & TRACKED_SECTIONS: + for name in accessor.accessed_attributes: + self._record(section, name.partition(".")[0]) if ( isinstance(node.func, ast.Name) and node.func.id == "getattr" @@ -1395,6 +1481,13 @@ def visit_Call(self, node: ast.Call) -> None: def visit_Subscript(self, node: ast.Subscript) -> None: before = self._binding_snapshot() + if ( + isinstance(node.ctx, ast.Load) + and isinstance(node.slice, ast.Constant) + and isinstance(node.slice.value, str) + ): + for section in self._expression_value(node.value).serialized_sections: + self._record(section, node.slice.value) self.generic_visit(node) if isinstance(node.ctx, ast.Load): self._record_possible_exception(before) @@ -2358,6 +2451,8 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: self._states[-1][bound] = ( _origin_value(_TYPING_MODULE) if alias.name == "typing" + else _origin_value(_OPERATOR_MODULE) + if alias.name == "operator" else _AbstractValue( modules=frozenset({module.name}) if module is not None else frozenset() ) @@ -2389,6 +2484,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: self._states[-1][bound] = ( _type_checking_value() if node.module == "typing" and alias.name == "TYPE_CHECKING" + else _origin_value(_ATTRGETTER_FACTORY) + if node.module == "operator" and alias.name == "attrgetter" else _AbstractValue( classes=classes, modules=( @@ -2591,6 +2688,23 @@ def bind_positional( scoped[arguments.kwarg.arg] = _AbstractValue(entries=tuple(extra_keywords)) return scoped + def _bound_direct_arguments( + self, + function: _FunctionNode, + values: tuple[_AbstractValue, ...], + ) -> dict[str, _AbstractValue]: + """Bind already-evaluated positional values to a local callable.""" + + arguments = function.args + scoped = self._scoped_arguments(arguments) + scoped.update(self._default_argument_values(function)) + positional = (*arguments.posonlyargs, *arguments.args) + for parameter, value in zip(positional, values, strict=False): + scoped[parameter.arg] = value + if arguments.vararg is not None: + scoped[arguments.vararg.arg] = _AbstractValue(items=values[len(positional) :]) + return scoped + def _visit_function_body( self, node: _FunctionNode, @@ -2617,6 +2731,7 @@ def _visit_function_body( previous_module = self._module self._module = self._source_index.owner(node) or self._module self._expression_cache = {} + self._function_body_depth += 1 try: if isinstance(node, ast.Lambda): self.visit(node.body) @@ -2628,6 +2743,7 @@ def _visit_function_body( if path.kind == "return" ) finally: + self._function_body_depth -= 1 self._expression_cache = previous_cache self._module = previous_module self._functions.pop() @@ -2653,6 +2769,24 @@ def _local_call_value( self._active_calls.remove(function_id) return _join_values(*returned) + def _local_direct_call_value( + self, + function: _FunctionNode, + values: tuple[_AbstractValue, ...], + ) -> _AbstractValue: + function_id = id(function) + if function_id in self._active_calls: + return _UNKNOWN_VALUE + self._active_calls.add(function_id) + try: + returned = self._visit_function_body( + function, + self._bound_direct_arguments(function, values), + ) + finally: + self._active_calls.remove(function_id) + return _join_values(*returned) + def visit_Return(self, node: ast.Return) -> None: if node.value is not None: self.visit(node.value) @@ -2728,25 +2862,60 @@ def visit_Continue(self, node: ast.Continue) -> None: ) self._path_reachable = False - def _visit_scoped(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + def _decorated_function_value( + self, node: ast.FunctionDef | ast.AsyncFunctionDef + ) -> _AbstractValue: + original = _AbstractValue(callables=(_CallableTarget(node),)) + value = original + for decorator in reversed(node.decorator_list): + targets = self._call_targets(decorator) + if not targets: + return original + replacements = [ + self._local_direct_call_value( + function, + ((receiver,) if receiver is not None else ()) + (value,), + ) + for function, receiver in targets + ] + if not replacements: + return original + replacement = _join_values(*replacements) + if not replacement.callables and not replacement.instance_classes: + return original + value = replacement + return value + + def _visit_function_definition(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: for decorator in node.decorator_list: self.visit(decorator) for default in (*node.args.defaults, *node.args.kw_defaults): if default is not None: self.visit(default) - self._visit_function_body(node, self._scoped_function_arguments(node)) + value = self._decorated_function_value(node) + functions = frozenset(target.function for target in value.callables) + self._states[-1][node.name] = value + self._functions[-1][node.name] = functions + self._annotations[-1][node.name] = _UNKNOWN_VALUE + + if self._function_body_depth != 0: + return + targets = value.callables or (_CallableTarget(node),) + visited: set[int] = set() + for target in targets: + if id(target.function) in visited: + continue + visited.add(id(target.function)) + scoped = self._scoped_function_arguments(target.function) + if target.receiver is not None: + scoped.update(self._bound_direct_arguments(target.function, (target.receiver,))) + self._visit_function_body(target.function, scoped) def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - self._functions[-1][node.name] = frozenset({node}) - self._visit_scoped(node) - self._states[-1][node.name] = _UNKNOWN_VALUE - self._annotations[-1][node.name] = _UNKNOWN_VALUE + self._visit_function_definition(node) def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: - self._functions[-1][node.name] = frozenset({node}) - self._visit_scoped(node) - self._states[-1][node.name] = _UNKNOWN_VALUE - self._annotations[-1][node.name] = _UNKNOWN_VALUE + self._visit_function_definition(node) def visit_Module(self, node: ast.Module) -> None: self._functions[-1].update(self._declared_functions(node.body)) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 698910bfc8..75ff081d76 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1625,6 +1625,131 @@ def guaranteed_iteration_shadowing(config): ) +def test_runtime_scan_tracks_model_dump_subscript_reads(contract, tmp_path: Path) -> None: + (tmp_path / "model_dump_read.py").write_text( + """ +def read(section): + return section.model_dump()["stage1_enabled"] + +read(config.evaluation) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_tracks_vars_subscript_reads(contract, tmp_path: Path) -> None: + (tmp_path / "vars_read.py").write_text( + """ +def read(section): + return vars(section)["stage2_enabled"] + +read(config.evaluation) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage2_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_tracks_operator_attrgetter_reads(contract, tmp_path: Path) -> None: + (tmp_path / "attrgetter_read.py").write_text( + """ +import operator + +read_stage = operator.attrgetter("stage3_enabled") +read_stage(config.evaluation) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage3_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_invokes_captured_property_getters(contract, tmp_path: Path) -> None: + (tmp_path / "property_read.py").write_text( + """ +class Reader: + @property + def value(self): + return self.section.satisfaction_threshold + +reader = Reader(section=config.evaluation) +captured = reader.value +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "satisfaction_threshold") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_invokes_callable_object_dunder_call(contract, tmp_path: Path) -> None: + (tmp_path / "callable_object_read.py").write_text( + """ +class Reader: + def __call__(self, section): + return section.stage2_enabled + +reader = Reader() +reader(config.evaluation) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage2_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_ignores_unreachable_local_reader_before_external_overwrite( + contract, tmp_path: Path +) -> None: + (tmp_path / "unreachable_local_reader.py").write_text( + """ +from ouroboros.config.models import EvaluationConfig + +def dispatch(config): + def local_reader(section: EvaluationConfig): + return section.stage1_enabled + + selected = external + if False: + selected = local_reader + selected = external + selected(config.evaluation) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +def test_runtime_scan_honors_exact_local_replacement_decorator(contract, tmp_path: Path) -> None: + (tmp_path / "replacement_decorator.py").write_text( + """ +from ouroboros.config.models import EvaluationConfig + +def replace_with_noop(function): + return lambda *args, **kwargs: None + +@replace_with_noop +def read_stage(section: EvaluationConfig): + return section.stage2_enabled + +read_stage(config.evaluation) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage2_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + def test_runtime_scan_keeps_for_break_path_separate_from_loop_else( contract, tmp_path: Path ) -> None: From 3476dfdcbd7604640ba8bb7cb41b3c67b729adef Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 01:06:23 +0900 Subject: [PATCH 20/70] fix(config): preserve exact runtime read provenance --- scripts/check-config-reference-contract.py | 30 ++++++++--- .../test_check_config_reference_contract.py | 50 +++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 6649b0731e..1741ff32b1 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -145,6 +145,7 @@ class _AbstractValue: attributes: tuple[tuple[str, _AbstractValue], ...] | None = None identity: frozenset[int] = frozenset() literal: str | None = None + string_value: str | None = None truth: bool | None = None classes: frozenset[ast.ClassDef] = frozenset() instance_classes: frozenset[ast.ClassDef] = frozenset() @@ -277,6 +278,11 @@ def join_entries( if all(value.literal == values[0].literal for value in values) else None ), + string_value=( + values[0].string_value + if all(value.string_value == values[0].string_value for value in values) + else None + ), truth=( values[0].truth if all(value.truth == values[0].truth for value in values) else None ), @@ -806,6 +812,7 @@ def _replace_identity( attributes=attributes, identity=value.identity, literal=value.literal, + string_value=value.string_value, truth=value.truth, classes=value.classes, instance_classes=value.instance_classes, @@ -852,6 +859,7 @@ def _mapping_replacement( attributes=owner.attributes, identity=owner.identity, literal=owner.literal, + string_value=owner.string_value, truth=False if not normalized else None, classes=owner.classes, instance_classes=owner.instance_classes, @@ -873,6 +881,7 @@ def _sequence_replacement( attributes=owner.attributes, identity=owner.identity, literal=owner.literal, + string_value=owner.string_value, truth=False if not items else None, classes=owner.classes, instance_classes=owner.instance_classes, @@ -894,6 +903,7 @@ def _attribute_replacement( attributes=attributes, identity=owner.identity, literal=owner.literal, + string_value=owner.string_value, truth=owner.truth, classes=owner.classes, instance_classes=owner.instance_classes, @@ -991,6 +1001,7 @@ def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: attributes=owner.attributes, identity=frozenset({id(node)}), literal=owner.literal, + string_value=owner.string_value, truth=owner.truth, ) if method == "values": @@ -1141,7 +1152,11 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if cached is not None: return cached if isinstance(node, ast.Constant): - return _AbstractValue(literal=_key_token(node), truth=bool(node.value)) + return _AbstractValue( + literal=_key_token(node), + string_value=node.value if isinstance(node.value, str) else None, + truth=bool(node.value), + ) if isinstance(node, ast.Name): value = self._name_value(node.id) if value.callables: @@ -1471,11 +1486,12 @@ def visit_Call(self, node: ast.Call) -> None: isinstance(node.func, ast.Name) and node.func.id == "getattr" and len(node.args) >= 2 - and isinstance(node.args[1], ast.Constant) - and isinstance(node.args[1].value, str) + and not self._name_is_bound("getattr") ): - for section in self._expression_value(node.args[0]).origins & TRACKED_SECTIONS: - self._record(section, node.args[1].value) + field_name = self._expression_value(node.args[1]).string_value + if field_name is not None: + for section in self._expression_value(node.args[0]).origins & TRACKED_SECTIONS: + self._record(section, field_name) self.generic_visit(node) self._record_possible_exception(before) @@ -2882,6 +2898,8 @@ def _decorated_function_value( return original replacement = _join_values(*replacements) if not replacement.callables and not replacement.instance_classes: + if replacement != _UNKNOWN_VALUE: + return replacement return original value = replacement return value @@ -2900,7 +2918,7 @@ def _visit_function_definition(self, node: ast.FunctionDef | ast.AsyncFunctionDe if self._function_body_depth != 0: return - targets = value.callables or (_CallableTarget(node),) + targets = value.callables visited: set[int] = set() for target in targets: if id(target.function) in visited: diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 75ff081d76..8506abe588 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1670,6 +1670,37 @@ def test_runtime_scan_tracks_operator_attrgetter_reads(contract, tmp_path: Path) assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_resolves_constant_indirected_getattr_and_ignores_shadowed_builtin( + contract, tmp_path: Path +) -> None: + (tmp_path / "constant_getattr.py").write_text( + """ +FIELD = "semantic_model" +getattr(config.evaluation, FIELD) +""", + encoding="utf-8", + ) + (tmp_path / "shadowed_getattr.py").write_text( + """ +def getattr(section, name): + return None + +getattr(config.evaluation, "stage2_enabled") +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("evaluation", "stage2_enabled"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "semantic_model")} + ) + + def test_runtime_scan_invokes_captured_property_getters(contract, tmp_path: Path) -> None: (tmp_path / "property_read.py").write_text( """ @@ -1750,6 +1781,25 @@ def read_stage(section: EvaluationConfig): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() +def test_runtime_scan_does_not_visit_erased_body_for_non_callable_decorator( + contract, tmp_path: Path +) -> None: + (tmp_path / "non_callable_decorator.py").write_text( + """ +def erase(function): + return None + +@erase +def read_stage(config): + return config.evaluation.stage2_enabled +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage2_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + def test_runtime_scan_keeps_for_break_path_separate_from_loop_else( contract, tmp_path: Path ) -> None: From 74d187dd782f9accbff01deb854e9b1d3368b7d8 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 01:45:40 +0900 Subject: [PATCH 21/70] fix(config): track builtin accessor aliases --- scripts/check-config-reference-contract.py | 37 +++++++++++-------- .../test_check_config_reference_contract.py | 34 +++++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 1741ff32b1..b150df82df 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -170,6 +170,8 @@ class _CallableTarget: _TYPING_MODULE = "" _OPERATOR_MODULE = "" _ATTRGETTER_FACTORY = "" +_BUILTINS_MODULE = "" +_GETATTR_BUILTIN = "" _SECTION_ANNOTATIONS: Mapping[str, str] = { "EvaluationConfig": "evaluation", "ConsensusConfig": "consensus", @@ -1158,7 +1160,11 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: truth=bool(node.value), ) if isinstance(node, ast.Name): - value = self._name_value(node.id) + value = ( + _origin_value(_GETATTR_BUILTIN) + if node.id == "getattr" and not self._name_is_bound("getattr") + else self._name_value(node.id) + ) if value.callables: return value functions = self._local_functions(node.id) @@ -1191,6 +1197,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: ) if _OPERATOR_MODULE in owner.origins and node.attr == "attrgetter": return _origin_value(_ATTRGETTER_FACTORY) + if _BUILTINS_MODULE in owner.origins and node.attr == "getattr": + return _origin_value(_GETATTR_BUILTIN) resolved_modules = { child.name for module_name in owner.modules @@ -1280,15 +1288,13 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: and node.func.value.id in {"self", "cls"} ): return _origin_value(_CONFIG_ROOT) - if ( - isinstance(node.func, ast.Name) - and node.func.id == "getattr" - and len(node.args) >= 2 - and isinstance(node.args[1], ast.Constant) - and node.args[1].value in TRACKED_SECTIONS - and _CONFIG_ROOT in self._expression_value(node.args[0]).origins - ): - return _origin_value(node.args[1].value) + if _GETATTR_BUILTIN in function_value.origins and len(node.args) >= 2: + field_name = self._expression_value(node.args[1]).string_value + if ( + field_name in TRACKED_SECTIONS + and _CONFIG_ROOT in self._expression_value(node.args[0]).origins + ): + return _origin_value(field_name) values = [ self._local_call_value(node, function, bound_receiver) for function, bound_receiver in self._call_targets(node.func) @@ -1482,12 +1488,7 @@ def visit_Call(self, node: ast.Call) -> None: for section in value.origins & TRACKED_SECTIONS: for name in accessor.accessed_attributes: self._record(section, name.partition(".")[0]) - if ( - isinstance(node.func, ast.Name) - and node.func.id == "getattr" - and len(node.args) >= 2 - and not self._name_is_bound("getattr") - ): + if _GETATTR_BUILTIN in accessor.origins and len(node.args) >= 2: field_name = self._expression_value(node.args[1]).string_value if field_name is not None: for section in self._expression_value(node.args[0]).origins & TRACKED_SECTIONS: @@ -2469,6 +2470,8 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: if alias.name == "typing" else _origin_value(_OPERATOR_MODULE) if alias.name == "operator" + else _origin_value(_BUILTINS_MODULE) + if alias.name == "builtins" else _AbstractValue( modules=frozenset({module.name}) if module is not None else frozenset() ) @@ -2502,6 +2505,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "typing" and alias.name == "TYPE_CHECKING" else _origin_value(_ATTRGETTER_FACTORY) if node.module == "operator" and alias.name == "attrgetter" + else _origin_value(_GETATTR_BUILTIN) + if node.module == "builtins" and alias.name == "getattr" else _AbstractValue( classes=classes, modules=( diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 8506abe588..27e206d236 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1701,6 +1701,40 @@ def getattr(section, name): ) +def test_runtime_scan_preserves_builtin_getattr_aliases(contract, tmp_path: Path) -> None: + (tmp_path / "assigned_alias.py").write_text( + """ +reader = getattr +reader(config.evaluation, "stage2_enabled") +""", + encoding="utf-8", + ) + (tmp_path / "imported_alias.py").write_text( + """ +from builtins import getattr as reader +reader(config.evaluation, "stage3_enabled") +""", + encoding="utf-8", + ) + (tmp_path / "module_alias.py").write_text( + """ +import builtins +reader = builtins.getattr +reader(config.evaluation, "satisfaction_threshold") +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + def test_runtime_scan_invokes_captured_property_getters(contract, tmp_path: Path) -> None: (tmp_path / "property_read.py").write_text( """ From 32f9ed68e2907d44c9a39225f25b11c24a85ab3c Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 02:10:49 +0900 Subject: [PATCH 22/70] fix(config): evaluate safe constant expressions --- scripts/check-config-reference-contract.py | 140 ++++++++++++++++++ .../test_check_config_reference_contract.py | 69 +++++++++ 2 files changed, 209 insertions(+) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index b150df82df..67709236e9 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -164,6 +164,7 @@ class _CallableTarget: _UNKNOWN_VALUE = _AbstractValue() +_STATIC_UNKNOWN = object() _ANNOTATION_MODULE = "" _TYPE_CHECKING_FALSE = "" @@ -184,6 +185,135 @@ class _CallableTarget: ) +def _safe_constant_value(node: ast.AST) -> object: + """Evaluate a deliberately small, side-effect-free constant expression.""" + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, (ast.Tuple, ast.List, ast.Set)): + values = [_safe_constant_value(item) for item in node.elts] + if any(value is _STATIC_UNKNOWN for value in values): + return _STATIC_UNKNOWN + if isinstance(node, ast.Tuple): + return tuple(values) + if isinstance(node, ast.List): + return values + try: + return set(values) + except TypeError: + return _STATIC_UNKNOWN + if isinstance(node, ast.UnaryOp): + operand = _safe_constant_value(node.operand) + if operand is _STATIC_UNKNOWN: + return _STATIC_UNKNOWN + try: + if isinstance(node.op, ast.Not): + return not operand + if isinstance(node.op, ast.UAdd) and isinstance(operand, (int, float, complex)): + return +operand + if isinstance(node.op, ast.USub) and isinstance(operand, (int, float, complex)): + return -operand + if isinstance(node.op, ast.Invert) and isinstance(operand, int): + return ~operand + except (TypeError, ValueError, OverflowError): + return _STATIC_UNKNOWN + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left = _safe_constant_value(node.left) + right = _safe_constant_value(node.right) + if left is _STATIC_UNKNOWN or right is _STATIC_UNKNOWN: + return _STATIC_UNKNOWN + if isinstance(left, str) and isinstance(right, str): + return left + right + if isinstance(left, bytes) and isinstance(right, bytes): + return left + right + if isinstance(left, tuple) and isinstance(right, tuple): + return left + right + if ( + isinstance(left, (int, float, complex)) + and not isinstance(left, bool) + and isinstance(right, (int, float, complex)) + and not isinstance(right, bool) + ): + return left + right + if isinstance(node, ast.JoinedStr): + parts: list[str] = [] + for part in node.values: + if isinstance(part, ast.Constant) and isinstance(part.value, str): + parts.append(part.value) + continue + if not isinstance(part, ast.FormattedValue): + return _STATIC_UNKNOWN + value = _safe_constant_value(part.value) + if value is _STATIC_UNKNOWN: + return _STATIC_UNKNOWN + if part.conversion == 115: + value = str(value) + elif part.conversion == 114: + value = repr(value) + elif part.conversion == 97: + value = ascii(value) + elif part.conversion != -1: + return _STATIC_UNKNOWN + format_spec = "" + if part.format_spec is not None: + resolved_spec = _safe_constant_value(part.format_spec) + if not isinstance(resolved_spec, str): + return _STATIC_UNKNOWN + format_spec = resolved_spec + try: + parts.append(format(value, format_spec)) + except (TypeError, ValueError): + return _STATIC_UNKNOWN + return "".join(parts) + if isinstance(node, ast.Compare): + left = _safe_constant_value(node.left) + if left is _STATIC_UNKNOWN: + return _STATIC_UNKNOWN + for operator, comparator in zip(node.ops, node.comparators, strict=True): + right = _safe_constant_value(comparator) + if right is _STATIC_UNKNOWN: + return _STATIC_UNKNOWN + try: + if isinstance(operator, ast.Eq): + matched = left == right + elif isinstance(operator, ast.NotEq): + matched = left != right + elif isinstance(operator, ast.Lt): + matched = left < right + elif isinstance(operator, ast.LtE): + matched = left <= right + elif isinstance(operator, ast.Gt): + matched = left > right + elif isinstance(operator, ast.GtE): + matched = left >= right + elif isinstance(operator, ast.Is): + if not ( + (left is None or type(left) is bool) + and (right is None or type(right) is bool) + ): + return _STATIC_UNKNOWN + matched = left is right + elif isinstance(operator, ast.IsNot): + if not ( + (left is None or type(left) is bool) + and (right is None or type(right) is bool) + ): + return _STATIC_UNKNOWN + matched = left is not right + elif isinstance(operator, ast.In): + matched = left in right + elif isinstance(operator, ast.NotIn): + matched = left not in right + else: + return _STATIC_UNKNOWN + except (TypeError, ValueError): + return _STATIC_UNKNOWN + if not matched: + return False + left = right + return True + return _STATIC_UNKNOWN + + def _origin_value(*origins: str) -> _AbstractValue: return _AbstractValue(origins=frozenset(origins)) @@ -1107,6 +1237,9 @@ def _dynamic_setdefault_value(self, node: ast.Call) -> _AbstractValue: def _static_truth(self, node: ast.AST) -> bool | None: """Return truth only when Python runtime behavior is statically certain.""" + constant_value = _safe_constant_value(node) + if constant_value is not _STATIC_UNKNOWN: + return bool(constant_value) if isinstance(node, ast.Constant): return bool(node.value) if isinstance(node, ast.Dict): @@ -1159,6 +1292,13 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: string_value=node.value if isinstance(node.value, str) else None, truth=bool(node.value), ) + constant_value = _safe_constant_value(node) + if isinstance(constant_value, str): + return _AbstractValue( + literal=_key_token(ast.Constant(constant_value)), + string_value=constant_value, + truth=bool(constant_value), + ) if isinstance(node, ast.Name): value = ( _origin_value(_GETATTR_BUILTIN) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 27e206d236..2926802445 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1735,6 +1735,75 @@ def test_runtime_scan_preserves_builtin_getattr_aliases(contract, tmp_path: Path assert contract.runtime_reads(tmp_path, fields) == fields +def test_static_comparisons_cannot_make_dead_reads_satisfy_the_contract( + contract, tmp_path: Path +) -> None: + (tmp_path / "dead_comparison.py").write_text( + """ +if 1 == 2: + dead = config.evaluation.stage1_enabled +if "stable" != "stable": + also_dead = config.evaluation.stage1_enabled +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + reads = contract.runtime_reads(tmp_path, frozenset({field})) + + report = contract.audit_contract( + fields=frozenset({field}), + reads=reads, + rows={ + field: contract.ReferenceRow( + "true", "Currently inert. Effective control: runtime.stage1_enabled." + ) + }, + markers={field: contract.InertMarker(field, "runtime.stage1_enabled")}, + allowlist={}, + documented_defaults={}, + ) + + assert reads == frozenset() + assert report.violations == () + + +def test_constant_string_expressions_establish_getattr_reads_and_reject_stale_inert_docs( + contract, tmp_path: Path +) -> None: + (tmp_path / "constant_string_getattr.py").write_text( + """ +FIELD = "semantic_" + "model" +getattr(config.evaluation, FIELD) +getattr(config.evaluation, f"stage{2}_enabled") +getattr(config.evaluation, f"semantic_{dynamic_suffix}") +""", + encoding="utf-8", + ) + semantic = contract.ConfigField("evaluation", "semantic_model") + stage2 = contract.ConfigField("evaluation", "stage2_enabled") + reads = contract.runtime_reads(tmp_path, frozenset({semantic, stage2})) + + report = contract.audit_contract( + fields=frozenset({semantic}), + reads=reads & {semantic}, + rows={ + semantic: contract.ReferenceRow( + '"claude-opus-4-8"', + "Currently inert. Effective control: models.semantic.", + ) + }, + markers={semantic: contract.InertMarker(semantic, "models.semantic")}, + allowlist={}, + documented_defaults={}, + ) + + assert reads == frozenset({semantic, stage2}) + assert "evaluation.semantic_model: conflicting config-field dispositions" in (report.violations) + assert "evaluation.semantic_model: production-wired field is still documented inert" in ( + report.violations + ) + + def test_runtime_scan_invokes_captured_property_getters(contract, tmp_path: Path) -> None: (tmp_path / "property_read.py").write_text( """ From 06797ab356b73c8e36e2d8e7b15afe58a406cf23 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 02:30:40 +0900 Subject: [PATCH 23/70] fix(config): ignore erased reader definitions --- scripts/check-config-reference-contract.py | 48 ++++++++++++++----- .../test_check_config_reference_contract.py | 40 ++++++++++++++++ 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 67709236e9..1ececfb4ce 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -731,6 +731,7 @@ def __init__( self._exception_capture_depth = 0 self._caught_exception_stack: list[tuple[_AbruptPath, ...]] = [] self._function_body_depth = 0 + self._class_member_values: dict[int, tuple[_AbstractValue, ...]] = {} self.reads: set[ConfigField] = set() def _name_value(self, name: str) -> _AbstractValue: @@ -1909,6 +1910,15 @@ def visit_NamedExpr(self, node: ast.NamedExpr) -> None: self._bind_destructured(node.target, self._expression_value(node.value)) self._bind_function_target(node.target, self._function_value(node.value)) + def visit_Delete(self, node: ast.Delete) -> None: + for target in node.targets: + if isinstance(target, ast.Name): + self._states[-1].pop(target.id, None) + self._annotations[-1].pop(target.id, None) + self._functions[-1].pop(target.id, None) + else: + self.visit(target) + def visit_AugAssign(self, node: ast.AugAssign) -> None: owner = self._expression_value(node.target) self.visit(node.target) @@ -3061,18 +3071,32 @@ def _visit_function_definition(self, node: ast.FunctionDef | ast.AsyncFunctionDe self._functions[-1][node.name] = functions self._annotations[-1][node.name] = _UNKNOWN_VALUE - if self._function_body_depth != 0: - return - targets = value.callables + def _visit_reachable_values(self, values: Iterable[_AbstractValue]) -> None: + """Scan bodies that remain reachable from final scope bindings. + + Calls executed while walking a scope are already scanned at their call + sites. Deferred scanning here covers exported definitions without + letting a later overwrite or deletion keep an erased body alive. + """ visited: set[int] = set() - for target in targets: - if id(target.function) in visited: - continue - visited.add(id(target.function)) - scoped = self._scoped_function_arguments(target.function) - if target.receiver is not None: - scoped.update(self._bound_direct_arguments(target.function, (target.receiver,))) - self._visit_function_body(target.function, scoped) + pending = list(values) + while pending: + value = pending.pop() + for target in value.callables: + function_id = id(target.function) + if function_id in visited: + continue + visited.add(function_id) + scoped = self._scoped_function_arguments(target.function) + if target.receiver is not None: + scoped.update(self._bound_direct_arguments(target.function, (target.receiver,))) + self._visit_function_body(target.function, scoped) + for class_node in value.classes: + class_id = id(class_node) + if class_id in visited: + continue + visited.add(class_id) + pending.extend(self._class_member_values.get(class_id, ())) def visit_FunctionDef(self, node: ast.FunctionDef) -> None: self._visit_function_definition(node) @@ -3084,6 +3108,7 @@ def visit_Module(self, node: ast.Module) -> None: self._functions[-1].update(self._declared_functions(node.body)) for statement in node.body: self.visit(statement) + self._visit_reachable_values(self._states[-1].values()) def visit_ClassDef(self, node: ast.ClassDef) -> None: for expression in (*node.decorator_list, *node.bases): @@ -3096,6 +3121,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: try: for statement in node.body: self.visit(statement) + self._class_member_values[id(node)] = tuple(self._states[-1].values()) finally: self._functions.pop() self._annotations.pop() diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 2926802445..f122ec40ee 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -1863,6 +1863,46 @@ def local_reader(section: EvaluationConfig): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() +def test_erased_definitions_cannot_satisfy_full_config_audit(contract, tmp_path: Path) -> None: + (tmp_path / "erased_definitions.py").write_text( + """ +def overwritten(config): + return config.evaluation.stage1_enabled +overwritten = None + +def deleted(config): + return config.evaluation.stage1_enabled +del deleted + +class Readers: + def replaced(self, config): + return config.evaluation.stage1_enabled + replaced = None + + def duplicate(self, config): + return config.evaluation.stage1_enabled + def duplicate(self, report): + return report.evaluation.stage1_enabled +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + reads = contract.runtime_reads(tmp_path, frozenset({field})) + + assert reads == frozenset() + report = contract.audit_contract( + fields=frozenset({field}), + reads=reads, + rows={field: contract.ReferenceRow("true", "Runtime control.")}, + markers={}, + allowlist={}, + documented_defaults={}, + ) + assert report.violations == ( + "evaluation.stage1_enabled: no production read, inert documentation, or schema-only rationale", + ) + + def test_runtime_scan_honors_exact_local_replacement_decorator(contract, tmp_path: Path) -> None: (tmp_path / "replacement_decorator.py").write_text( """ From f54c0bcba630d367eb68a4ed64e3bf6675ddbafc Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 03:58:54 +0900 Subject: [PATCH 24/70] fix(contract): model deferred and scope-bound reads --- scripts/check-config-reference-contract.py | 78 ++++++++++++++++--- .../test_check_config_reference_contract.py | 64 +++++++++++++++ 2 files changed, 132 insertions(+), 10 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 1ececfb4ce..f2370d0645 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -724,6 +724,8 @@ def __init__( self._states: list[dict[str, _AbstractValue]] = [{}] self._annotations: list[dict[str, _AbstractValue]] = [{}] self._functions: list[dict[str, _FunctionSet]] = [{}] + self._global_names: list[set[str]] = [set()] + self._nonlocal_names: list[set[str]] = [set()] self._active_calls: set[int] = set() self._expression_cache: dict[int, _AbstractValue] = {} self._flow_abrupts: list[list[_AbruptPath]] = [[]] @@ -868,6 +870,8 @@ def _call_has_relevant_provenance( ) -> bool: if isinstance(function, ast.Lambda): return True + if any(isinstance(child, (ast.Global, ast.Nonlocal)) for child in ast.walk(function)): + return True values = [ self._expression_value( argument.value if isinstance(argument, ast.Starred) else argument @@ -1617,6 +1621,20 @@ def visit_BoolOp(self, node: ast.BoolOp) -> None: def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() + if isinstance(node.func, ast.Name) and node.func.id in { + "all", + "any", + "list", + "max", + "min", + "next", + "set", + "sum", + "tuple", + }: + for argument in node.args: + if isinstance(argument, ast.GeneratorExp): + self._visit_comprehension(argument) # Evaluate once even when the call is a standalone mutating # ``setdefault`` expression. self._expression_value(node) @@ -1761,9 +1779,25 @@ def _visit_binding_branch( self._expression_cache = previous_cache self._path_reachable = previous_reachable + def _binding_scope_index(self, name: str) -> int: + """Resolve the lexical scope targeted by global/nonlocal assignment.""" + if name in self._global_names[-1]: + return 0 + if name in self._nonlocal_names[-1]: + for index in range(len(self._states) - 2, -1, -1): + if name in self._states[index] or index == 0: + return index + return len(self._states) - 1 + + def visit_Global(self, node: ast.Global) -> None: + self._global_names[-1].update(node.names) + + def visit_Nonlocal(self, node: ast.Nonlocal) -> None: + self._nonlocal_names[-1].update(node.names) + def _bind_target_value(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Name): - self._states[-1][target.id] = value + self._states[self._binding_scope_index(target.id)][target.id] = value elif isinstance(target, ast.Starred): self._bind_target_value(target.value, value) elif isinstance(target, (ast.Tuple, ast.List)): @@ -1772,7 +1806,7 @@ def _bind_target_value(self, target: ast.expr, value: _AbstractValue) -> None: def _bind_annotation_target(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Name): - self._annotations[-1][target.id] = value + self._annotations[self._binding_scope_index(target.id)][target.id] = value elif isinstance(target, (ast.Tuple, ast.List)): for element in target.elts: self._bind_annotation_target(element, _UNKNOWN_VALUE) @@ -1783,14 +1817,14 @@ def _bind_function_target( functions: _FunctionSet, ) -> None: if isinstance(target, ast.Name): - self._functions[-1][target.id] = functions + self._functions[self._binding_scope_index(target.id)][target.id] = functions elif isinstance(target, (ast.Tuple, ast.List)): for element in target.elts: self._bind_function_target(element, frozenset()) def _bind_destructured(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Name): - self._states[-1][target.id] = value + self._states[self._binding_scope_index(target.id)][target.id] = value return if isinstance(target, ast.Starred): self._bind_target_value(target.value, value) @@ -1913,9 +1947,10 @@ def visit_NamedExpr(self, node: ast.NamedExpr) -> None: def visit_Delete(self, node: ast.Delete) -> None: for target in node.targets: if isinstance(target, ast.Name): - self._states[-1].pop(target.id, None) - self._annotations[-1].pop(target.id, None) - self._functions[-1].pop(target.id, None) + index = self._binding_scope_index(target.id) + self._states[index].pop(target.id, None) + self._annotations[index].pop(target.id, None) + self._functions[index].pop(target.id, None) else: self.visit(target) @@ -1937,8 +1972,9 @@ def visit_AugAssign(self, node: ast.AugAssign) -> None: self._replace_shared_value(owner, replacement, node.target) return if isinstance(node.target, ast.Name): - self._states[-1][node.target.id] = self._name_value(node.target.id) - self._functions[-1][node.target.id] = frozenset() + index = self._binding_scope_index(node.target.id) + self._states[index][node.target.id] = self._name_value(node.target.id) + self._functions[index][node.target.id] = frozenset() def visit_If(self, node: ast.If) -> None: self.visit(node.test) @@ -1993,6 +2029,8 @@ def _bind_iteration_target(self, target: ast.expr, value: _AbstractValue) -> boo return True def visit_For(self, node: ast.For) -> None: + if isinstance(node.iter, ast.GeneratorExp): + self._visit_comprehension(node.iter) self.visit(node.iter) iterable = self._expression_value(node.iter) zero_iterations_possible = self._static_truth(node.iter) is not True @@ -2526,6 +2564,10 @@ def _visit_comprehension( self.visit(first.iter) first_value = self._expression_value(first.iter) self._states.append({}) + self._annotations.append({}) + self._functions.append({}) + self._global_names.append(set()) + self._nonlocal_names.append(set()) try: if not self._bind_iteration_target(first.target, first_value): self._expression_cache[id(node)] = _UNKNOWN_VALUE @@ -2559,6 +2601,10 @@ def _visit_comprehension( result = _AbstractValue(items=(self._expression_value(node.elt),)) self._expression_cache[id(node)] = result finally: + self._nonlocal_names.pop() + self._global_names.pop() + self._functions.pop() + self._annotations.pop() self._states.pop() def visit_ListComp(self, node: ast.ListComp) -> None: @@ -2568,7 +2614,9 @@ def visit_SetComp(self, node: ast.SetComp) -> None: self._visit_comprehension(node) def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: - self._visit_comprehension(node) + # Creating a generator evaluates no element expression; reads become + # reachable only when a consumer iterates it. + self._expression_cache[id(node)] = _UNKNOWN_VALUE def visit_DictComp(self, node: ast.DictComp) -> None: self._visit_comprehension(node) @@ -2883,6 +2931,8 @@ def _visit_function_body( ) -> tuple[_AbstractValue, ...]: self._states.append(scoped) self._annotations.append({}) + self._global_names.append(set()) + self._nonlocal_names.append(set()) function_bindings: dict[str, _FunctionSet] = { argument.arg: frozenset() for argument in ( @@ -2907,6 +2957,8 @@ def _visit_function_body( if isinstance(node, ast.Lambda): self.visit(node.body) return (self._expression_value(node.body),) + if any(isinstance(child, (ast.Yield, ast.YieldFrom)) for child in ast.walk(node)): + return () result = self._visit_binding_branch(node.body, self._binding_snapshot()) return tuple( path.return_value or _UNKNOWN_VALUE @@ -2918,6 +2970,8 @@ def _visit_function_body( self._expression_cache = previous_cache self._module = previous_module self._functions.pop() + self._nonlocal_names.pop() + self._global_names.pop() self._annotations.pop() self._states.pop() @@ -3118,11 +3172,15 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: self._states.append({}) self._annotations.append({}) self._functions.append(self._declared_functions(node.body)) + self._global_names.append(set()) + self._nonlocal_names.append(set()) try: for statement in node.body: self.visit(statement) self._class_member_values[id(node)] = tuple(self._states[-1].values()) finally: + self._nonlocal_names.pop() + self._global_names.pop() self._functions.pop() self._annotations.pop() self._states.pop() diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index f122ec40ee..7533cba09a 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -41,6 +41,70 @@ def test_current_repository_passes_standalone_contract() -> None: assert "Config reference contract OK" in result.stdout +def test_runtime_scan_honors_global_and_nonlocal_binding_updates(contract, tmp_path: Path) -> None: + (tmp_path / "scope_updates.py").write_text( + """ +reader = None + +def install_reader(): + global reader + reader = lambda config: config.evaluation.stage1_enabled + +install_reader() +reader(settings) + +erased = lambda config: config.evaluation.stage2_enabled +def erase_reader(): + global erased + erased = None +erase_reader() + +def outer(): + nested = lambda config: config.evaluation.stage3_enabled + def erase_nested(): + nonlocal nested + nested = None + erase_nested() + return nested +outer() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage1_enabled")} + ) + + +def test_runtime_scan_requires_generator_consumption(contract, tmp_path: Path) -> None: + (tmp_path / "deferred.py").write_text( + """ +def generators(config): + section = config.evaluation + pending = (section.stage1_enabled for _ in [1]) + consumed = list(section.stage2_enabled for _ in [1]) + for item in (section.stage3_enabled for _ in [1]): + pass +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + } + ) + + def test_runtime_scan_finds_attribute_alias_and_literal_getattr_reads( contract, tmp_path: Path ) -> None: From abad3fc8ecf1a116944e1802b7cdf194310654b0 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 04:25:46 +0900 Subject: [PATCH 25/70] fix(contract): track generators and closure bindings --- scripts/check-config-reference-contract.py | 75 ++++++++++++++++++- .../test_check_config_reference_contract.py | 35 +++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index f2370d0645..7ee23baf93 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -135,6 +135,34 @@ def _callable_name(node: ast.AST) -> str | None: _FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda +def _is_generator_function(node: _FunctionNode) -> bool: + if isinstance(node, ast.Lambda): + return False + + class Finder(ast.NodeVisitor): + found = False + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + return + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + return + + def visit_Lambda(self, node: ast.Lambda) -> None: + return + + def visit_Yield(self, node: ast.Yield) -> None: + self.found = True + + def visit_YieldFrom(self, node: ast.YieldFrom) -> None: + self.found = True + + finder = Finder() + for statement in node.body: + finder.visit(statement) + return finder.found + + @dataclass(frozen=True) class _AbstractValue: """Possible config provenance plus bounded container/object shape.""" @@ -734,6 +762,8 @@ def __init__( self._caught_exception_stack: list[tuple[_AbruptPath, ...]] = [] self._function_body_depth = 0 self._class_member_values: dict[int, tuple[_AbstractValue, ...]] = {} + self._closure_bindings: dict[int, dict[str, _AbstractValue]] = {} + self._deferred_generators: dict[int, tuple[_FunctionNode, dict[str, _AbstractValue]]] = {} self.reads: set[ConfigField] = set() def _name_value(self, name: str) -> _AbstractValue: @@ -880,6 +910,7 @@ def _call_has_relevant_provenance( ] values.extend(self._expression_value(keyword.value) for keyword in node.keywords) values.extend(self._default_argument_values(function).values()) + values.extend(self._closure_bindings.get(id(function), {}).values()) tracked = TRACKED_SECTIONS | {_CONFIG_ROOT} if any(_contained_origins(value) & tracked or value.callables for value in values): return True @@ -1322,6 +1353,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: ) return value if isinstance(node, ast.Lambda): + self._capture_function_closure(node) return _AbstractValue(callables=(_CallableTarget(node),)) if isinstance(node, ast.Attribute): owner = self._expression_value(node.value) @@ -1619,6 +1651,15 @@ def visit_BoolOp(self, node: ast.BoolOp) -> None: for value in self._reachable_bool_values(node): self.visit(value) + def _consume_deferred_generator(self, node: ast.AST) -> None: + value = self._expression_value(node) + for identity in value.identity: + deferred = self._deferred_generators.get(identity) + if deferred is None: + continue + function, scoped = deferred + self._visit_function_body(function, scoped, consume_generator=True) + def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() if isinstance(node.func, ast.Name) and node.func.id in { @@ -1635,6 +1676,8 @@ def visit_Call(self, node: ast.Call) -> None: for argument in node.args: if isinstance(argument, ast.GeneratorExp): self._visit_comprehension(argument) + else: + self._consume_deferred_generator(argument) # Evaluate once even when the call is a standalone mutating # ``setdefault`` expression. self._expression_value(node) @@ -1789,6 +1832,24 @@ def _binding_scope_index(self, name: str) -> int: return index return len(self._states) - 1 + def _capture_function_closure(self, node: _FunctionNode) -> None: + visible: dict[str, _AbstractValue] = {} + if len(self._states) <= 1: + return + for scope in self._states: + visible.update(scope) + visible = { + name: value + for name, value in visible.items() + if _contained_origins(value) & (TRACKED_SECTIONS | {_CONFIG_ROOT}) + } + if not visible: + return + existing = self._closure_bindings.get(id(node)) + self._closure_bindings[id(node)] = ( + self._join_states(existing, visible) if existing is not None else visible + ) + def visit_Global(self, node: ast.Global) -> None: self._global_names[-1].update(node.names) @@ -2031,6 +2092,8 @@ def _bind_iteration_target(self, target: ast.expr, value: _AbstractValue) -> boo def visit_For(self, node: ast.For) -> None: if isinstance(node.iter, ast.GeneratorExp): self._visit_comprehension(node.iter) + else: + self._consume_deferred_generator(node.iter) self.visit(node.iter) iterable = self._expression_value(node.iter) zero_iterations_possible = self._static_truth(node.iter) is not True @@ -2928,7 +2991,10 @@ def _visit_function_body( self, node: _FunctionNode, scoped: dict[str, _AbstractValue], + *, + consume_generator: bool = False, ) -> tuple[_AbstractValue, ...]: + scoped = {**self._closure_bindings.get(id(node), {}), **scoped} self._states.append(scoped) self._annotations.append({}) self._global_names.append(set()) @@ -2957,7 +3023,7 @@ def _visit_function_body( if isinstance(node, ast.Lambda): self.visit(node.body) return (self._expression_value(node.body),) - if any(isinstance(child, (ast.Yield, ast.YieldFrom)) for child in ast.walk(node)): + if _is_generator_function(node) and not consume_generator: return () result = self._visit_binding_branch(node.body, self._binding_snapshot()) return tuple( @@ -2981,6 +3047,10 @@ def _local_call_value( function: _FunctionNode, bound_receiver: _AbstractValue | None = None, ) -> _AbstractValue: + scoped = self._bound_call_arguments(call, function, bound_receiver) + if _is_generator_function(function): + self._deferred_generators[id(call)] = (function, scoped) + return _AbstractValue(identity=frozenset({id(call)})) function_id = id(function) if function_id in self._active_calls: return _UNKNOWN_VALUE @@ -2988,7 +3058,7 @@ def _local_call_value( try: returned = self._visit_function_body( function, - self._bound_call_arguments(call, function, bound_receiver), + scoped, ) finally: self._active_calls.remove(function_id) @@ -3119,6 +3189,7 @@ def _visit_function_definition(self, node: ast.FunctionDef | ast.AsyncFunctionDe for default in (*node.args.defaults, *node.args.kw_defaults): if default is not None: self.visit(default) + self._capture_function_closure(node) value = self._decorated_function_value(node) functions = frozenset(target.function for target in value.callables) self._states[-1][node.name] = value diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 7533cba09a..9ffd4af8d5 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -105,6 +105,41 @@ def generators(config): ) +def test_runtime_scan_tracks_consumed_generator_functions_and_closures( + contract, tmp_path: Path +) -> None: + (tmp_path / "deferred_callables.py").write_text( + """ +def reader(config): + yield config.evaluation.stage1_enabled + +list(reader(settings)) + +def make_reader(config): + section = config.evaluation + def inner(): + return section.stage2_enabled + return inner + +make_reader(settings)() + +def outer_with_nested_generator(config): + def nested(): + yield config.evaluation.stage1_enabled + return config.evaluation.stage3_enabled + +outer_with_nested_generator(settings) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + def test_runtime_scan_finds_attribute_alias_and_literal_getattr_reads( contract, tmp_path: Path ) -> None: From 879ff9ce0cb1a545bd49e71914e1286325bfc0a5 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 04:49:06 +0900 Subject: [PATCH 26/70] fix(ci): resolve generator consumers by identity --- scripts/check-config-reference-contract.py | 50 ++++++++++++---- .../test_check_config_reference_contract.py | 59 +++++++++++++++++++ 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 7ee23baf93..21eb2cc65a 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -201,6 +201,28 @@ class _CallableTarget: _ATTRGETTER_FACTORY = "" _BUILTINS_MODULE = "" _GETATTR_BUILTIN = "" +_BUILTIN_CONSUMERS = frozenset( + { + "all", + "any", + "enumerate", + "filter", + "frozenset", + "iter", + "list", + "map", + "max", + "min", + "next", + "reversed", + "set", + "sorted", + "sum", + "tuple", + "zip", + } +) +_BUILTIN_CONSUMER_ORIGINS = frozenset(f"" for name in _BUILTIN_CONSUMERS) _SECTION_ANNOTATIONS: Mapping[str, str] = { "EvaluationConfig": "evaluation", "ConsensusConfig": "consensus", @@ -1339,6 +1361,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: value = ( _origin_value(_GETATTR_BUILTIN) if node.id == "getattr" and not self._name_is_bound("getattr") + else _origin_value(f"") + if node.id in _BUILTIN_CONSUMERS and not self._name_is_bound(node.id) else self._name_value(node.id) ) if value.callables: @@ -1662,17 +1686,8 @@ def _consume_deferred_generator(self, node: ast.AST) -> None: def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() - if isinstance(node.func, ast.Name) and node.func.id in { - "all", - "any", - "list", - "max", - "min", - "next", - "set", - "sum", - "tuple", - }: + accessor = self._expression_value(node.func) + if accessor.origins & _BUILTIN_CONSUMER_ORIGINS: for argument in node.args: if isinstance(argument, ast.GeneratorExp): self._visit_comprehension(argument) @@ -1681,7 +1696,6 @@ def visit_Call(self, node: ast.Call) -> None: # Evaluate once even when the call is a standalone mutating # ``setdefault`` expression. self._expression_value(node) - accessor = self._expression_value(node.func) if accessor.accessed_attributes: for argument in node.args: value = self._expression_value( @@ -1698,6 +1712,16 @@ def visit_Call(self, node: ast.Call) -> None: self.generic_visit(node) self._record_possible_exception(before) + def visit_Starred(self, node: ast.Starred) -> None: + """Iterable unpacking eagerly consumes a deferred generator.""" + self._consume_deferred_generator(node.value) + self.generic_visit(node) + + def visit_YieldFrom(self, node: ast.YieldFrom) -> None: + """``yield from`` eagerly consumes its delegated iterable.""" + self._consume_deferred_generator(node.value) + self.generic_visit(node) + def visit_Subscript(self, node: ast.Subscript) -> None: before = self._binding_snapshot() if ( @@ -2768,6 +2792,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "operator" and alias.name == "attrgetter" else _origin_value(_GETATTR_BUILTIN) if node.module == "builtins" and alias.name == "getattr" + else _origin_value(f"") + if node.module == "builtins" and alias.name in _BUILTIN_CONSUMERS else _AbstractValue( classes=classes, modules=( diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 9ffd4af8d5..06e9f7563d 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -140,6 +140,65 @@ def nested(): assert contract.runtime_reads(tmp_path, fields) == fields +def test_runtime_scan_resolves_generator_consumers_by_builtin_identity( + contract, tmp_path: Path +) -> None: + (tmp_path / "shadowed.py").write_text( + """ +def reader(config): + yield config.evaluation.stage1_enabled + +def list(value): + return None + +list(reader(settings)) +""", + encoding="utf-8", + ) + (tmp_path / "consumed.py").write_text( + """ +from builtins import list as builtin_list + +def reader_two(config): + yield config.evaluation.stage2_enabled + +def reader_three(config): + yield config.evaluation.stage3_enabled + +consume = list +consume(reader_two(settings)) +builtin_list(reader_three(settings)) + +def delegated(config): + def inner(): + yield config.evaluation.satisfaction_threshold + yield from inner() + +list(delegated(settings)) + +def unpacked(config): + yield config.evaluation.uncertainty_threshold + +[*unpacked(settings)] +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ( + "stage1_enabled", + "stage2_enabled", + "stage3_enabled", + "satisfaction_threshold", + "uncertainty_threshold", + ) + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + fields - {contract.ConfigField("evaluation", "stage1_enabled")} + ) + + def test_runtime_scan_finds_attribute_alias_and_literal_getattr_reads( contract, tmp_path: Path ) -> None: From aa500ed3784fe661476b1a6b714752f69c2827b2 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 05:08:08 +0900 Subject: [PATCH 27/70] fix(ci): model lazy and async generator consumers --- scripts/check-config-reference-contract.py | 47 +++++++++++++---- .../test_check_config_reference_contract.py | 50 +++++++++++++++++++ 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 21eb2cc65a..d1baa28b11 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -201,28 +201,35 @@ class _CallableTarget: _ATTRGETTER_FACTORY = "" _BUILTINS_MODULE = "" _GETATTR_BUILTIN = "" -_BUILTIN_CONSUMERS = frozenset( +_EAGER_BUILTIN_CONSUMERS = frozenset( { "all", "any", - "enumerate", - "filter", "frozenset", - "iter", "list", - "map", "max", "min", "next", - "reversed", "set", "sorted", "sum", "tuple", - "zip", } ) -_BUILTIN_CONSUMER_ORIGINS = frozenset(f"" for name in _BUILTIN_CONSUMERS) +_LAZY_BUILTIN_CONSUMERS = frozenset({"enumerate", "filter", "iter", "map", "reversed", "zip"}) +_ASYNC_BUILTIN_CONSUMERS = frozenset({"anext"}) +_TRACKED_BUILTIN_CONSUMERS = ( + _EAGER_BUILTIN_CONSUMERS | _LAZY_BUILTIN_CONSUMERS | _ASYNC_BUILTIN_CONSUMERS +) +_EAGER_BUILTIN_CONSUMER_ORIGINS = frozenset( + f"" for name in _EAGER_BUILTIN_CONSUMERS +) +_LAZY_BUILTIN_CONSUMER_ORIGINS = frozenset( + f"" for name in _LAZY_BUILTIN_CONSUMERS +) +_ASYNC_BUILTIN_CONSUMER_ORIGINS = frozenset( + f"" for name in _ASYNC_BUILTIN_CONSUMERS +) _SECTION_ANNOTATIONS: Mapping[str, str] = { "EvaluationConfig": "evaluation", "ConsensusConfig": "consensus", @@ -1362,7 +1369,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: _origin_value(_GETATTR_BUILTIN) if node.id == "getattr" and not self._name_is_bound("getattr") else _origin_value(f"") - if node.id in _BUILTIN_CONSUMERS and not self._name_is_bound(node.id) + if node.id in _TRACKED_BUILTIN_CONSUMERS and not self._name_is_bound(node.id) else self._name_value(node.id) ) if value.callables: @@ -1400,6 +1407,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_ATTRGETTER_FACTORY) if _BUILTINS_MODULE in owner.origins and node.attr == "getattr": return _origin_value(_GETATTR_BUILTIN) + if _BUILTINS_MODULE in owner.origins and node.attr in _TRACKED_BUILTIN_CONSUMERS: + return _origin_value(f"") resolved_modules = { child.name for module_name in owner.modules @@ -1458,6 +1467,13 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: self._expression_cache[id(node)] = dict_method_value return dict_method_value function_value = self._expression_value(node.func) + if function_value.origins & _LAZY_BUILTIN_CONSUMER_ORIGINS: + iterable_arguments = ( + node.args[1:] if _callable_name(node.func) in {"filter", "map"} else node.args + ) + return _join_values( + *(self._expression_value(argument) for argument in iterable_arguments) + ) if _ATTRGETTER_FACTORY in function_value.origins: return _AbstractValue( accessed_attributes=frozenset( @@ -1687,7 +1703,7 @@ def _consume_deferred_generator(self, node: ast.AST) -> None: def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() accessor = self._expression_value(node.func) - if accessor.origins & _BUILTIN_CONSUMER_ORIGINS: + if accessor.origins & _EAGER_BUILTIN_CONSUMER_ORIGINS: for argument in node.args: if isinstance(argument, ast.GeneratorExp): self._visit_comprehension(argument) @@ -1712,6 +1728,15 @@ def visit_Call(self, node: ast.Call) -> None: self.generic_visit(node) self._record_possible_exception(before) + def visit_Await(self, node: ast.Await) -> None: + """Awaiting ``anext`` consumes one turn of an async generator.""" + if isinstance(node.value, ast.Call): + accessor = self._expression_value(node.value.func) + if accessor.origins & _ASYNC_BUILTIN_CONSUMER_ORIGINS: + for argument in node.value.args: + self._consume_deferred_generator(argument) + self.generic_visit(node) + def visit_Starred(self, node: ast.Starred) -> None: """Iterable unpacking eagerly consumes a deferred generator.""" self._consume_deferred_generator(node.value) @@ -2793,7 +2818,7 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: else _origin_value(_GETATTR_BUILTIN) if node.module == "builtins" and alias.name == "getattr" else _origin_value(f"") - if node.module == "builtins" and alias.name in _BUILTIN_CONSUMERS + if node.module == "builtins" and alias.name in _TRACKED_BUILTIN_CONSUMERS else _AbstractValue( classes=classes, modules=( diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 06e9f7563d..fefbe50b39 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -199,6 +199,56 @@ def unpacked(config): ) +def test_runtime_scan_defers_lazy_builtin_generator_wrappers(contract, tmp_path: Path) -> None: + (tmp_path / "lazy.py").write_text( + """ +def reader(config): + yield config.evaluation.stage1_enabled + +pending_iter = iter(reader(settings)) +pending_enumerate = enumerate(reader(settings)) +pending_filter = filter(None, reader(settings)) +pending_map = map(str, reader(settings)) +pending_reversed = reversed(reader(settings)) +pending_zip = zip(reader(settings)) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +def test_runtime_scan_consumes_qualified_and_awaited_builtins(contract, tmp_path: Path) -> None: + (tmp_path / "qualified.py").write_text( + """ +import builtins + +def sync_reader(config): + yield config.evaluation.semantic_model + +builtins.list(sync_reader(settings)) + +async def async_reader(config): + yield config.consensus.advocate_model + +async def consume_async(): + await anext(async_reader(settings)) + +consume_async() +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "semantic_model"), + contract.ConfigField("consensus", "advocate_model"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + def test_runtime_scan_finds_attribute_alias_and_literal_getattr_reads( contract, tmp_path: Path ) -> None: From 428fbe7dabf610d504a441b323b92ecb457246ba Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 05:30:39 +0900 Subject: [PATCH 28/70] fix(ci): model deferred generator consumption --- scripts/check-config-reference-contract.py | 65 ++++++++++++++--- .../test_check_config_reference_contract.py | 70 +++++++++++++++++++ 2 files changed, 124 insertions(+), 11 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index d1baa28b11..f65cb42590 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -191,6 +191,14 @@ class _CallableTarget: receiver: _AbstractValue | None = None +@dataclass(frozen=True) +class _DeferredGenerator: + """A generator body plus the bindings captured when it was created.""" + + node: _FunctionNode | ast.GeneratorExp + scoped: dict[str, _AbstractValue] + + _UNKNOWN_VALUE = _AbstractValue() _STATIC_UNKNOWN = object() @@ -792,7 +800,8 @@ def __init__( self._function_body_depth = 0 self._class_member_values: dict[int, tuple[_AbstractValue, ...]] = {} self._closure_bindings: dict[int, dict[str, _AbstractValue]] = {} - self._deferred_generators: dict[int, tuple[_FunctionNode, dict[str, _AbstractValue]]] = {} + self._deferred_generators: dict[int, _DeferredGenerator] = {} + self._one_turn_generator_depth = 0 self.reads: set[ConfigField] = set() def _name_value(self, name: str) -> _AbstractValue: @@ -941,7 +950,12 @@ def _call_has_relevant_provenance( values.extend(self._default_argument_values(function).values()) values.extend(self._closure_bindings.get(id(function), {}).values()) tracked = TRACKED_SECTIONS | {_CONFIG_ROOT} - if any(_contained_origins(value) & tracked or value.callables for value in values): + if any( + _contained_origins(value) & tracked + or value.callables + or any(identity in self._deferred_generators for identity in value.identity) + for value in values + ): return True return bound_receiver is not None and bool(_contained_origins(bound_receiver) & tracked) @@ -1351,6 +1365,9 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: cached = self._expression_cache.get(id(node)) if cached is not None: return cached + if isinstance(node, ast.GeneratorExp): + self.visit_GeneratorExp(node) + return self._expression_cache[id(node)] if isinstance(node, ast.Constant): return _AbstractValue( literal=_key_token(node), @@ -1691,24 +1708,32 @@ def visit_BoolOp(self, node: ast.BoolOp) -> None: for value in self._reachable_bool_values(node): self.visit(value) - def _consume_deferred_generator(self, node: ast.AST) -> None: + def _consume_deferred_generator(self, node: ast.AST, *, one_turn: bool = False) -> None: value = self._expression_value(node) for identity in value.identity: deferred = self._deferred_generators.get(identity) if deferred is None: continue - function, scoped = deferred - self._visit_function_body(function, scoped, consume_generator=True) + if isinstance(deferred.node, ast.GeneratorExp): + self._visit_comprehension(deferred.node) + continue + self._visit_function_body( + deferred.node, + deferred.scoped, + consume_generator=True, + one_turn=one_turn, + ) def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() accessor = self._expression_value(node.func) if accessor.origins & _EAGER_BUILTIN_CONSUMER_ORIGINS: + one_turn = "" in accessor.origins for argument in node.args: if isinstance(argument, ast.GeneratorExp): self._visit_comprehension(argument) else: - self._consume_deferred_generator(argument) + self._consume_deferred_generator(argument, one_turn=one_turn) # Evaluate once even when the call is a standalone mutating # ``setdefault`` expression. self._expression_value(node) @@ -1734,7 +1759,7 @@ def visit_Await(self, node: ast.Await) -> None: accessor = self._expression_value(node.value.func) if accessor.origins & _ASYNC_BUILTIN_CONSUMER_ORIGINS: for argument in node.value.args: - self._consume_deferred_generator(argument) + self._consume_deferred_generator(argument, one_turn=True) self.generic_visit(node) def visit_Starred(self, node: ast.Starred) -> None: @@ -2726,9 +2751,14 @@ def visit_SetComp(self, node: ast.SetComp) -> None: self._visit_comprehension(node) def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: - # Creating a generator evaluates no element expression; reads become - # reachable only when a consumer iterates it. - self._expression_cache[id(node)] = _UNKNOWN_VALUE + # Python eagerly evaluates only the outermost iterable at generator + # construction. The targets, filters, nested iterables and element + # expression stay deferred until a consumer advances the generator. + if id(node) in self._expression_cache: + return + self.visit(node.generators[0].iter) + self._deferred_generators[id(node)] = _DeferredGenerator(node, {}) + self._expression_cache[id(node)] = _AbstractValue(identity=frozenset({id(node)})) def visit_DictComp(self, node: ast.DictComp) -> None: self._visit_comprehension(node) @@ -3044,6 +3074,7 @@ def _visit_function_body( scoped: dict[str, _AbstractValue], *, consume_generator: bool = False, + one_turn: bool = False, ) -> tuple[_AbstractValue, ...]: scoped = {**self._closure_bindings.get(id(node), {}), **scoped} self._states.append(scoped) @@ -3070,6 +3101,8 @@ def _visit_function_body( self._module = self._source_index.owner(node) or self._module self._expression_cache = {} self._function_body_depth += 1 + if one_turn: + self._one_turn_generator_depth += 1 try: if isinstance(node, ast.Lambda): self.visit(node.body) @@ -3083,6 +3116,8 @@ def _visit_function_body( if path.kind == "return" ) finally: + if one_turn: + self._one_turn_generator_depth -= 1 self._function_body_depth -= 1 self._expression_cache = previous_cache self._module = previous_module @@ -3100,7 +3135,7 @@ def _local_call_value( ) -> _AbstractValue: scoped = self._bound_call_arguments(call, function, bound_receiver) if _is_generator_function(function): - self._deferred_generators[id(call)] = (function, scoped) + self._deferred_generators[id(call)] = _DeferredGenerator(function, scoped) return _AbstractValue(identity=frozenset({id(call)})) function_id = id(function) if function_id in self._active_calls: @@ -3143,6 +3178,14 @@ def visit_Return(self, node: ast.Return) -> None: ) self._path_reachable = False + def visit_Yield(self, node: ast.Yield) -> None: + if node.value is not None: + self.visit(node.value) + if self._one_turn_generator_depth: + # ``next``/awaited ``anext`` suspends immediately after the first + # reached yield; later statements have not executed yet. + self._path_reachable = False + def visit_Raise(self, node: ast.Raise) -> None: if node.exc is not None: exception_name = self._exception_name(node.exc) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index fefbe50b39..f348a54891 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -105,6 +105,76 @@ def generators(config): ) +def test_runtime_scan_evaluates_generator_expression_outer_iterable_eagerly( + contract, tmp_path: Path +) -> None: + (tmp_path / "generator_outer_iter.py").write_text( + "pending = (item for item in settings.evaluation.stage1_enabled)\n", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_propagates_generator_expression_into_local_consumer( + contract, tmp_path: Path +) -> None: + (tmp_path / "generator_consumer.py").write_text( + """ +def consume(items): + for _ in items: + pass + +consume(settings.evaluation.stage2_enabled for _ in [1]) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage2_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_one_turn_generator_consumers_stop_at_first_yield( + contract, tmp_path: Path +) -> None: + (tmp_path / "one_turn.py").write_text( + """ +def reader(config): + yield config.evaluation.stage1_enabled + yield config.evaluation.stage2_enabled + +async def async_reader(config): + yield config.evaluation.stage3_enabled + yield config.evaluation.satisfaction_threshold + +next(reader(settings)) + +async def advance_once(): + await anext(async_reader(settings)) + +advance_once() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ( + "stage1_enabled", + "stage2_enabled", + "stage3_enabled", + "satisfaction_threshold", + ) + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + } + ) + + def test_runtime_scan_tracks_consumed_generator_functions_and_closures( contract, tmp_path: Path ) -> None: From 5ec1066b842df9fddd5f65b4d553246d982a4c4f Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 05:47:49 +0900 Subject: [PATCH 29/70] fix(ci): model short-circuit generator advancement --- scripts/check-config-reference-contract.py | 62 ++++++++++---- .../test_check_config_reference_contract.py | 82 +++++++++++++++++++ 2 files changed, 130 insertions(+), 14 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index f65cb42590..a71c7105a6 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -801,7 +801,7 @@ def __init__( self._class_member_values: dict[int, tuple[_AbstractValue, ...]] = {} self._closure_bindings: dict[int, dict[str, _AbstractValue]] = {} self._deferred_generators: dict[int, _DeferredGenerator] = {} - self._one_turn_generator_depth = 0 + self._generator_consumer_modes: list[str] = [] self.reads: set[ConfigField] = set() def _name_value(self, name: str) -> _AbstractValue: @@ -1708,7 +1708,7 @@ def visit_BoolOp(self, node: ast.BoolOp) -> None: for value in self._reachable_bool_values(node): self.visit(value) - def _consume_deferred_generator(self, node: ast.AST, *, one_turn: bool = False) -> None: + def _consume_deferred_generator(self, node: ast.AST, *, mode: str = "full") -> None: value = self._expression_value(node) for identity in value.identity: deferred = self._deferred_generators.get(identity) @@ -1721,19 +1721,35 @@ def _consume_deferred_generator(self, node: ast.AST, *, one_turn: bool = False) deferred.node, deferred.scoped, consume_generator=True, - one_turn=one_turn, + generator_consumer_mode=mode, ) def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() accessor = self._expression_value(node.func) if accessor.origins & _EAGER_BUILTIN_CONSUMER_ORIGINS: - one_turn = "" in accessor.origins + mode = ( + "one_turn" + if "" in accessor.origins + else "any" + if "" in accessor.origins + else "all" + if "" in accessor.origins + else "full" + ) for argument in node.args: if isinstance(argument, ast.GeneratorExp): self._visit_comprehension(argument) else: - self._consume_deferred_generator(argument, one_turn=one_turn) + self._consume_deferred_generator(argument, mode=mode) + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "send" + and node.args + and isinstance(node.args[0], ast.Constant) + and node.args[0].value is None + ): + self._consume_deferred_generator(node.func.value, mode="one_turn") # Evaluate once even when the call is a standalone mutating # ``setdefault`` expression. self._expression_value(node) @@ -1759,7 +1775,15 @@ def visit_Await(self, node: ast.Await) -> None: accessor = self._expression_value(node.value.func) if accessor.origins & _ASYNC_BUILTIN_CONSUMER_ORIGINS: for argument in node.value.args: - self._consume_deferred_generator(argument, one_turn=True) + self._consume_deferred_generator(argument, mode="one_turn") + elif ( + isinstance(node.value.func, ast.Attribute) + and node.value.func.attr == "asend" + and node.value.args + and isinstance(node.value.args[0], ast.Constant) + and node.value.args[0].value is None + ): + self._consume_deferred_generator(node.value.func.value, mode="one_turn") self.generic_visit(node) def visit_Starred(self, node: ast.Starred) -> None: @@ -3074,7 +3098,7 @@ def _visit_function_body( scoped: dict[str, _AbstractValue], *, consume_generator: bool = False, - one_turn: bool = False, + generator_consumer_mode: str = "full", ) -> tuple[_AbstractValue, ...]: scoped = {**self._closure_bindings.get(id(node), {}), **scoped} self._states.append(scoped) @@ -3101,8 +3125,8 @@ def _visit_function_body( self._module = self._source_index.owner(node) or self._module self._expression_cache = {} self._function_body_depth += 1 - if one_turn: - self._one_turn_generator_depth += 1 + if generator_consumer_mode != "full": + self._generator_consumer_modes.append(generator_consumer_mode) try: if isinstance(node, ast.Lambda): self.visit(node.body) @@ -3116,8 +3140,8 @@ def _visit_function_body( if path.kind == "return" ) finally: - if one_turn: - self._one_turn_generator_depth -= 1 + if generator_consumer_mode != "full": + self._generator_consumer_modes.pop() self._function_body_depth -= 1 self._expression_cache = previous_cache self._module = previous_module @@ -3181,9 +3205,19 @@ def visit_Return(self, node: ast.Return) -> None: def visit_Yield(self, node: ast.Yield) -> None: if node.value is not None: self.visit(node.value) - if self._one_turn_generator_depth: - # ``next``/awaited ``anext`` suspends immediately after the first - # reached yield; later statements have not executed yet. + if not self._generator_consumer_modes: + return + mode = self._generator_consumer_modes[-1] + truth = self._static_truth(node.value) if node.value is not None else False + if ( + mode == "one_turn" + or mode == "any" + and truth is True + or mode == "all" + and truth is False + ): + # One-turn APIs always suspend. ``any`` and ``all`` suspend once a + # statically decisive yielded value short-circuits the consumer. self._path_reachable = False def visit_Raise(self, node: ast.Raise) -> None: diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index f348a54891..704a4ad6d3 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -157,6 +157,88 @@ async def advance_once(): """, encoding="utf-8", ) + + +def test_runtime_scan_models_any_and_all_short_circuiting(contract, tmp_path: Path) -> None: + (tmp_path / "short_circuit.py").write_text( + """ +def any_stops(config): + yield True + yield config.evaluation.stage1_enabled + +def any_continues(config): + yield False + yield config.evaluation.stage2_enabled + +def all_stops(config): + yield False + yield config.evaluation.stage3_enabled + +def all_continues(config): + yield True + yield config.evaluation.satisfaction_threshold + +any(any_stops(settings)) +any(any_continues(settings)) +all(all_stops(settings)) +all(all_continues(settings)) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ( + "stage1_enabled", + "stage2_enabled", + "stage3_enabled", + "satisfaction_threshold", + ) + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "satisfaction_threshold"), + } + ) + + +def test_runtime_scan_send_and_asend_advance_exactly_one_turn(contract, tmp_path: Path) -> None: + (tmp_path / "send_once.py").write_text( + """ +def reader(config): + yield config.evaluation.stage1_enabled + yield config.evaluation.stage2_enabled + +async def async_reader(config): + yield config.evaluation.stage3_enabled + yield config.evaluation.satisfaction_threshold + +reader(settings).send(None) + +async def advance_once(): + await async_reader(settings).asend(None) + +advance_once() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ( + "stage1_enabled", + "stage2_enabled", + "stage3_enabled", + "satisfaction_threshold", + ) + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + } + ) fields = frozenset( contract.ConfigField("evaluation", name) for name in ( From b0c466fa5022ebe07bd4832b13a41ae146717285 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 06:06:45 +0900 Subject: [PATCH 30/70] fix(ci): persist generator continuation state --- scripts/check-config-reference-contract.py | 25 +++- .../test_check_config_reference_contract.py | 110 ++++++++++++++++-- 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index a71c7105a6..0857d088fd 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -801,7 +801,10 @@ def __init__( self._class_member_values: dict[int, tuple[_AbstractValue, ...]] = {} self._closure_bindings: dict[int, dict[str, _AbstractValue]] = {} self._deferred_generators: dict[int, _DeferredGenerator] = {} + self._deferred_generator_positions: dict[int, int] = {} self._generator_consumer_modes: list[str] = [] + self._generator_skip_yields: list[int] = [] + self._generator_advanced_yields: list[int] = [] self.reads: set[ConfigField] = set() def _name_value(self, name: str) -> _AbstractValue: @@ -1722,6 +1725,7 @@ def _consume_deferred_generator(self, node: ast.AST, *, mode: str = "full") -> N deferred.scoped, consume_generator=True, generator_consumer_mode=mode, + generator_identity=identity, ) def visit_Call(self, node: ast.Call) -> None: @@ -2191,7 +2195,8 @@ def visit_For(self, node: ast.For) -> None: if isinstance(node.iter, ast.GeneratorExp): self._visit_comprehension(node.iter) else: - self._consume_deferred_generator(node.iter) + mode = "one_turn" if node.body and isinstance(node.body[0], ast.Break) else "full" + self._consume_deferred_generator(node.iter, mode=mode) self.visit(node.iter) iterable = self._expression_value(node.iter) zero_iterations_possible = self._static_truth(node.iter) is not True @@ -3099,6 +3104,7 @@ def _visit_function_body( *, consume_generator: bool = False, generator_consumer_mode: str = "full", + generator_identity: int | None = None, ) -> tuple[_AbstractValue, ...]: scoped = {**self._closure_bindings.get(id(node), {}), **scoped} self._states.append(scoped) @@ -3125,8 +3131,12 @@ def _visit_function_body( self._module = self._source_index.owner(node) or self._module self._expression_cache = {} self._function_body_depth += 1 - if generator_consumer_mode != "full": + if generator_identity is not None: self._generator_consumer_modes.append(generator_consumer_mode) + self._generator_skip_yields.append( + self._deferred_generator_positions.get(generator_identity, 0) + ) + self._generator_advanced_yields.append(0) try: if isinstance(node, ast.Lambda): self.visit(node.body) @@ -3140,7 +3150,12 @@ def _visit_function_body( if path.kind == "return" ) finally: - if generator_consumer_mode != "full": + if generator_identity is not None: + advanced = self._generator_advanced_yields.pop() + self._deferred_generator_positions[generator_identity] = ( + self._deferred_generator_positions.get(generator_identity, 0) + advanced + ) + self._generator_skip_yields.pop() self._generator_consumer_modes.pop() self._function_body_depth -= 1 self._expression_cache = previous_cache @@ -3203,10 +3218,14 @@ def visit_Return(self, node: ast.Return) -> None: self._path_reachable = False def visit_Yield(self, node: ast.Yield) -> None: + if self._generator_skip_yields and self._generator_skip_yields[-1] > 0: + self._generator_skip_yields[-1] -= 1 + return if node.value is not None: self.visit(node.value) if not self._generator_consumer_modes: return + self._generator_advanced_yields[-1] += 1 mode = self._generator_consumer_modes[-1] truth = self._static_truth(node.value) if node.value is not None else False if ( diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 704a4ad6d3..b5f1ad8cc9 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -157,6 +157,22 @@ async def advance_once(): """, encoding="utf-8", ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ( + "stage1_enabled", + "stage2_enabled", + "stage3_enabled", + "satisfaction_threshold", + ) + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + } + ) def test_runtime_scan_models_any_and_all_short_circuiting(contract, tmp_path: Path) -> None: @@ -239,21 +255,95 @@ async def advance_once(): contract.ConfigField("evaluation", "stage3_enabled"), } ) + + +def test_runtime_scan_preserves_generator_continuation_across_partial_consumers( + contract, tmp_path: Path +) -> None: + (tmp_path / "continuations.py").write_text( + """ +def next_reader(config): + yield 0 + yield config.evaluation.stage1_enabled + +def send_reader(config): + yield 0 + yield config.evaluation.stage2_enabled + +def any_reader(config): + yield True + yield config.evaluation.stage3_enabled + +first = next_reader(settings) +next(first) +next(first) + +second = send_reader(settings) +second.send(None) +second.send(None) + +third = any_reader(settings) +any(third) +next(third) +""", + encoding="utf-8", + ) fields = frozenset( contract.ConfigField("evaluation", name) - for name in ( - "stage1_enabled", - "stage2_enabled", - "stage3_enabled", - "satisfaction_threshold", - ) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_preserves_async_generator_continuation(contract, tmp_path: Path) -> None: + (tmp_path / "async_continuation.py").write_text( + """ +async def reader(config): + yield 0 + yield config.evaluation.satisfaction_threshold + +async def advance(): + stream = reader(settings) + await stream.asend(None) + await stream.asend(None) + +advance() +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "satisfaction_threshold") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_loop_break_consumes_only_first_generator_turn( + contract, tmp_path: Path +) -> None: + (tmp_path / "break_once.py").write_text( + """ +def read_after(config): + yield 0 + yield config.evaluation.stage1_enabled + +def read_before(config): + yield config.evaluation.stage2_enabled + yield 0 + +for value in read_after(settings): + break + +for value in read_before(settings): + break +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") ) assert contract.runtime_reads(tmp_path, fields) == frozenset( - { - contract.ConfigField("evaluation", "stage1_enabled"), - contract.ConfigField("evaluation", "stage3_enabled"), - } + {contract.ConfigField("evaluation", "stage2_enabled")} ) From dac18d89a87465ac9fe2934dd8e45c673a33f04f Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 06:34:07 +0900 Subject: [PATCH 31/70] fix(config): model deferred coroutine and iterator consumers --- scripts/check-config-reference-contract.py | 103 +++++++++++++++++- .../test_check_config_reference_contract.py | 69 ++++++++++++ 2 files changed, 166 insertions(+), 6 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 0857d088fd..909384a36e 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -199,6 +199,14 @@ class _DeferredGenerator: scoped: dict[str, _AbstractValue] +@dataclass(frozen=True) +class _DeferredCoroutine: + """An async function body, deferred until its coroutine is awaited.""" + + node: ast.AsyncFunctionDef + scoped: dict[str, _AbstractValue] + + _UNKNOWN_VALUE = _AbstractValue() _STATIC_UNKNOWN = object() @@ -232,6 +240,8 @@ class _DeferredGenerator: _EAGER_BUILTIN_CONSUMER_ORIGINS = frozenset( f"" for name in _EAGER_BUILTIN_CONSUMERS ) +_EAGER_EXTERNAL_CONSUMER_ORIGINS = frozenset({""}) +_EAGER_CONSUMER_ORIGINS = _EAGER_BUILTIN_CONSUMER_ORIGINS | _EAGER_EXTERNAL_CONSUMER_ORIGINS _LAZY_BUILTIN_CONSUMER_ORIGINS = frozenset( f"" for name in _LAZY_BUILTIN_CONSUMERS ) @@ -801,6 +811,7 @@ def __init__( self._class_member_values: dict[int, tuple[_AbstractValue, ...]] = {} self._closure_bindings: dict[int, dict[str, _AbstractValue]] = {} self._deferred_generators: dict[int, _DeferredGenerator] = {} + self._deferred_coroutines: dict[int, _DeferredCoroutine] = {} self._deferred_generator_positions: dict[int, int] = {} self._generator_consumer_modes: list[str] = [] self._generator_skip_yields: list[int] = [] @@ -1351,6 +1362,15 @@ def _static_truth(self, node: ast.AST) -> bool | None: test = self._static_truth(node.test) if test is not None: return self._static_truth(node.body if test else node.orelse) + if isinstance(node, ast.Compare) and len(node.ops) == 1 and len(node.comparators) == 1: + left = self._expression_value(node.left) + right = self._expression_value(node.comparators[0]) + if left.literal is not None and right.literal is not None: + equal = left.literal == right.literal + if isinstance(node.ops[0], ast.Eq): + return equal + if isinstance(node.ops[0], ast.NotEq): + return not equal return None def _reachable_bool_values(self, node: ast.BoolOp) -> tuple[ast.expr, ...]: @@ -1429,6 +1449,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_GETATTR_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr in _TRACKED_BUILTIN_CONSUMERS: return _origin_value(f"") + if node.attr == "deque" and "collections" in owner.modules: + return _origin_value("") resolved_modules = { child.name for module_name in owner.modules @@ -1718,7 +1740,7 @@ def _consume_deferred_generator(self, node: ast.AST, *, mode: str = "full") -> N if deferred is None: continue if isinstance(deferred.node, ast.GeneratorExp): - self._visit_comprehension(deferred.node) + self._consume_generator_expression(deferred.node, identity, mode=mode) continue self._visit_function_body( deferred.node, @@ -1728,10 +1750,17 @@ def _consume_deferred_generator(self, node: ast.AST, *, mode: str = "full") -> N generator_identity=identity, ) + def _consume_deferred_coroutine(self, node: ast.AST) -> None: + value = self._expression_value(node) + for identity in value.identity: + deferred = self._deferred_coroutines.get(identity) + if deferred is not None: + self._visit_function_body(deferred.node, deferred.scoped) + def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() accessor = self._expression_value(node.func) - if accessor.origins & _EAGER_BUILTIN_CONSUMER_ORIGINS: + if accessor.origins & _EAGER_CONSUMER_ORIGINS: mode = ( "one_turn" if "" in accessor.origins @@ -1742,10 +1771,7 @@ def visit_Call(self, node: ast.Call) -> None: else "full" ) for argument in node.args: - if isinstance(argument, ast.GeneratorExp): - self._visit_comprehension(argument) - else: - self._consume_deferred_generator(argument, mode=mode) + self._consume_deferred_generator(argument, mode=mode) if ( isinstance(node.func, ast.Attribute) and node.func.attr == "send" @@ -1775,6 +1801,7 @@ def visit_Call(self, node: ast.Call) -> None: def visit_Await(self, node: ast.Await) -> None: """Awaiting ``anext`` consumes one turn of an async generator.""" + self._consume_deferred_coroutine(node.value) if isinstance(node.value, ast.Call): accessor = self._expression_value(node.value.func) if accessor.origins & _ASYNC_BUILTIN_CONSUMER_ORIGINS: @@ -2773,6 +2800,63 @@ def _visit_comprehension( self._annotations.pop() self._states.pop() + def _consume_generator_expression( + self, node: ast.GeneratorExp, identity: int, *, mode: str + ) -> None: + """Advance one generator-expression identity with runtime consumption semantics.""" + first, *remaining = node.generators + candidates = self._iteration_values(self._expression_value(first.iter)) + start = self._deferred_generator_positions.get(identity, 0) + consumed = 0 + for candidate in candidates[start:]: + consumed += 1 + self._states.append({}) + self._annotations.append({}) + self._functions.append({}) + self._global_names.append(set()) + self._nonlocal_names.append(set()) + yielded = True + try: + self._bind_destructured(first.target, candidate) + for condition in first.ifs: + self.visit(condition) + if self._static_truth(condition) is False: + yielded = False + break + if yielded: + for generator in remaining: + self.visit(generator.iter) + if not self._bind_iteration_target( + generator.target, self._expression_value(generator.iter) + ): + yielded = False + break + for condition in generator.ifs: + self.visit(condition) + if self._static_truth(condition) is False: + yielded = False + break + if not yielded: + break + if yielded: + self.visit(node.elt) + truth = self._static_truth(node.elt) + finally: + self._nonlocal_names.pop() + self._global_names.pop() + self._functions.pop() + self._annotations.pop() + self._states.pop() + if yielded and ( + mode == "one_turn" + or mode == "any" + and truth is True + or mode == "all" + and truth is False + ): + break + self._deferred_generator_positions[identity] = start + consumed + def visit_ListComp(self, node: ast.ListComp) -> None: self._visit_comprehension(node) @@ -2841,6 +2925,8 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: if alias.name == "operator" else _origin_value(_BUILTINS_MODULE) if alias.name == "builtins" + else _AbstractValue(modules=frozenset({"collections"})) + if alias.name == "collections" else _AbstractValue( modules=frozenset({module.name}) if module is not None else frozenset() ) @@ -2878,6 +2964,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "builtins" and alias.name == "getattr" else _origin_value(f"") if node.module == "builtins" and alias.name in _TRACKED_BUILTIN_CONSUMERS + else _origin_value("") + if node.module == "collections" and alias.name == "deque" else _AbstractValue( classes=classes, modules=( @@ -3176,6 +3264,9 @@ def _local_call_value( if _is_generator_function(function): self._deferred_generators[id(call)] = _DeferredGenerator(function, scoped) return _AbstractValue(identity=frozenset({id(call)})) + if isinstance(function, ast.AsyncFunctionDef): + self._deferred_coroutines[id(call)] = _DeferredCoroutine(function, scoped) + return _AbstractValue(identity=frozenset({id(call)})) function_id = id(function) if function_id in self._active_calls: return _UNKNOWN_VALUE diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index b5f1ad8cc9..63c1f06ab6 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -105,6 +105,75 @@ def generators(config): ) +def test_runtime_scan_defers_coroutine_body_until_await(contract, tmp_path: Path) -> None: + (tmp_path / "coroutines.py").write_text( + """ +async def runtime(config): + async def ignored(): + return config.evaluation.stage1_enabled + async def consumed(): + return config.evaluation.stage2_enabled + ignored() + return await consumed() + +await runtime(settings) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage2_enabled")} + ) + + +def test_runtime_scan_generator_expression_next_advances_one_item(contract, tmp_path: Path) -> None: + (tmp_path / "generator_expression_next.py").write_text( + """ +def runtime(settings): + generated = ( + settings.evaluation.stage1_enabled + if index == 0 + else settings.evaluation.stage2_enabled + for index in [0, 1] + ) + next(generated) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage1_enabled")} + ) + + +@pytest.mark.parametrize("import_form", ("from collections import deque", "import collections")) +def test_runtime_scan_recognizes_collections_deque_as_eager_consumer( + contract, tmp_path: Path, import_form: str +) -> None: + call = ( + "deque(generated, maxlen=0)" + if import_form.startswith("from") + else ("collections.deque(generated, maxlen=0)") + ) + (tmp_path / "deque_consumer.py").write_text( + f""" +{import_form} +generated = (settings.evaluation.stage2_enabled for _ in [1]) +{call} +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage2_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + def test_runtime_scan_evaluates_generator_expression_outer_iterable_eagerly( contract, tmp_path: Path ) -> None: From 164c6cc34bdc8791a28470c6ef8dc38c264b298b Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 06:59:21 +0900 Subject: [PATCH 32/70] fix(config): model callback and coroutine consumers --- scripts/check-config-reference-contract.py | 84 +++++++++++++++++++ .../test_check_config_reference_contract.py | 74 ++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 909384a36e..713a9be069 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -207,6 +207,14 @@ class _DeferredCoroutine: scoped: dict[str, _AbstractValue] +@dataclass(frozen=True) +class _DeferredCallableIterator: + """A lazy map/filter adapter and the values captured at construction.""" + + callbacks: tuple[_CallableTarget, ...] + iterables: tuple[_AbstractValue, ...] + + _UNKNOWN_VALUE = _AbstractValue() _STATIC_UNKNOWN = object() @@ -217,6 +225,11 @@ class _DeferredCoroutine: _ATTRGETTER_FACTORY = "" _BUILTINS_MODULE = "" _GETATTR_BUILTIN = "" +_FUNCTOOLS_MODULE = "" +_PARTIAL_FACTORY = "" +_ASYNCIO_MODULE = "" +_ASYNCIO_CONSUMERS = frozenset({"create_task", "ensure_future", "gather", "run"}) +_ASYNCIO_CONSUMER_ORIGINS = frozenset(f"" for name in _ASYNCIO_CONSUMERS) _EAGER_BUILTIN_CONSUMERS = frozenset( { "all", @@ -812,6 +825,7 @@ def __init__( self._closure_bindings: dict[int, dict[str, _AbstractValue]] = {} self._deferred_generators: dict[int, _DeferredGenerator] = {} self._deferred_coroutines: dict[int, _DeferredCoroutine] = {} + self._deferred_callable_iterators: dict[int, _DeferredCallableIterator] = {} self._deferred_generator_positions: dict[int, int] = {} self._generator_consumer_modes: list[str] = [] self._generator_skip_yields: list[int] = [] @@ -1449,6 +1463,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_GETATTR_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr in _TRACKED_BUILTIN_CONSUMERS: return _origin_value(f"") + if _FUNCTOOLS_MODULE in owner.origins and node.attr == "partial": + return _origin_value(_PARTIAL_FACTORY) + if _ASYNCIO_MODULE in owner.origins and node.attr in _ASYNCIO_CONSUMERS: + return _origin_value(f"") if node.attr == "deque" and "collections" in owner.modules: return _origin_value("") resolved_modules = { @@ -1513,9 +1531,32 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: iterable_arguments = ( node.args[1:] if _callable_name(node.func) in {"filter", "map"} else node.args ) + if ( + _callable_name(node.func) in {"filter", "map"} + and node.args + and (callbacks := self._call_targets(node.args[0])) + ): + identity = id(node) + self._deferred_callable_iterators[identity] = _DeferredCallableIterator( + callbacks=tuple( + _CallableTarget(function, receiver) for function, receiver in callbacks + ), + iterables=tuple( + self._expression_value(argument) for argument in iterable_arguments + ), + ) + return _AbstractValue(identity=frozenset({identity})) return _join_values( *(self._expression_value(argument) for argument in iterable_arguments) ) + if _PARTIAL_FACTORY in function_value.origins and len(node.args) >= 2: + receiver = self._expression_value(node.args[1]) + return _AbstractValue( + callables=tuple( + _CallableTarget(function, receiver) + for function, _ in self._call_targets(node.args[0]) + ) + ) if _ATTRGETTER_FACTORY in function_value.origins: return _AbstractValue( accessed_attributes=frozenset( @@ -1757,6 +1798,37 @@ def _consume_deferred_coroutine(self, node: ast.AST) -> None: if deferred is not None: self._visit_function_body(deferred.node, deferred.scoped) + def _consume_deferred_callable_iterator(self, node: ast.AST, *, mode: str = "full") -> None: + """Execute callbacks only when their lazy map/filter is consumed.""" + + for identity in self._expression_value(node).identity: + deferred = self._deferred_callable_iterators.get(identity) + if deferred is None: + continue + if any( + iterable.items == () or iterable.truth is False for iterable in deferred.iterables + ): + continue + item_groups = [iterable.items for iterable in deferred.iterables] + if all(items is not None for items in item_groups): + count = min(len(items) for items in item_groups if items is not None) + if mode == "one_turn": + count = min(count, 1) + argument_sets = tuple( + tuple(items[index] for items in item_groups if items is not None) + for index in range(count) + ) + else: + argument_sets = ( + tuple(_conservative_value(iterable) for iterable in deferred.iterables), + ) + for arguments in argument_sets: + for target in deferred.callbacks: + values = ( + (target.receiver, *arguments) if target.receiver is not None else arguments + ) + self._local_direct_call_value(target.function, values) + def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() accessor = self._expression_value(node.func) @@ -1772,6 +1844,10 @@ def visit_Call(self, node: ast.Call) -> None: ) for argument in node.args: self._consume_deferred_generator(argument, mode=mode) + self._consume_deferred_callable_iterator(argument, mode=mode) + if accessor.origins & _ASYNCIO_CONSUMER_ORIGINS: + for argument in node.args: + self._consume_deferred_coroutine(argument) if ( isinstance(node.func, ast.Attribute) and node.func.attr == "send" @@ -2925,6 +3001,10 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: if alias.name == "operator" else _origin_value(_BUILTINS_MODULE) if alias.name == "builtins" + else _origin_value(_FUNCTOOLS_MODULE) + if alias.name == "functools" + else _origin_value(_ASYNCIO_MODULE) + if alias.name == "asyncio" else _AbstractValue(modules=frozenset({"collections"})) if alias.name == "collections" else _AbstractValue( @@ -2964,6 +3044,10 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "builtins" and alias.name == "getattr" else _origin_value(f"") if node.module == "builtins" and alias.name in _TRACKED_BUILTIN_CONSUMERS + else _origin_value(_PARTIAL_FACTORY) + if node.module == "functools" and alias.name == "partial" + else _origin_value(f"") + if node.module == "asyncio" and alias.name in _ASYNCIO_CONSUMERS else _origin_value("") if node.module == "collections" and alias.name == "deque" else _AbstractValue( diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 63c1f06ab6..ab3f2b19cd 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -129,6 +129,80 @@ async def consumed(): ) +def test_runtime_scan_executes_map_callback_only_when_eagerly_consumed( + contract, tmp_path: Path +) -> None: + (tmp_path / "map_callback.py").write_text( + """ +def read(section): + return section.stage1_enabled + +pending = map(read, [settings.evaluation]) +list(pending) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_does_not_execute_unconsumed_or_empty_map_callbacks( + contract, tmp_path: Path +) -> None: + (tmp_path / "map_callback_negative.py").write_text( + """ +def read(section): + return section.stage1_enabled + +pending = map(read, [settings.evaluation]) +consumed_empty = list(map(read, [])) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +def test_runtime_scan_tracks_functools_partial_callable_provenance( + contract, tmp_path: Path +) -> None: + (tmp_path / "partial_callback.py").write_text( + """ +import functools + +def read(section): + return section.stage1_enabled + +runner = functools.partial(read, settings.evaluation) +runner() +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_consumes_coroutines_scheduled_by_asyncio_gather( + contract, tmp_path: Path +) -> None: + (tmp_path / "asyncio_scheduler.py").write_text( + """ +import asyncio + +async def read(config): + return config.evaluation.stage1_enabled + +async def main(): + await asyncio.gather(read(settings)) + +asyncio.run(main()) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + def test_runtime_scan_generator_expression_next_advances_one_item(contract, tmp_path: Path) -> None: (tmp_path / "generator_expression_next.py").write_text( """ From 96a8ad39daea002997ef485e1b7ba3eaacb7e228 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 07:23:17 +0900 Subject: [PATCH 33/70] fix(config): preserve partial and serializer provenance --- scripts/check-config-reference-contract.py | 88 +++++++++++++++---- .../test_check_config_reference_contract.py | 47 ++++++++++ 2 files changed, 119 insertions(+), 16 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 713a9be069..7b95aec350 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -215,6 +215,15 @@ class _DeferredCallableIterator: iterables: tuple[_AbstractValue, ...] +@dataclass(frozen=True) +class _DeferredPartial: + """A local callable with positional and keyword arguments pre-applied.""" + + targets: tuple[_CallableTarget, ...] + positional: tuple[_AbstractValue, ...] + keywords: tuple[tuple[str, _AbstractValue], ...] + + _UNKNOWN_VALUE = _AbstractValue() _STATIC_UNKNOWN = object() @@ -225,6 +234,7 @@ class _DeferredCallableIterator: _ATTRGETTER_FACTORY = "" _BUILTINS_MODULE = "" _GETATTR_BUILTIN = "" +_VARS_BUILTIN = "" _FUNCTOOLS_MODULE = "" _PARTIAL_FACTORY = "" _ASYNCIO_MODULE = "" @@ -826,6 +836,7 @@ def __init__( self._deferred_generators: dict[int, _DeferredGenerator] = {} self._deferred_coroutines: dict[int, _DeferredCoroutine] = {} self._deferred_callable_iterators: dict[int, _DeferredCallableIterator] = {} + self._deferred_partials: dict[int, _DeferredPartial] = {} self._deferred_generator_positions: dict[int, int] = {} self._generator_consumer_modes: list[str] = [] self._generator_skip_yields: list[int] = [] @@ -1422,6 +1433,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: value = ( _origin_value(_GETATTR_BUILTIN) if node.id == "getattr" and not self._name_is_bound("getattr") + else _origin_value(_VARS_BUILTIN) + if node.id == "vars" and not self._name_is_bound("vars") else _origin_value(f"") if node.id in _TRACKED_BUILTIN_CONSUMERS and not self._name_is_bound(node.id) else self._name_value(node.id) @@ -1461,6 +1474,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_ATTRGETTER_FACTORY) if _BUILTINS_MODULE in owner.origins and node.attr == "getattr": return _origin_value(_GETATTR_BUILTIN) + if _BUILTINS_MODULE in owner.origins and node.attr == "vars": + return _origin_value(_VARS_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr in _TRACKED_BUILTIN_CONSUMERS: return _origin_value(f"") if _FUNCTOOLS_MODULE in owner.origins and node.attr == "partial": @@ -1469,6 +1484,9 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(f"") if node.attr == "deque" and "collections" in owner.modules: return _origin_value("") + sections = owner.origins & TRACKED_SECTIONS + if node.attr == "model_dump" and sections: + return _AbstractValue(serialized_sections=sections) resolved_modules = { child.name for module_name in owner.modules @@ -1549,14 +1567,21 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _join_values( *(self._expression_value(argument) for argument in iterable_arguments) ) - if _PARTIAL_FACTORY in function_value.origins and len(node.args) >= 2: - receiver = self._expression_value(node.args[1]) - return _AbstractValue( - callables=tuple( + if _PARTIAL_FACTORY in function_value.origins and node.args: + identity = id(node) + self._deferred_partials[identity] = _DeferredPartial( + targets=tuple( _CallableTarget(function, receiver) - for function, _ in self._call_targets(node.args[0]) - ) + for function, receiver in self._call_targets(node.args[0]) + ), + positional=tuple(self._expression_value(arg) for arg in node.args[1:]), + keywords=tuple( + (keyword.arg, self._expression_value(keyword.value)) + for keyword in node.keywords + if keyword.arg is not None + ), ) + return _AbstractValue(identity=frozenset({identity})) if _ATTRGETTER_FACTORY in function_value.origins: return _AbstractValue( accessed_attributes=frozenset( @@ -1565,19 +1590,19 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if isinstance(argument, ast.Constant) and isinstance(argument.value, str) ) ) - if isinstance(node.func, ast.Attribute) and node.func.attr == "model_dump": - sections = self._expression_value(node.func.value).origins & TRACKED_SECTIONS - if sections: - return _AbstractValue(serialized_sections=sections) - if ( - isinstance(node.func, ast.Name) - and node.func.id == "vars" - and not self._name_is_bound("vars") - and node.args - ): + if function_value.serialized_sections: + return _AbstractValue(serialized_sections=function_value.serialized_sections) + if _VARS_BUILTIN in function_value.origins and node.args: sections = self._expression_value(node.args[0]).origins & TRACKED_SECTIONS if sections: return _AbstractValue(serialized_sections=sections) + partial_values = [ + self._partial_call_value(node, partial) + for identity in function_value.identity + if (partial := self._deferred_partials.get(identity)) is not None + ] + if partial_values: + return _join_values(*partial_values) callable_name = _callable_name(node.func) if callable_name is not None and _CONFIG_FACTORY.search(callable_name): if isinstance(node.func, ast.Name): @@ -3042,6 +3067,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "operator" and alias.name == "attrgetter" else _origin_value(_GETATTR_BUILTIN) if node.module == "builtins" and alias.name == "getattr" + else _origin_value(_VARS_BUILTIN) + if node.module == "builtins" and alias.name == "vars" else _origin_value(f"") if node.module == "builtins" and alias.name in _TRACKED_BUILTIN_CONSUMERS else _origin_value(_PARTIAL_FACTORY) @@ -3382,6 +3409,35 @@ def _local_direct_call_value( self._active_calls.remove(function_id) return _join_values(*returned) + def _partial_call_value(self, call: ast.Call, partial: _DeferredPartial) -> _AbstractValue: + """Execute a partial with every pre-bound positional/keyword argument.""" + values = (*partial.positional, *(self._expression_value(arg) for arg in call.args)) + results: list[_AbstractValue] = [] + for target in partial.targets: + function_id = id(target.function) + if function_id in self._active_calls: + continue + scoped = self._bound_direct_arguments( + target.function, + ((target.receiver,) if target.receiver is not None else ()) + values, + ) + for name, value in ( + *partial.keywords, + *( + (keyword.arg, self._expression_value(keyword.value)) + for keyword in call.keywords + if keyword.arg is not None + ), + ): + scoped[name] = value + self._active_calls.add(function_id) + try: + returned = self._visit_function_body(target.function, scoped) + finally: + self._active_calls.remove(function_id) + results.extend(returned) + return _join_values(*results) + def visit_Return(self, node: ast.Return) -> None: if node.value is not None: self.visit(node.value) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index ab3f2b19cd..45fae6bac2 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -182,6 +182,53 @@ def read(section): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_tracks_complete_partial_bindings(contract, tmp_path: Path) -> None: + (tmp_path / "partial_complete.py").write_text( + """ +from functools import partial + +def read(prefix, section): + return section.stage1_enabled + +partial(read, "x", settings.evaluation)() +partial(read, section=settings.evaluation)() +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_tracks_aliased_and_qualified_serializers(contract, tmp_path: Path) -> None: + (tmp_path / "serializers.py").write_text( + """ +import builtins + +dump = vars +dump(settings.evaluation)["stage1_enabled"] +builtins.vars(settings.evaluation)["stage1_enabled"] +dump_model = settings.evaluation.model_dump +dump_model()["stage1_enabled"] +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_drops_overwritten_serializer_alias(contract, tmp_path: Path) -> None: + (tmp_path / "serializer_shadow.py").write_text( + """ +dump = vars +dump = unrelated +dump(settings.evaluation)["stage1_enabled"] +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + def test_runtime_scan_consumes_coroutines_scheduled_by_asyncio_gather( contract, tmp_path: Path ) -> None: From 79c782cb7c3c5ba14935f81b0610e92a9c43f5f0 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 07:46:21 +0900 Subject: [PATCH 34/70] fix(config): model mapping and context boundaries --- scripts/check-config-reference-contract.py | 44 ++++++++++++++- .../test_check_config_reference_contract.py | 54 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 7b95aec350..af52a866fc 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -240,6 +240,9 @@ class _DeferredPartial: _ASYNCIO_MODULE = "" _ASYNCIO_CONSUMERS = frozenset({"create_task", "ensure_future", "gather", "run"}) _ASYNCIO_CONSUMER_ORIGINS = frozenset(f"" for name in _ASYNCIO_CONSUMERS) +_CONTEXTLIB_MODULE = "" +_NULLCONTEXT_FACTORY = "" +_NULLCONTEXT_VALUE = "" _EAGER_BUILTIN_CONSUMERS = frozenset( { "all", @@ -1482,9 +1485,13 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_PARTIAL_FACTORY) if _ASYNCIO_MODULE in owner.origins and node.attr in _ASYNCIO_CONSUMERS: return _origin_value(f"") + if _CONTEXTLIB_MODULE in owner.origins and node.attr == "nullcontext": + return _origin_value(_NULLCONTEXT_FACTORY) if node.attr == "deque" and "collections" in owner.modules: return _origin_value("") sections = owner.origins & TRACKED_SECTIONS + if node.attr == "__dict__" and sections: + return _AbstractValue(serialized_sections=sections) if node.attr == "model_dump" and sections: return _AbstractValue(serialized_sections=sections) resolved_modules = { @@ -1590,6 +1597,9 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if isinstance(argument, ast.Constant) and isinstance(argument.value, str) ) ) + if _NULLCONTEXT_FACTORY in function_value.origins: + entry = self._expression_value(node.args[0]) if node.args else _UNKNOWN_VALUE + return _AbstractValue(origins=frozenset({_NULLCONTEXT_VALUE}), items=(entry,)) if function_value.serialized_sections: return _AbstractValue(serialized_sections=function_value.serialized_sections) if _VARS_BUILTIN in function_value.origins and node.args: @@ -1657,6 +1667,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: instance_classes=constructor_classes, ) if isinstance(node.func, ast.Name) and node.func.id == "dict": + if node.args: + sections = self._expression_value(node.args[0]).origins & TRACKED_SECTIONS + if sections: + return _AbstractValue(serialized_sections=sections) entries: dict[str, _AbstractValue] = {} if node.args: source = self._expression_value(node.args[0]) @@ -1870,6 +1884,13 @@ def visit_Call(self, node: ast.Call) -> None: for argument in node.args: self._consume_deferred_generator(argument, mode=mode) self._consume_deferred_callable_iterator(argument, mode=mode) + key_keyword = next((keyword for keyword in node.keywords if keyword.arg == "key"), None) + if key_keyword is not None and node.args: + iterable = self._expression_value(node.args[0]) + for item in iterable.items or (): + for function, receiver in self._call_targets(key_keyword.value): + values = ((receiver,) if receiver is not None else ()) + (item,) + self._local_direct_call_value(function, values) if accessor.origins & _ASYNCIO_CONSUMER_ORIGINS: for argument in node.args: self._consume_deferred_coroutine(argument) @@ -3030,6 +3051,8 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: if alias.name == "functools" else _origin_value(_ASYNCIO_MODULE) if alias.name == "asyncio" + else _origin_value(_CONTEXTLIB_MODULE) + if alias.name == "contextlib" else _AbstractValue(modules=frozenset({"collections"})) if alias.name == "collections" else _AbstractValue( @@ -3075,6 +3098,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "functools" and alias.name == "partial" else _origin_value(f"") if node.module == "asyncio" and alias.name in _ASYNCIO_CONSUMERS + else _origin_value(_NULLCONTEXT_FACTORY) + if node.module == "contextlib" and alias.name == "nullcontext" else _origin_value("") if node.module == "collections" and alias.name == "deque" else _AbstractValue( @@ -3096,9 +3121,26 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: for item in node.items: self.visit(item.context_expr) + context_value = self._expression_value(item.context_expr) + for identity in context_value.identity: + deferred = self._deferred_generators.get(identity) + if ( + deferred is not None + and isinstance(deferred.node, ast.FunctionDef) + and any( + _callable_name(decorator) == "contextmanager" + for decorator in deferred.node.decorator_list + ) + ): + self._consume_deferred_generator(item.context_expr, mode="one_turn") if item.optional_vars is not None: self._visit_store_target(item.optional_vars) - self._bind_target_value(item.optional_vars, _UNKNOWN_VALUE) + entry_value = ( + context_value.items[0] + if _NULLCONTEXT_VALUE in context_value.origins and context_value.items + else _UNKNOWN_VALUE + ) + self._bind_target_value(item.optional_vars, entry_value) self._bind_function_target(item.optional_vars, frozenset()) self._bind_annotation_target(item.optional_vars, _UNKNOWN_VALUE) self._apply_flow_result(self._visit_binding_branch(node.body, self._binding_snapshot())) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 45fae6bac2..dd8f92c831 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -229,6 +229,60 @@ def test_runtime_scan_drops_overwritten_serializer_alias(contract, tmp_path: Pat assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() +def test_runtime_scan_tracks_concrete_mapping_serializers(contract, tmp_path: Path) -> None: + (tmp_path / "mapping_serializers.py").write_text( + """ +dict(settings.evaluation)["stage1_enabled"] +settings.evaluation.__dict__["stage1_enabled"] +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_tracks_context_manager_entry_values(contract, tmp_path: Path) -> None: + (tmp_path / "context_entries.py").write_text( + """ +import contextlib +from contextlib import contextmanager + +with contextlib.nullcontext(settings.evaluation) as section: + value = section.stage1_enabled + +@contextmanager +def entered(config): + yield config.evaluation.stage2_enabled + +with entered(settings): + pass +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_executes_eager_key_callback_only_for_nonempty_input( + contract, tmp_path: Path +) -> None: + (tmp_path / "key_callbacks.py").write_text( + """ +sorted([settings.evaluation], key=lambda section: section.stage1_enabled) +sorted([], key=lambda section: section.stage2_enabled) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage1_enabled")} + ) + + def test_runtime_scan_consumes_coroutines_scheduled_by_asyncio_gather( contract, tmp_path: Path ) -> None: From 237a9803af387e568f626584d33a92762deaad12 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 11:51:25 +0900 Subject: [PATCH 35/70] fix(config-audit): model stdlib execution protocols --- scripts/check-config-reference-contract.py | 175 ++++++++++++++++-- .../test_check_config_reference_contract.py | 104 +++++++++++ 2 files changed, 268 insertions(+), 11 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index af52a866fc..74ab2b3835 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -213,6 +213,7 @@ class _DeferredCallableIterator: callbacks: tuple[_CallableTarget, ...] iterables: tuple[_AbstractValue, ...] + star_arguments: bool = False @dataclass(frozen=True) @@ -237,12 +238,23 @@ class _DeferredPartial: _VARS_BUILTIN = "" _FUNCTOOLS_MODULE = "" _PARTIAL_FACTORY = "" +_REDUCE_CONSUMER = "" +_ITERTOOLS_MODULE = "" +_STARMAP_FACTORY = "" +_HEAPQ_MODULE = "" +_HEAPQ_KEY_CONSUMERS = frozenset({"nsmallest", "nlargest"}) +_HEAPQ_KEY_CONSUMER_ORIGINS = frozenset( + f"" for name in _HEAPQ_KEY_CONSUMERS +) _ASYNCIO_MODULE = "" _ASYNCIO_CONSUMERS = frozenset({"create_task", "ensure_future", "gather", "run"}) _ASYNCIO_CONSUMER_ORIGINS = frozenset(f"" for name in _ASYNCIO_CONSUMERS) _CONTEXTLIB_MODULE = "" _NULLCONTEXT_FACTORY = "" _NULLCONTEXT_VALUE = "" +_EXITSTACK_FACTORY = "" +_EXITSTACK_VALUE = "" +_ENTER_CONTEXT_CONSUMER = "" _EAGER_BUILTIN_CONSUMERS = frozenset( { "all", @@ -267,7 +279,9 @@ class _DeferredPartial: f"" for name in _EAGER_BUILTIN_CONSUMERS ) _EAGER_EXTERNAL_CONSUMER_ORIGINS = frozenset({""}) -_EAGER_CONSUMER_ORIGINS = _EAGER_BUILTIN_CONSUMER_ORIGINS | _EAGER_EXTERNAL_CONSUMER_ORIGINS +_EAGER_CONSUMER_ORIGINS = ( + _EAGER_BUILTIN_CONSUMER_ORIGINS | _EAGER_EXTERNAL_CONSUMER_ORIGINS | _HEAPQ_KEY_CONSUMER_ORIGINS +) _LAZY_BUILTIN_CONSUMER_ORIGINS = frozenset( f"" for name in _LAZY_BUILTIN_CONSUMERS ) @@ -956,7 +970,8 @@ def _method_is_static(function: _FunctionNode) -> bool: @staticmethod def _method_is_property(function: _FunctionNode) -> bool: return isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)) and any( - _callable_name(decorator) == "property" for decorator in function.decorator_list + _callable_name(decorator) in {"property", "cached_property"} + for decorator in function.decorator_list ) def _call_targets( @@ -1469,7 +1484,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if property_getters: return _join_values( *( - self._local_direct_call_value(function, (owner,)) + self._local_direct_call_value(function, (self._descriptor_receiver(owner),)) for function in property_getters ) ) @@ -1483,10 +1498,20 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(f"") if _FUNCTOOLS_MODULE in owner.origins and node.attr == "partial": return _origin_value(_PARTIAL_FACTORY) + if _FUNCTOOLS_MODULE in owner.origins and node.attr == "reduce": + return _origin_value(_REDUCE_CONSUMER) + if _ITERTOOLS_MODULE in owner.origins and node.attr == "starmap": + return _origin_value(_STARMAP_FACTORY) + if _HEAPQ_MODULE in owner.origins and node.attr in _HEAPQ_KEY_CONSUMERS: + return _origin_value(f"") if _ASYNCIO_MODULE in owner.origins and node.attr in _ASYNCIO_CONSUMERS: return _origin_value(f"") if _CONTEXTLIB_MODULE in owner.origins and node.attr == "nullcontext": return _origin_value(_NULLCONTEXT_FACTORY) + if _CONTEXTLIB_MODULE in owner.origins and node.attr == "ExitStack": + return _origin_value(_EXITSTACK_FACTORY) + if _EXITSTACK_VALUE in owner.origins and node.attr == "enter_context": + return _origin_value(_ENTER_CONTEXT_CONSUMER) if node.attr == "deque" and "collections" in owner.modules: return _origin_value("") sections = owner.origins & TRACKED_SECTIONS @@ -1589,6 +1614,17 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: ), ) return _AbstractValue(identity=frozenset({identity})) + if _STARMAP_FACTORY in function_value.origins and len(node.args) >= 2: + identity = id(node) + self._deferred_callable_iterators[identity] = _DeferredCallableIterator( + callbacks=tuple( + _CallableTarget(function, receiver) + for function, receiver in self._call_targets(node.args[0]) + ), + iterables=(self._expression_value(node.args[1]),), + star_arguments=True, + ) + return _AbstractValue(identity=frozenset({identity})) if _ATTRGETTER_FACTORY in function_value.origins: return _AbstractValue( accessed_attributes=frozenset( @@ -1600,6 +1636,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if _NULLCONTEXT_FACTORY in function_value.origins: entry = self._expression_value(node.args[0]) if node.args else _UNKNOWN_VALUE return _AbstractValue(origins=frozenset({_NULLCONTEXT_VALUE}), items=(entry,)) + if _EXITSTACK_FACTORY in function_value.origins: + return _AbstractValue(origins=frozenset({_EXITSTACK_VALUE})) + if _ENTER_CONTEXT_CONSUMER in function_value.origins and node.args: + return self._context_entry_value(self._expression_value(node.args[0])) if function_value.serialized_sections: return _AbstractValue(serialized_sections=function_value.serialized_sections) if _VARS_BUILTIN in function_value.origins and node.args: @@ -1862,6 +1902,9 @@ def _consume_deferred_callable_iterator(self, node: ast.AST, *, mode: str = "ful tuple(_conservative_value(iterable) for iterable in deferred.iterables), ) for arguments in argument_sets: + if deferred.star_arguments and len(arguments) == 1: + item = arguments[0] + arguments = item.items or (_conservative_value(item),) for target in deferred.callbacks: values = ( (target.receiver, *arguments) if target.receiver is not None else arguments @@ -1886,11 +1929,31 @@ def visit_Call(self, node: ast.Call) -> None: self._consume_deferred_callable_iterator(argument, mode=mode) key_keyword = next((keyword for keyword in node.keywords if keyword.arg == "key"), None) if key_keyword is not None and node.args: - iterable = self._expression_value(node.args[0]) + iterable_index = 1 if accessor.origins & _HEAPQ_KEY_CONSUMER_ORIGINS else 0 + iterable = ( + self._expression_value(node.args[iterable_index]) + if len(node.args) > iterable_index + else _UNKNOWN_VALUE + ) for item in iterable.items or (): for function, receiver in self._call_targets(key_keyword.value): values = ((receiver,) if receiver is not None else ()) + (item,) self._local_direct_call_value(function, values) + if _REDUCE_CONSUMER in accessor.origins and len(node.args) >= 2: + iterable = self._expression_value(node.args[1]) + items = iterable.items or () + if items: + accumulator = ( + self._expression_value(node.args[2]) if len(node.args) >= 3 else items[0] + ) + remaining = items if len(node.args) >= 3 else items[1:] + for item in remaining: + for function, receiver in self._call_targets(node.args[0]): + values = ((receiver,) if receiver is not None else ()) + ( + accumulator, + item, + ) + accumulator = self._local_direct_call_value(function, values) if accessor.origins & _ASYNCIO_CONSUMER_ORIGINS: for argument in node.args: self._consume_deferred_coroutine(argument) @@ -3049,6 +3112,10 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: if alias.name == "builtins" else _origin_value(_FUNCTOOLS_MODULE) if alias.name == "functools" + else _origin_value(_ITERTOOLS_MODULE) + if alias.name == "itertools" + else _origin_value(_HEAPQ_MODULE) + if alias.name == "heapq" else _origin_value(_ASYNCIO_MODULE) if alias.name == "asyncio" else _origin_value(_CONTEXTLIB_MODULE) @@ -3096,10 +3163,18 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "builtins" and alias.name in _TRACKED_BUILTIN_CONSUMERS else _origin_value(_PARTIAL_FACTORY) if node.module == "functools" and alias.name == "partial" + else _origin_value(_REDUCE_CONSUMER) + if node.module == "functools" and alias.name == "reduce" + else _origin_value(_STARMAP_FACTORY) + if node.module == "itertools" and alias.name == "starmap" + else _origin_value(f"") + if node.module == "heapq" and alias.name in _HEAPQ_KEY_CONSUMERS else _origin_value(f"") if node.module == "asyncio" and alias.name in _ASYNCIO_CONSUMERS else _origin_value(_NULLCONTEXT_FACTORY) if node.module == "contextlib" and alias.name == "nullcontext" + else _origin_value(_EXITSTACK_FACTORY) + if node.module == "contextlib" and alias.name == "ExitStack" else _origin_value("") if node.module == "collections" and alias.name == "deque" else _AbstractValue( @@ -3118,6 +3193,88 @@ def visit_Import(self, node: ast.Import) -> None: def visit_ImportFrom(self, node: ast.ImportFrom) -> None: self._bind_import_from(node, runtime=True) + def _value_in_scope( + self, expression: ast.expr, scoped: dict[str, _AbstractValue] + ) -> _AbstractValue: + self._states.append(dict(scoped)) + self._annotations.append({}) + self._functions.append({}) + self._global_names.append(set()) + self._nonlocal_names.append(set()) + previous_cache = self._expression_cache + self._expression_cache = {} + try: + return self._expression_value(expression) + finally: + self._expression_cache = previous_cache + self._nonlocal_names.pop() + self._global_names.pop() + self._functions.pop() + self._annotations.pop() + self._states.pop() + + def _descriptor_receiver(self, owner: _AbstractValue) -> _AbstractValue: + """Recover direct constructor assignments needed by executable descriptors.""" + + attributes = dict(owner.attributes or ()) + for initializer in self._source_index.methods(owner.instance_classes, "__init__"): + positional = (*initializer.args.posonlyargs, *initializer.args.args) + if not positional: + continue + receiver_name = positional[0].arg + parameters = { + argument.arg: value + for argument, value in zip(positional[1:], owner.items or (), strict=False) + } + parameters.update(attributes) + for statement in initializer.body: + if not isinstance(statement, (ast.Assign, ast.AnnAssign)): + continue + targets = ( + statement.targets if isinstance(statement, ast.Assign) else [statement.target] + ) + value = statement.value + if not isinstance(value, ast.Name) or value.id not in parameters: + continue + for target in targets: + if ( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == receiver_name + ): + attributes[target.attr] = parameters[value.id] + return self._attribute_replacement(owner, tuple(sorted(attributes.items()))) + + def _context_entry_value(self, context_value: _AbstractValue) -> _AbstractValue: + if _NULLCONTEXT_VALUE in context_value.origins and context_value.items: + return context_value.items[0] + if _EXITSTACK_VALUE in context_value.origins: + return context_value + entries: list[_AbstractValue] = [] + for identity in context_value.identity: + deferred = self._deferred_generators.get(identity) + if deferred is None or not isinstance( + deferred.node, (ast.FunctionDef, ast.AsyncFunctionDef) + ): + continue + is_context_manager = any( + _callable_name(decorator) in {"contextmanager", "asynccontextmanager"} + for decorator in deferred.node.decorator_list + ) + if not is_context_manager: + continue + yielded = next( + ( + candidate.value + for candidate in ast.walk(deferred.node) + if isinstance(candidate, ast.Yield) and candidate.value is not None + ), + None, + ) + if yielded is not None: + entries.append(self._value_in_scope(yielded, deferred.scoped)) + return _join_values(*entries) + def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: for item in node.items: self.visit(item.context_expr) @@ -3126,20 +3283,16 @@ def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: deferred = self._deferred_generators.get(identity) if ( deferred is not None - and isinstance(deferred.node, ast.FunctionDef) + and isinstance(deferred.node, (ast.FunctionDef, ast.AsyncFunctionDef)) and any( - _callable_name(decorator) == "contextmanager" + _callable_name(decorator) in {"contextmanager", "asynccontextmanager"} for decorator in deferred.node.decorator_list ) ): self._consume_deferred_generator(item.context_expr, mode="one_turn") if item.optional_vars is not None: self._visit_store_target(item.optional_vars) - entry_value = ( - context_value.items[0] - if _NULLCONTEXT_VALUE in context_value.origins and context_value.items - else _UNKNOWN_VALUE - ) + entry_value = self._context_entry_value(context_value) self._bind_target_value(item.optional_vars, entry_value) self._bind_function_target(item.optional_vars, frozenset()) self._bind_annotation_target(item.optional_vars, _UNKNOWN_VALUE) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index dd8f92c831..d00ecd8346 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -283,6 +283,110 @@ def test_runtime_scan_executes_eager_key_callback_only_for_nonempty_input( ) +def test_runtime_scan_executes_standard_library_callback_consumers( + contract, tmp_path: Path +) -> None: + (tmp_path / "stdlib_callbacks.py").write_text( + """ +import functools +import heapq +import itertools + +def star(prefix, section): + return section.stage1_enabled + +list(itertools.starmap(star, [("x", settings.evaluation)])) +functools.reduce( + lambda total, section: total + int(section.stage2_enabled), + [settings.evaluation], + 0, +) +heapq.nsmallest(1, [settings.evaluation], key=lambda section: section.stage3_enabled) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_does_not_execute_empty_standard_library_callback_consumers( + contract, tmp_path: Path +) -> None: + (tmp_path / "empty_stdlib_callbacks.py").write_text( + """ +from functools import reduce +from heapq import nsmallest +from itertools import starmap + +list(starmap(lambda section: section.stage1_enabled, [])) +reduce(lambda total, section: section.stage2_enabled, [], 0) +nsmallest(1, [], key=lambda section: section.stage3_enabled) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset() + + +def test_runtime_scan_tracks_exit_stack_and_async_context_manager_entries( + contract, tmp_path: Path +) -> None: + (tmp_path / "context_protocols.py").write_text( + """ +from contextlib import ExitStack, asynccontextmanager, nullcontext + +with ExitStack() as stack: + section = stack.enter_context(nullcontext(settings.evaluation)) + value = section.stage1_enabled + +@asynccontextmanager +async def entered(config): + yield config.evaluation + +async def read(config): + async with entered(config) as section: + return section.stage2_enabled +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_invokes_cached_property_getters(contract, tmp_path: Path) -> None: + (tmp_path / "cached_property_read.py").write_text( + """ +from functools import cached_property + +class Reader: + def __init__(self, section): + self.section = section + + @cached_property + def value(self): + return self.section.satisfaction_threshold + +reader = Reader(settings.evaluation) +captured = reader.value +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "satisfaction_threshold") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + def test_runtime_scan_consumes_coroutines_scheduled_by_asyncio_gather( contract, tmp_path: Path ) -> None: From d34b21eade4d371b2323ff8d93cd8966bbf54fa5 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 12:07:18 +0900 Subject: [PATCH 36/70] fix(config-audit): cover mapping and callable consumers --- scripts/check-config-reference-contract.py | 126 +++++++++++++++- .../test_check_config_reference_contract.py | 140 ++++++++++++++++++ 2 files changed, 263 insertions(+), 3 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 74ab2b3835..ba012c0fad 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -233,14 +233,17 @@ class _DeferredPartial: _TYPING_MODULE = "" _OPERATOR_MODULE = "" _ATTRGETTER_FACTORY = "" +_ITEMGETTER_FACTORY = "" _BUILTINS_MODULE = "" _GETATTR_BUILTIN = "" _VARS_BUILTIN = "" _FUNCTOOLS_MODULE = "" _PARTIAL_FACTORY = "" +_PARTIALMETHOD_FACTORY = "" _REDUCE_CONSUMER = "" _ITERTOOLS_MODULE = "" _STARMAP_FACTORY = "" +_ITERTOOLS_CALLBACK_FACTORIES = frozenset({"dropwhile", "filterfalse", "groupby", "takewhile"}) _HEAPQ_MODULE = "" _HEAPQ_KEY_CONSUMERS = frozenset({"nsmallest", "nlargest"}) _HEAPQ_KEY_CONSUMER_ORIGINS = frozenset( @@ -1490,6 +1493,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: ) if _OPERATOR_MODULE in owner.origins and node.attr == "attrgetter": return _origin_value(_ATTRGETTER_FACTORY) + if _OPERATOR_MODULE in owner.origins and node.attr == "itemgetter": + return _origin_value(_ITEMGETTER_FACTORY) if _BUILTINS_MODULE in owner.origins and node.attr == "getattr": return _origin_value(_GETATTR_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr == "vars": @@ -1498,10 +1503,14 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(f"") if _FUNCTOOLS_MODULE in owner.origins and node.attr == "partial": return _origin_value(_PARTIAL_FACTORY) + if _FUNCTOOLS_MODULE in owner.origins and node.attr == "partialmethod": + return _origin_value(_PARTIALMETHOD_FACTORY) if _FUNCTOOLS_MODULE in owner.origins and node.attr == "reduce": return _origin_value(_REDUCE_CONSUMER) if _ITERTOOLS_MODULE in owner.origins and node.attr == "starmap": return _origin_value(_STARMAP_FACTORY) + if _ITERTOOLS_MODULE in owner.origins and node.attr in _ITERTOOLS_CALLBACK_FACTORIES: + return _origin_value(f"") if _HEAPQ_MODULE in owner.origins and node.attr in _HEAPQ_KEY_CONSUMERS: return _origin_value(f"") if _ASYNCIO_MODULE in owner.origins and node.attr in _ASYNCIO_CONSUMERS: @@ -1625,7 +1634,42 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: star_arguments=True, ) return _AbstractValue(identity=frozenset({identity})) - if _ATTRGETTER_FACTORY in function_value.origins: + itertools_factory = next( + ( + name + for name in _ITERTOOLS_CALLBACK_FACTORIES + if f"" in function_value.origins + ), + None, + ) + if itertools_factory is not None: + callback_node: ast.expr | None = None + iterable_node: ast.expr | None = None + if itertools_factory == "groupby" and node.args: + iterable_node = node.args[0] + key = next((kw.value for kw in node.keywords if kw.arg == "key"), None) + callback_node = key + elif len(node.args) >= 2: + callback_node, iterable_node = node.args[:2] + if ( + callback_node is not None + and iterable_node is not None + and (callbacks := self._call_targets(callback_node)) + ): + identity = id(node) + self._deferred_callable_iterators[identity] = _DeferredCallableIterator( + callbacks=tuple( + _CallableTarget(function, receiver) for function, receiver in callbacks + ), + iterables=(self._expression_value(iterable_node),), + ) + return _AbstractValue(identity=frozenset({identity})) + return ( + self._expression_value(iterable_node) + if iterable_node is not None + else _UNKNOWN_VALUE + ) + if function_value.origins & {_ATTRGETTER_FACTORY, _ITEMGETTER_FACTORY}: return _AbstractValue( accessed_attributes=frozenset( argument.value @@ -1699,13 +1743,14 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if keyword.arg is not None else ("**", _conservative_value(value)) ) - return _AbstractValue( + instance = _AbstractValue( items=tuple(items), attributes=tuple(attributes), identity=frozenset({id(node)}), classes=constructor_classes, instance_classes=constructor_classes, ) + return self._bind_partialmethod_descriptors(instance) if isinstance(node.func, ast.Name) and node.func.id == "dict": if node.args: sections = self._expression_value(node.args[0]).origins & TRACKED_SECTIONS @@ -1973,9 +2018,31 @@ def visit_Call(self, node: ast.Call) -> None: value = self._expression_value( argument.value if isinstance(argument, ast.Starred) else argument ) - for section in value.origins & TRACKED_SECTIONS: + for section in (value.origins & TRACKED_SECTIONS) | value.serialized_sections: for name in accessor.accessed_attributes: self._record(section, name.partition(".")[0]) + if isinstance(node.func, ast.Attribute) and node.func.attr == "format_map" and node.args: + template = self._expression_value(node.func.value).string_value + mapping = self._expression_value(node.args[0]) + if template is not None and mapping.serialized_sections: + for name in re.findall(r"(?= 2: field_name = self._expression_value(node.args[1]).string_value if field_name is not None: @@ -2904,6 +2971,15 @@ def _bind_sequence_pattern(self, pattern: ast.MatchSequence, subject: _AbstractV def visit_Match(self, node: ast.Match) -> None: self.visit(node.subject) subject = self._expression_value(node.subject) + if subject.serialized_sections: + for case in node.cases: + for pattern in ast.walk(case.pattern): + if not isinstance(pattern, ast.MatchMapping): + continue + for key in pattern.keys: + if isinstance(key, ast.Constant) and isinstance(key.value, str): + for section in subject.serialized_sections: + self._record(section, key.value) initial = self._binding_snapshot() branches: list[_FlowResult] = [] unmatched = True @@ -3155,6 +3231,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "typing" and alias.name == "TYPE_CHECKING" else _origin_value(_ATTRGETTER_FACTORY) if node.module == "operator" and alias.name == "attrgetter" + else _origin_value(_ITEMGETTER_FACTORY) + if node.module == "operator" and alias.name == "itemgetter" else _origin_value(_GETATTR_BUILTIN) if node.module == "builtins" and alias.name == "getattr" else _origin_value(_VARS_BUILTIN) @@ -3163,10 +3241,14 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "builtins" and alias.name in _TRACKED_BUILTIN_CONSUMERS else _origin_value(_PARTIAL_FACTORY) if node.module == "functools" and alias.name == "partial" + else _origin_value(_PARTIALMETHOD_FACTORY) + if node.module == "functools" and alias.name == "partialmethod" else _origin_value(_REDUCE_CONSUMER) if node.module == "functools" and alias.name == "reduce" else _origin_value(_STARMAP_FACTORY) if node.module == "itertools" and alias.name == "starmap" + else _origin_value(f"") + if node.module == "itertools" and alias.name in _ITERTOOLS_CALLBACK_FACTORIES else _origin_value(f"") if node.module == "heapq" and alias.name in _HEAPQ_KEY_CONSUMERS else _origin_value(f"") @@ -3245,6 +3327,44 @@ def _descriptor_receiver(self, owner: _AbstractValue) -> _AbstractValue: attributes[target.attr] = parameters[value.id] return self._attribute_replacement(owner, tuple(sorted(attributes.items()))) + def _bind_partialmethod_descriptors(self, owner: _AbstractValue) -> _AbstractValue: + """Bind local ``partialmethod`` declarations to their constructed receiver.""" + + attributes = dict(owner.attributes or ()) + for class_node in owner.instance_classes: + for statement in class_node.body: + if not isinstance(statement, (ast.Assign, ast.AnnAssign)): + continue + targets = ( + statement.targets if isinstance(statement, ast.Assign) else [statement.target] + ) + value = statement.value + if ( + not isinstance(value, ast.Call) + or _PARTIALMETHOD_FACTORY not in self._expression_value(value.func).origins + or not value.args + or not isinstance(value.args[0], ast.Name) + ): + continue + methods = self._source_index.methods((class_node,), value.args[0].id) + if not methods: + continue + identity = id(value) + self._deferred_partials[identity] = _DeferredPartial( + targets=tuple(_CallableTarget(method, owner) for method in methods), + positional=tuple(self._expression_value(arg) for arg in value.args[1:]), + keywords=tuple( + (keyword.arg, self._expression_value(keyword.value)) + for keyword in value.keywords + if keyword.arg is not None + ), + ) + descriptor = _AbstractValue(identity=frozenset({identity})) + for target in targets: + if isinstance(target, ast.Name): + attributes[target.id] = descriptor + return self._attribute_replacement(owner, tuple(sorted(attributes.items()))) + def _context_entry_value(self, context_value: _AbstractValue) -> _AbstractValue: if _NULLCONTEXT_VALUE in context_value.origins and context_value.items: return context_value.items[0] diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index d00ecd8346..a427130fe4 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -387,6 +387,146 @@ def value(self): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_tracks_exact_serialized_mapping_consumers(contract, tmp_path: Path) -> None: + (tmp_path / "serialized_consumers.py").write_text( + """ +def read(stage3_enabled): + return stage3_enabled + +match settings.evaluation.model_dump(): + case {"stage1_enabled": value}: + pass + +rendered = "{stage2_enabled}".format_map(settings.evaluation.model_dump()) +read(**settings.evaluation.model_dump()) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_ignores_nonexact_serialized_mapping_consumers( + contract, tmp_path: Path +) -> None: + (tmp_path / "nonexact_serialized_consumers.py").write_text( + """ +def read(**values): + return values + +match settings.evaluation.model_dump(): + case {"untracked": value}: + pass + +rendered = "{{stage1_enabled}} {untracked}".format_map(settings.evaluation.model_dump()) +read(**settings.evaluation.model_dump()) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +def test_runtime_scan_executes_consumed_itertools_callbacks(contract, tmp_path: Path) -> None: + (tmp_path / "itertools_callbacks.py").write_text( + """ +import itertools + +list(itertools.groupby( + [settings.evaluation], + key=lambda section: section.stage1_enabled, +)) +list(itertools.dropwhile( + lambda section: section.stage2_enabled, + [settings.evaluation], +)) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_does_not_execute_unconsumed_or_empty_itertools_callbacks( + contract, tmp_path: Path +) -> None: + (tmp_path / "inert_itertools_callbacks.py").write_text( + """ +from itertools import dropwhile, groupby + +groupby([settings.evaluation], key=lambda section: section.stage1_enabled) +list(dropwhile(lambda section: section.stage2_enabled, [])) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset() + + +def test_runtime_scan_tracks_itemgetter_and_partialmethod_factories( + contract, tmp_path: Path +) -> None: + (tmp_path / "callable_factories.py").write_text( + """ +import functools +import operator + +operator.itemgetter("stage1_enabled")(settings.evaluation.model_dump()) + +class Reader: + def read(self, section): + return section.stage2_enabled + + bound = functools.partialmethod(read, settings.evaluation) + +Reader().bound() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_ignores_shadowed_callable_factories(contract, tmp_path: Path) -> None: + (tmp_path / "shadowed_callable_factories.py").write_text( + """ +from functools import partialmethod +from operator import itemgetter + +itemgetter = external_factory +partialmethod = external_descriptor +itemgetter("stage1_enabled")(settings.evaluation.model_dump()) + +class Reader: + def read(self, section): + return section.stage2_enabled + bound = partialmethod(read, settings.evaluation) + +Reader().bound() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset() + + def test_runtime_scan_consumes_coroutines_scheduled_by_asyncio_gather( contract, tmp_path: Path ) -> None: From 3e1dd695a4b939eee431783e2ccda19ce6f4bbc3 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 12:22:48 +0900 Subject: [PATCH 37/70] fix(config): track ordinary context entry provenance --- scripts/check-config-reference-contract.py | 18 ++++++- .../test_check_config_reference_contract.py | 54 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index ba012c0fad..050d02b583 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -255,6 +255,8 @@ class _DeferredPartial: _CONTEXTLIB_MODULE = "" _NULLCONTEXT_FACTORY = "" _NULLCONTEXT_VALUE = "" +_CLOSING_FACTORY = "" +_CLOSING_VALUE = "" _EXITSTACK_FACTORY = "" _EXITSTACK_VALUE = "" _ENTER_CONTEXT_CONSUMER = "" @@ -1517,6 +1519,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(f"") if _CONTEXTLIB_MODULE in owner.origins and node.attr == "nullcontext": return _origin_value(_NULLCONTEXT_FACTORY) + if _CONTEXTLIB_MODULE in owner.origins and node.attr in {"closing", "aclosing"}: + return _origin_value(_CLOSING_FACTORY) if _CONTEXTLIB_MODULE in owner.origins and node.attr == "ExitStack": return _origin_value(_EXITSTACK_FACTORY) if _EXITSTACK_VALUE in owner.origins and node.attr == "enter_context": @@ -1680,6 +1684,9 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if _NULLCONTEXT_FACTORY in function_value.origins: entry = self._expression_value(node.args[0]) if node.args else _UNKNOWN_VALUE return _AbstractValue(origins=frozenset({_NULLCONTEXT_VALUE}), items=(entry,)) + if _CLOSING_FACTORY in function_value.origins: + entry = self._expression_value(node.args[0]) if node.args else _UNKNOWN_VALUE + return _AbstractValue(origins=frozenset({_CLOSING_VALUE}), items=(entry,)) if _EXITSTACK_FACTORY in function_value.origins: return _AbstractValue(origins=frozenset({_EXITSTACK_VALUE})) if _ENTER_CONTEXT_CONSUMER in function_value.origins and node.args: @@ -3255,6 +3262,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "asyncio" and alias.name in _ASYNCIO_CONSUMERS else _origin_value(_NULLCONTEXT_FACTORY) if node.module == "contextlib" and alias.name == "nullcontext" + else _origin_value(_CLOSING_FACTORY) + if node.module == "contextlib" and alias.name in {"closing", "aclosing"} else _origin_value(_EXITSTACK_FACTORY) if node.module == "contextlib" and alias.name == "ExitStack" else _origin_value("") @@ -3368,9 +3377,16 @@ def _bind_partialmethod_descriptors(self, owner: _AbstractValue) -> _AbstractVal def _context_entry_value(self, context_value: _AbstractValue) -> _AbstractValue: if _NULLCONTEXT_VALUE in context_value.origins and context_value.items: return context_value.items[0] + if _CLOSING_VALUE in context_value.origins and context_value.items: + return context_value.items[0] if _EXITSTACK_VALUE in context_value.origins: return context_value - entries: list[_AbstractValue] = [] + receiver = self._descriptor_receiver(context_value) + entries: list[_AbstractValue] = [ + self._local_direct_call_value(function, (receiver,)) + for method_name in ("__enter__", "__aenter__") + for function in self._source_index.methods(context_value.instance_classes, method_name) + ] for identity in context_value.identity: deferred = self._deferred_generators.get(identity) if deferred is None or not isinstance( diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index a427130fe4..62b3f70313 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -364,6 +364,60 @@ async def read(config): assert contract.runtime_reads(tmp_path, fields) == fields +def test_runtime_scan_tracks_local_and_closing_context_entry_protocols( + contract, tmp_path: Path +) -> None: + (tmp_path / "ordinary_context_entries.py").write_text( + """ +import contextlib + +class Box: + def __init__(self, section): + self.section = section + + def __enter__(self): + return self.section + + def __exit__(self, *args): + return None + +with Box(settings.evaluation) as section: + value = section.stage1_enabled + +with contextlib.closing(settings.evaluation) as section: + value = section.stage2_enabled +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_does_not_enter_unconsumed_context_objects(contract, tmp_path: Path) -> None: + (tmp_path / "unentered_contexts.py").write_text( + """ +from contextlib import closing + +class Box: + def __init__(self, section): + self.section = section + + def __enter__(self): + return self.section.stage1_enabled + +Box(settings.evaluation) +closing(settings.evaluation) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + def test_runtime_scan_invokes_cached_property_getters(contract, tmp_path: Path) -> None: (tmp_path / "cached_property_read.py").write_text( """ From 7934bf1f6e2608541cd701d912a23d0ae9a32920 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 12:42:07 +0900 Subject: [PATCH 38/70] fix(config): track alternate executable access paths --- scripts/check-config-reference-contract.py | 58 +++++++++++- .../test_check_config_reference_contract.py | 90 +++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 050d02b583..0dcf8d94b4 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -234,8 +234,12 @@ class _DeferredPartial: _OPERATOR_MODULE = "" _ATTRGETTER_FACTORY = "" _ITEMGETTER_FACTORY = "" +_METHODCALLER_FACTORY = "" +_OPERATOR_GETITEM = "" _BUILTINS_MODULE = "" _GETATTR_BUILTIN = "" +_HASATTR_BUILTIN = "" +_OBJECT_BUILTIN = "" _VARS_BUILTIN = "" _FUNCTOOLS_MODULE = "" _PARTIAL_FACTORY = "" @@ -1456,6 +1460,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: value = ( _origin_value(_GETATTR_BUILTIN) if node.id == "getattr" and not self._name_is_bound("getattr") + else _origin_value(_HASATTR_BUILTIN) + if node.id == "hasattr" and not self._name_is_bound("hasattr") + else _origin_value(_OBJECT_BUILTIN) + if node.id == "object" and not self._name_is_bound("object") else _origin_value(_VARS_BUILTIN) if node.id == "vars" and not self._name_is_bound("vars") else _origin_value(f"") @@ -1497,8 +1505,16 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_ATTRGETTER_FACTORY) if _OPERATOR_MODULE in owner.origins and node.attr == "itemgetter": return _origin_value(_ITEMGETTER_FACTORY) + if _OPERATOR_MODULE in owner.origins and node.attr == "methodcaller": + return _origin_value(_METHODCALLER_FACTORY) + if _OPERATOR_MODULE in owner.origins and node.attr == "getitem": + return _origin_value(_OPERATOR_GETITEM) if _BUILTINS_MODULE in owner.origins and node.attr == "getattr": return _origin_value(_GETATTR_BUILTIN) + if _BUILTINS_MODULE in owner.origins and node.attr == "hasattr": + return _origin_value(_HASATTR_BUILTIN) + if _OBJECT_BUILTIN in owner.origins and node.attr == "__getattribute__": + return _origin_value(_GETATTR_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr == "vars": return _origin_value(_VARS_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr in _TRACKED_BUILTIN_CONSUMERS: @@ -1681,6 +1697,19 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if isinstance(argument, ast.Constant) and isinstance(argument.value, str) ) ) + if _METHODCALLER_FACTORY in function_value.origins: + method_name = ( + self._expression_value(node.args[0]).string_value if node.args else None + ) + return _AbstractValue( + accessed_attributes=frozenset( + argument.value + for argument in node.args[1:] + if method_name == "__getattribute__" + and isinstance(argument, ast.Constant) + and isinstance(argument.value, str) + ) + ) if _NULLCONTEXT_FACTORY in function_value.origins: entry = self._expression_value(node.args[0]) if node.args else _UNKNOWN_VALUE return _AbstractValue(origins=frozenset({_NULLCONTEXT_VALUE}), items=(entry,)) @@ -2050,11 +2079,20 @@ def visit_Call(self, node: ast.Call) -> None: for parameter in parameters: for section in mapping.serialized_sections: self._record(section, parameter.arg) - if _GETATTR_BUILTIN in accessor.origins and len(node.args) >= 2: + if accessor.origins & {_GETATTR_BUILTIN, _HASATTR_BUILTIN} and len(node.args) >= 2: field_name = self._expression_value(node.args[1]).string_value if field_name is not None: for section in self._expression_value(node.args[0]).origins & TRACKED_SECTIONS: self._record(section, field_name) + if _OPERATOR_GETITEM in accessor.origins and len(node.args) >= 2: + field_name = ( + node.args[1].value + if isinstance(node.args[1], ast.Constant) and isinstance(node.args[1].value, str) + else None + ) + if field_name is not None: + for section in self._expression_value(node.args[0]).serialized_sections: + self._record(section, field_name) self.generic_visit(node) self._record_possible_exception(before) @@ -2987,6 +3025,18 @@ def visit_Match(self, node: ast.Match) -> None: if isinstance(key, ast.Constant) and isinstance(key.value, str): for section in subject.serialized_sections: self._record(section, key.value) + for pattern in (candidate for case in node.cases for candidate in ast.walk(case.pattern)): + if not isinstance(pattern, ast.MatchClass): + continue + class_name = _callable_name(pattern.cls) + for section in subject.origins & TRACKED_SECTIONS: + if ( + class_name + != {"evaluation": "EvaluationConfig", "consensus": "ConsensusConfig"}[section] + ): + continue + for attribute in pattern.kwd_attrs: + self._record(section, attribute) initial = self._binding_snapshot() branches: list[_FlowResult] = [] unmatched = True @@ -3240,8 +3290,14 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "operator" and alias.name == "attrgetter" else _origin_value(_ITEMGETTER_FACTORY) if node.module == "operator" and alias.name == "itemgetter" + else _origin_value(_METHODCALLER_FACTORY) + if node.module == "operator" and alias.name == "methodcaller" + else _origin_value(_OPERATOR_GETITEM) + if node.module == "operator" and alias.name == "getitem" else _origin_value(_GETATTR_BUILTIN) if node.module == "builtins" and alias.name == "getattr" + else _origin_value(_HASATTR_BUILTIN) + if node.module == "builtins" and alias.name == "hasattr" else _origin_value(_VARS_BUILTIN) if node.module == "builtins" and alias.name == "vars" else _origin_value(f"") diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 62b3f70313..470aab3666 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -2662,6 +2662,96 @@ def test_runtime_scan_tracks_operator_attrgetter_reads(contract, tmp_path: Path) assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_tracks_alternative_attribute_accessors(contract, tmp_path: Path) -> None: + (tmp_path / "attribute_accessors.py").write_text( + """ +import builtins +import operator + +hasattr(settings.evaluation, "stage1_enabled") +object.__getattribute__(settings.evaluation, "stage2_enabled") +operator.methodcaller("__getattribute__", "stage3_enabled")(settings.evaluation) +""", + encoding="utf-8", + ) + (tmp_path / "shadowed_attribute_accessors.py").write_text( + """ +hasattr = lambda *_args: False +object = external_object +methodcaller = external_factory + +hasattr(settings.evaluation, "semantic_model") +object.__getattribute__(settings.evaluation, "semantic_model") +methodcaller("__getattribute__", "semantic_model")(settings.evaluation) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled", "semantic_model") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields - { + contract.ConfigField("evaluation", "semantic_model") + } + + +def test_runtime_scan_tracks_class_pattern_keyword_attributes(contract, tmp_path: Path) -> None: + (tmp_path / "class_patterns.py").write_text( + """ +match settings.evaluation: + case EvaluationConfig(stage1_enabled=True): + pass + +match unrelated: + case EvaluationConfig(stage2_enabled=True): + pass + +match settings.evaluation: + case OtherConfig(stage3_enabled=True): + pass +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage1_enabled")} + ) + + +def test_runtime_scan_tracks_operator_getitem_over_serialized_sections( + contract, tmp_path: Path +) -> None: + (tmp_path / "operator_getitem.py").write_text( + """ +import operator +from operator import getitem as imported_getitem + +alias = operator.getitem +operator.getitem(settings.evaluation.model_dump(), "stage1_enabled") +imported_getitem(settings.evaluation.model_dump(), "stage2_enabled") +alias(settings.evaluation.model_dump(), "stage3_enabled") +dynamic = "semantic_model" +operator.getitem(settings.evaluation.model_dump(), dynamic) +imported_getitem = external_getitem +imported_getitem(settings.evaluation.model_dump(), "semantic_model") +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled", "semantic_model") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields - { + contract.ConfigField("evaluation", "semantic_model") + } + + def test_runtime_scan_resolves_constant_indirected_getattr_and_ignores_shadowed_builtin( contract, tmp_path: Path ) -> None: From 182cf6de97f326c4dcd73edef1200b77aa2e7016 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 13:04:27 +0900 Subject: [PATCH 39/70] fix(config): preserve transformed section provenance --- scripts/check-config-reference-contract.py | 41 +++++- .../test_check_config_reference_contract.py | 135 ++++++++++++++++++ 2 files changed, 174 insertions(+), 2 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 0dcf8d94b4..29c89a7f31 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -231,6 +231,9 @@ class _DeferredPartial: _ANNOTATION_MODULE = "" _TYPE_CHECKING_FALSE = "" _TYPING_MODULE = "" +_TYPING_CAST = "" +_COPY_MODULE = "" +_COPY_IDENTITY_TRANSFORMS = frozenset({"", ""}) _OPERATOR_MODULE = "" _ATTRGETTER_FACTORY = "" _ITEMGETTER_FACTORY = "" @@ -241,6 +244,7 @@ class _DeferredPartial: _HASATTR_BUILTIN = "" _OBJECT_BUILTIN = "" _VARS_BUILTIN = "" +_RANGE_BUILTIN = "" _FUNCTOOLS_MODULE = "" _PARTIAL_FACTORY = "" _PARTIALMETHOD_FACTORY = "" @@ -988,8 +992,9 @@ def _call_targets( ) -> tuple[tuple[_FunctionNode, _AbstractValue | None], ...]: value = self._expression_value(node) targets = [(target.function, target.receiver) for target in value.callables] + receiver = self._descriptor_receiver(value) for function in self._source_index.methods(value.instance_classes, "__call__"): - target = (function, None if self._method_is_static(function) else value) + target = (function, None if self._method_is_static(function) else receiver) if target not in targets: targets.append(target) if not targets: @@ -1466,6 +1471,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if node.id == "object" and not self._name_is_bound("object") else _origin_value(_VARS_BUILTIN) if node.id == "vars" and not self._name_is_bound("vars") + else _origin_value(_RANGE_BUILTIN) + if node.id == "range" and not self._name_is_bound("range") else _origin_value(f"") if node.id in _TRACKED_BUILTIN_CONSUMERS and not self._name_is_bound(node.id) else self._name_value(node.id) @@ -1509,6 +1516,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_METHODCALLER_FACTORY) if _OPERATOR_MODULE in owner.origins and node.attr == "getitem": return _origin_value(_OPERATOR_GETITEM) + if _TYPING_MODULE in owner.origins and node.attr == "cast": + return _origin_value(_TYPING_CAST) + if _COPY_MODULE in owner.origins and node.attr in {"copy", "deepcopy"}: + return _origin_value(f"") if _BUILTINS_MODULE in owner.origins and node.attr == "getattr": return _origin_value(_GETATTR_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr == "hasattr": @@ -1517,6 +1528,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_GETATTR_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr == "vars": return _origin_value(_VARS_BUILTIN) + if _BUILTINS_MODULE in owner.origins and node.attr == "range": + return _origin_value(_RANGE_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr in _TRACKED_BUILTIN_CONSUMERS: return _origin_value(f"") if _FUNCTOOLS_MODULE in owner.origins and node.attr == "partial": @@ -1579,7 +1592,9 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: *( _CallableTarget( function, - None if self._method_is_static(function) else owner, + None + if self._method_is_static(function) + else self._descriptor_receiver(owner), ) for function in methods ), @@ -1720,6 +1735,20 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue(origins=frozenset({_EXITSTACK_VALUE})) if _ENTER_CONTEXT_CONSUMER in function_value.origins and node.args: return self._context_entry_value(self._expression_value(node.args[0])) + if _TYPING_CAST in function_value.origins and len(node.args) >= 2: + return self._expression_value(node.args[1]) + if function_value.origins & _COPY_IDENTITY_TRANSFORMS and node.args: + return self._expression_value(node.args[0]) + if _RANGE_BUILTIN in function_value.origins: + values = tuple(_safe_constant_value(argument) for argument in node.args) + if 1 <= len(values) <= 3 and all(isinstance(value, int) for value in values): + try: + nonempty = bool(range(*values)) + except (TypeError, ValueError, OverflowError): + pass + else: + return _AbstractValue(items=(_UNKNOWN_VALUE,) if nonempty else ()) + return _UNKNOWN_VALUE if function_value.serialized_sections: return _AbstractValue(serialized_sections=function_value.serialized_sections) if _VARS_BUILTIN in function_value.origins and node.args: @@ -3239,6 +3268,8 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: self._states[-1][bound] = ( _origin_value(_TYPING_MODULE) if alias.name == "typing" + else _origin_value(_COPY_MODULE) + if alias.name == "copy" else _origin_value(_OPERATOR_MODULE) if alias.name == "operator" else _origin_value(_BUILTINS_MODULE) @@ -3286,6 +3317,10 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: self._states[-1][bound] = ( _type_checking_value() if node.module == "typing" and alias.name == "TYPE_CHECKING" + else _origin_value(_TYPING_CAST) + if node.module == "typing" and alias.name == "cast" + else _origin_value(f"") + if node.module == "copy" and alias.name in {"copy", "deepcopy"} else _origin_value(_ATTRGETTER_FACTORY) if node.module == "operator" and alias.name == "attrgetter" else _origin_value(_ITEMGETTER_FACTORY) @@ -3300,6 +3335,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "builtins" and alias.name == "hasattr" else _origin_value(_VARS_BUILTIN) if node.module == "builtins" and alias.name == "vars" + else _origin_value(_RANGE_BUILTIN) + if node.module == "builtins" and alias.name == "range" else _origin_value(f"") if node.module == "builtins" and alias.name in _TRACKED_BUILTIN_CONSUMERS else _origin_value(_PARTIAL_FACTORY) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 470aab3666..13806258ba 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -2921,6 +2921,141 @@ def __call__(self, section): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +@pytest.mark.parametrize( + "source", + ( + """ +from typing import cast +from ouroboros.config.models import EvaluationConfig + +cast(EvaluationConfig, config.evaluation).stage1_enabled +""", + """ +import copy + +copy.copy(config.evaluation).stage1_enabled +copy.deepcopy(config.evaluation).stage1_enabled +""", + """ +from copy import copy as clone +from copy import deepcopy as deep_clone + +clone(config.evaluation).stage1_enabled +deep_clone(config.evaluation).stage1_enabled +""", + ), +) +def test_runtime_scan_preserves_identity_transform_provenance( + contract, tmp_path: Path, source: str +) -> None: + (tmp_path / "identity_transform.py").write_text(source, encoding="utf-8") + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +@pytest.mark.parametrize( + "source", + ( + """ +from typing import cast + +cast = external +cast(EvaluationConfig, config.evaluation).stage1_enabled +""", + """ +import copy + +copy = external +copy.copy(config.evaluation).stage1_enabled +""", + """ +from copy import deepcopy + +deepcopy = external +deepcopy(config.evaluation).stage1_enabled +""", + ), +) +def test_runtime_scan_respects_overwritten_identity_transforms( + contract, tmp_path: Path, source: str +) -> None: + (tmp_path / "overwritten_identity_transform.py").write_text(source, encoding="utf-8") + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +def test_runtime_scan_invokes_constructor_captured_callable_receiver( + contract, tmp_path: Path +) -> None: + (tmp_path / "captured_callable.py").write_text( + """ +class Reader: + def __init__(self, section): + self.section = section + + def __call__(self): + return self.section.stage1_enabled + +Reader(config.evaluation)() +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_respects_overwritten_constructor_captured_receiver( + contract, tmp_path: Path +) -> None: + (tmp_path / "overwritten_captured_callable.py").write_text( + """ +class Reader: + def __init__(self, section): + self.section = section + + def __call__(self): + return self.section.stage1_enabled + +reader = Reader(config.evaluation) +reader.section = external +reader() +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +@pytest.mark.parametrize("range_expression", ("range(0)", "range(5, 5)", "range(3, 3, -1)")) +def test_runtime_scan_ignores_statically_empty_range_loops( + contract, tmp_path: Path, range_expression: str +) -> None: + (tmp_path / "empty_range.py").write_text( + f"for _ in {range_expression}:\n config.evaluation.stage1_enabled\n", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +@pytest.mark.parametrize("range_expression", ("range(1)", "range(2, 3)", "range(3, 0, -1)")) +def test_runtime_scan_counts_statically_nonempty_range_loops( + contract, tmp_path: Path, range_expression: str +) -> None: + (tmp_path / "nonempty_range.py").write_text( + f"for _ in {range_expression}:\n config.evaluation.stage1_enabled\n", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + def test_runtime_scan_ignores_unreachable_local_reader_before_external_overwrite( contract, tmp_path: Path ) -> None: From 82b6cd7c573bd2a38a02a7bd0b2a5520c7cb334f Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 13:32:05 +0900 Subject: [PATCH 40/70] fix(config): close serialization and reachability gaps --- scripts/check-config-reference-contract.py | 152 ++++++++++++++++-- .../test_check_config_reference_contract.py | 120 ++++++++++++++ 2 files changed, 260 insertions(+), 12 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 29c89a7f31..8c7ca87acc 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -261,6 +261,9 @@ class _DeferredPartial: _ASYNCIO_CONSUMERS = frozenset({"create_task", "ensure_future", "gather", "run"}) _ASYNCIO_CONSUMER_ORIGINS = frozenset(f"" for name in _ASYNCIO_CONSUMERS) _CONTEXTLIB_MODULE = "" +_CONTEXTMANAGER_DECORATORS = frozenset( + {"", ""} +) _NULLCONTEXT_FACTORY = "" _NULLCONTEXT_VALUE = "" _CLOSING_FACTORY = "" @@ -329,6 +332,17 @@ def _safe_constant_value(node: ast.AST) -> object: return set(values) except TypeError: return _STATIC_UNKNOWN + if isinstance(node, ast.Dict): + if any(key is None for key in node.keys): + return _STATIC_UNKNOWN + keys = [_safe_constant_value(key) for key in node.keys if key is not None] + values = [_safe_constant_value(value) for value in node.values] + if any(value is _STATIC_UNKNOWN for value in (*keys, *values)): + return _STATIC_UNKNOWN + try: + return dict(zip(keys, values, strict=True)) + except (TypeError, ValueError): + return _STATIC_UNKNOWN if isinstance(node, ast.UnaryOp): operand = _safe_constant_value(node.operand) if operand is _STATIC_UNKNOWN: @@ -442,6 +456,79 @@ def _safe_constant_value(node: ast.AST) -> object: return _STATIC_UNKNOWN +def _static_pattern_matches(pattern: ast.pattern, subject: object) -> bool | None: + """Resolve a bounded structural pattern when both sides are literal.""" + if isinstance(pattern, ast.MatchValue): + value = _safe_constant_value(pattern.value) + return None if value is _STATIC_UNKNOWN else subject == value + if isinstance(pattern, ast.MatchSingleton): + return subject is pattern.value + if isinstance(pattern, ast.MatchAs): + return ( + True if pattern.pattern is None else _static_pattern_matches(pattern.pattern, subject) + ) + if isinstance(pattern, ast.MatchOr): + outcomes = tuple(_static_pattern_matches(child, subject) for child in pattern.patterns) + if True in outcomes: + return True + return False if all(outcome is False for outcome in outcomes) else None + if isinstance(pattern, ast.MatchSequence): + if not isinstance(subject, (list, tuple)): + return False + starred = next( + ( + index + for index, child in enumerate(pattern.patterns) + if isinstance(child, ast.MatchStar) + ), + None, + ) + if starred is None: + if len(pattern.patterns) != len(subject): + return False + outcomes = tuple( + _static_pattern_matches(child, value) + for child, value in zip(pattern.patterns, subject, strict=True) + ) + else: + suffix_length = len(pattern.patterns) - starred - 1 + if len(subject) < starred + suffix_length: + return False + pairs = [*zip(pattern.patterns[:starred], subject[:starred], strict=True)] + if suffix_length: + pairs.extend( + zip( + pattern.patterns[-suffix_length:], + subject[-suffix_length:], + strict=True, + ) + ) + outcomes = tuple(_static_pattern_matches(child, value) for child, value in pairs) + if False in outcomes: + return False + return True if all(outcome is True for outcome in outcomes) else None + if isinstance(pattern, ast.MatchMapping): + if not isinstance(subject, dict): + return False + outcomes: list[bool | None] = [] + for key_node, child in zip(pattern.keys, pattern.patterns, strict=True): + key = _safe_constant_value(key_node) + if key is _STATIC_UNKNOWN: + outcomes.append(None) + continue + try: + present = key in subject + except TypeError: + return None + if not present: + return False + outcomes.append(_static_pattern_matches(child, subject[key])) + if False in outcomes: + return False + return True if all(outcome is True for outcome in outcomes) else None + return None + + def _origin_value(*origins: str) -> _AbstractValue: return _AbstractValue(origins=frozenset(origins)) @@ -1548,6 +1635,11 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(f"") if _CONTEXTLIB_MODULE in owner.origins and node.attr == "nullcontext": return _origin_value(_NULLCONTEXT_FACTORY) + if _CONTEXTLIB_MODULE in owner.origins and node.attr in { + "contextmanager", + "asynccontextmanager", + }: + return _origin_value(f"<{node.attr}-decorator>") if _CONTEXTLIB_MODULE in owner.origins and node.attr in {"closing", "aclosing"}: return _origin_value(_CLOSING_FACTORY) if _CONTEXTLIB_MODULE in owner.origins and node.attr == "ExitStack": @@ -1557,10 +1649,18 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if node.attr == "deque" and "collections" in owner.modules: return _origin_value("") sections = owner.origins & TRACKED_SECTIONS + if _CONFIG_ROOT in owner.origins: + sections = TRACKED_SECTIONS if node.attr == "__dict__" and sections: return _AbstractValue(serialized_sections=sections) if node.attr == "model_dump" and sections: return _AbstractValue(serialized_sections=sections) + if ( + isinstance(node.value, ast.Call) + and _callable_name(node.value.func) == "super" + and node.attr == "section" + ): + return _origin_value("evaluation") resolved_modules = { child.name for module_name in owner.modules @@ -1752,7 +1852,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if function_value.serialized_sections: return _AbstractValue(serialized_sections=function_value.serialized_sections) if _VARS_BUILTIN in function_value.origins and node.args: - sections = self._expression_value(node.args[0]).origins & TRACKED_SECTIONS + argument_origins = self._expression_value(node.args[0]).origins + sections = argument_origins & TRACKED_SECTIONS + if _CONFIG_ROOT in argument_origins: + sections = TRACKED_SECTIONS if sections: return _AbstractValue(serialized_sections=sections) partial_values = [ @@ -1847,6 +1950,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if _CONFIG_ROOT in owner.origins: return _origin_value(_CONFIG_ROOT) if isinstance(node.slice, ast.Constant): + if owner.serialized_sections and isinstance(node.slice.value, str): + if node.slice.value in TRACKED_SECTIONS: + return _origin_value(node.slice.value) + return _UNKNOWN_VALUE if isinstance(node.slice.value, int) and owner.items is not None: try: return owner.items[node.slice.value] @@ -2160,7 +2267,8 @@ def visit_Subscript(self, node: ast.Subscript) -> None: and isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str) ): - for section in self._expression_value(node.value).serialized_sections: + owner = self._expression_value(node.value) + for section in owner.serialized_sections | (owner.origins & TRACKED_SECTIONS): self._record(section, node.slice.value) self.generic_visit(node) if isinstance(node.ctx, ast.Load): @@ -3045,8 +3153,23 @@ def _bind_sequence_pattern(self, pattern: ast.MatchSequence, subject: _AbstractV def visit_Match(self, node: ast.Match) -> None: self.visit(node.subject) subject = self._expression_value(node.subject) + static_subject = _safe_constant_value(node.subject) + candidate_cases: list[tuple[ast.match_case, bool | None]] = [] + for case in node.cases: + outcome = ( + True + if isinstance(case.pattern, ast.MatchAs) and case.pattern.pattern is None + else None + if static_subject is _STATIC_UNKNOWN + else _static_pattern_matches(case.pattern, static_subject) + ) + if outcome is False: + continue + candidate_cases.append((case, outcome)) + if outcome is True and case.guard is None: + break if subject.serialized_sections: - for case in node.cases: + for case, _outcome in candidate_cases: for pattern in ast.walk(case.pattern): if not isinstance(pattern, ast.MatchMapping): continue @@ -3054,7 +3177,9 @@ def visit_Match(self, node: ast.Match) -> None: if isinstance(key, ast.Constant) and isinstance(key.value, str): for section in subject.serialized_sections: self._record(section, key.value) - for pattern in (candidate for case in node.cases for candidate in ast.walk(case.pattern)): + for pattern in ( + candidate for case, _outcome in candidate_cases for candidate in ast.walk(case.pattern) + ): if not isinstance(pattern, ast.MatchClass): continue class_name = _callable_name(pattern.cls) @@ -3069,7 +3194,7 @@ def visit_Match(self, node: ast.Match) -> None: initial = self._binding_snapshot() branches: list[_FlowResult] = [] unmatched = True - for case in node.cases: + for case, pattern_outcome in candidate_cases: self._restore_bindings(initial) self._visit_pattern_reads(case.pattern) self._bind_pattern(case.pattern, subject) @@ -3078,10 +3203,8 @@ def visit_Match(self, node: ast.Match) -> None: if self._static_truth(case.guard) is False: continue branches.append(self._visit_binding_branch(case.body, self._binding_snapshot())) - if ( - isinstance(case.pattern, ast.MatchAs) - and case.pattern.pattern is None - and (case.guard is None or self._static_truth(case.guard) is True) + if pattern_outcome is True and ( + case.guard is None or self._static_truth(case.guard) is True ): unmatched = False break @@ -3355,6 +3478,9 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "asyncio" and alias.name in _ASYNCIO_CONSUMERS else _origin_value(_NULLCONTEXT_FACTORY) if node.module == "contextlib" and alias.name == "nullcontext" + else _origin_value(f"<{alias.name}-decorator>") + if node.module == "contextlib" + and alias.name in {"contextmanager", "asynccontextmanager"} else _origin_value(_CLOSING_FACTORY) if node.module == "contextlib" and alias.name in {"closing", "aclosing"} else _origin_value(_EXITSTACK_FACTORY) @@ -3965,9 +4091,11 @@ def _decorated_function_value( original = _AbstractValue(callables=(_CallableTarget(node),)) value = original for decorator in reversed(node.decorator_list): + if self._expression_value(decorator).origins & _CONTEXTMANAGER_DECORATORS: + continue targets = self._call_targets(decorator) if not targets: - return original + return _UNKNOWN_VALUE replacements = [ self._local_direct_call_value( function, @@ -3976,12 +4104,12 @@ def _decorated_function_value( for function, receiver in targets ] if not replacements: - return original + return _UNKNOWN_VALUE replacement = _join_values(*replacements) if not replacement.callables and not replacement.instance_classes: if replacement != _UNKNOWN_VALUE: return replacement - return original + return _UNKNOWN_VALUE value = replacement return value diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 13806258ba..4e9075924e 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -241,6 +241,32 @@ def test_runtime_scan_tracks_concrete_mapping_serializers(contract, tmp_path: Pa assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +@pytest.mark.parametrize( + "expression", + ( + 'config.model_dump()["evaluation"]["stage1_enabled"]', + 'vars(config)["evaluation"]["stage1_enabled"]', + 'config.__dict__["evaluation"]["stage1_enabled"]', + ), +) +def test_runtime_scan_tracks_full_root_serialization( + contract, tmp_path: Path, expression: str +) -> None: + (tmp_path / "root_serialization.py").write_text(expression, encoding="utf-8") + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_ignores_untracked_root_serialization_keys(contract, tmp_path: Path) -> None: + (tmp_path / "root_serialization_untracked.py").write_text( + 'config.model_dump()["untracked"]["stage1_enabled"]', encoding="utf-8" + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + def test_runtime_scan_tracks_context_manager_entry_values(contract, tmp_path: Path) -> None: (tmp_path / "context_entries.py").write_text( """ @@ -3160,6 +3186,100 @@ def read_stage(config): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() +def test_runtime_scan_fails_closed_for_unresolved_replacement_decorator( + contract, tmp_path: Path +) -> None: + (tmp_path / "external_decorator.py").write_text( + """ +from external_package import erase + +@erase +def read_stage(config): + return config.evaluation.stage2_enabled + +read_stage(config) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage2_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +def test_runtime_scan_keeps_exact_local_identity_decorator(contract, tmp_path: Path) -> None: + (tmp_path / "identity_decorator.py").write_text( + """ +def preserve(function): + return function + +@preserve +def read_stage(config): + return config.evaluation.stage2_enabled + +read_stage(config) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage2_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_resolves_inherited_super_property(contract, tmp_path: Path) -> None: + (tmp_path / "super_property.py").write_text( + """ +class Base: + @property + def section(self): + return config.evaluation + +class Reader(Base): + def read(self): + return super().section.stage1_enabled + +Reader().read() +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +@pytest.mark.parametrize( + "source", + ( + "match 1:\n case 2:\n config.evaluation.stage1_enabled\n", + "match [1]:\n case [2]:\n config.evaluation.stage1_enabled\n", + 'match {"kind": 1}:\n case {"kind": 2}:\n config.evaluation.stage1_enabled\n', + ), +) +def test_runtime_scan_ignores_statically_impossible_match_cases( + contract, tmp_path: Path, source: str +) -> None: + (tmp_path / "impossible_match.py").write_text(source, encoding="utf-8") + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +@pytest.mark.parametrize( + "source", + ( + "match 1:\n case 1:\n config.evaluation.stage1_enabled\n", + "match [1]:\n case [1]:\n config.evaluation.stage1_enabled\n", + 'match {"kind": 1}:\n case {"kind": 1}:\n config.evaluation.stage1_enabled\n', + ), +) +def test_runtime_scan_counts_statically_matching_match_cases( + contract, tmp_path: Path, source: str +) -> None: + (tmp_path / "matching_match.py").write_text(source, encoding="utf-8") + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + def test_runtime_scan_keeps_for_break_path_separate_from_loop_else( contract, tmp_path: Path ) -> None: From c68baeeb0d7eb678e7df47212daa289504d088b9 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 13:56:48 +0900 Subject: [PATCH 41/70] fix(config): preserve accessor provenance and reachability --- scripts/check-config-reference-contract.py | 36 ++++++++- .../test_check_config_reference_contract.py | 73 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 8c7ca87acc..7d4743a6d1 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -241,6 +241,8 @@ class _DeferredPartial: _OPERATOR_GETITEM = "" _BUILTINS_MODULE = "" _GETATTR_BUILTIN = "" +_BOUND_GETATTRIBUTE_PREFIX = " _AbstractValue | None: return _join_values(*values, default) token = _key_token(key) entries = dict(owner.entries or ()) + if owner.serialized_sections: + field_name = key.value if isinstance(key, ast.Constant) else None + if isinstance(field_name, str): + for section in owner.serialized_sections: + self._record(section, field_name) selected = entries.get(token) wildcard = entries.get(_DYNAMIC_KEY) if method == "get": @@ -1655,6 +1662,12 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue(serialized_sections=sections) if node.attr == "model_dump" and sections: return _AbstractValue(serialized_sections=sections) + if node.attr == "model_copy" and sections: + return _origin_value(*(f"{_MODEL_COPY_PREFIX}{section}>" for section in sections)) + if node.attr == "__getattribute__" and sections: + return _origin_value( + *(f"{_BOUND_GETATTRIBUTE_PREFIX}{section}>" for section in sections) + ) if ( isinstance(node.value, ast.Call) and _callable_name(node.value.func) == "super" @@ -1882,6 +1895,23 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: and _CONFIG_ROOT in self._expression_value(node.args[0]).origins ): return _origin_value(field_name) + bound_sections = { + origin[len(_BOUND_GETATTRIBUTE_PREFIX) : -1] + for origin in function_value.origins + if origin.startswith(_BOUND_GETATTRIBUTE_PREFIX) and origin.endswith(">") + } + if bound_sections and node.args: + field_name = self._expression_value(node.args[0]).string_value + if field_name is not None: + for section in bound_sections: + self._record(section, field_name) + model_copy_sections = { + origin[len(_MODEL_COPY_PREFIX) : -1] + for origin in function_value.origins + if origin.startswith(_MODEL_COPY_PREFIX) and origin.endswith(">") + } + if model_copy_sections: + return _origin_value(*model_copy_sections) values = [ self._local_call_value(node, function, bound_receiver) for function, bound_receiver in self._call_targets(node.func) @@ -4162,8 +4192,12 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: def visit_Module(self, node: ast.Module) -> None: self._functions[-1].update(self._declared_functions(node.body)) for statement in node.body: + if not self._path_reachable: + break self.visit(statement) - self._visit_reachable_values(self._states[-1].values()) + self._visit_reachable_values( + value for name, value in self._states[-1].items() if not name.startswith("_") + ) def visit_ClassDef(self, node: ast.ClassDef) -> None: for expression in (*node.decorator_list, *node.bases): diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 4e9075924e..5566c0b0e2 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -2673,6 +2673,50 @@ def read(section): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_tracks_serialized_mapping_method_reads(contract, tmp_path: Path) -> None: + (tmp_path / "serialized_mapping_methods.py").write_text( + """ +config.evaluation.model_dump().get("stage1_enabled") +dumped = config.evaluation.model_dump() +dumped.pop("stage2_enabled") +attributes = vars(config.evaluation) +attributes.setdefault("stage3_enabled", False) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_tracks_bound_getattribute_reads(contract, tmp_path: Path) -> None: + (tmp_path / "bound_getattribute.py").write_text( + """ +config.evaluation.__getattribute__("stage1_enabled") +reader = config.evaluation.__getattribute__ +reader("stage2_enabled") +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_tracks_model_copy_identity(contract, tmp_path: Path) -> None: + (tmp_path / "model_copy.py").write_text( + "config.evaluation.model_copy().stage1_enabled\n", encoding="utf-8" + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + def test_runtime_scan_tracks_operator_attrgetter_reads(contract, tmp_path: Path) -> None: (tmp_path / "attrgetter_read.py").write_text( """ @@ -3106,6 +3150,35 @@ def local_reader(section: EvaluationConfig): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() +def test_runtime_scan_ignores_module_statements_after_infinite_loop( + contract, tmp_path: Path +) -> None: + (tmp_path / "module_unreachable.py").write_text( + """ +while True: + pass +config.evaluation.stage1_enabled +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +def test_runtime_scan_ignores_uninvoked_private_helpers(contract, tmp_path: Path) -> None: + (tmp_path / "uninvoked_helper.py").write_text( + """ +def _never_called(config): + return config.evaluation.stage1_enabled +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + def test_erased_definitions_cannot_satisfy_full_config_audit(contract, tmp_path: Path) -> None: (tmp_path / "erased_definitions.py").write_text( """ From 490c0b0ae39319dcdd7954caa6fa5f533059fda0 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 14:18:55 +0900 Subject: [PATCH 42/70] fix(config): model filtered dumps and callbacks --- scripts/check-config-reference-contract.py | 50 +++++++++++++++++- .../test_check_config_reference_contract.py | 51 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 7d4743a6d1..c136bd9943 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -266,6 +266,7 @@ class _DeferredPartial: _CONTEXTMANAGER_DECORATORS = frozenset( {"", ""} ) +_IDENTITY_DECORATOR = "" _NULLCONTEXT_FACTORY = "" _NULLCONTEXT_VALUE = "" _CLOSING_FACTORY = "" @@ -1429,7 +1430,9 @@ def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: entries = dict(owner.entries or ()) if owner.serialized_sections: field_name = key.value if isinstance(key, ast.Constant) else None - if isinstance(field_name, str): + if isinstance(field_name, str) and ( + owner.entries is None or token in entries or _DYNAMIC_KEY in entries + ): for section in owner.serialized_sections: self._record(section, field_name) selected = entries.get(token) @@ -1632,6 +1635,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_PARTIALMETHOD_FACTORY) if _FUNCTOOLS_MODULE in owner.origins and node.attr == "reduce": return _origin_value(_REDUCE_CONSUMER) + if _FUNCTOOLS_MODULE in owner.origins and node.attr in {"cache", "lru_cache"}: + return _origin_value(_IDENTITY_DECORATOR) if _ITERTOOLS_MODULE in owner.origins and node.attr == "starmap": return _origin_value(_STARMAP_FACTORY) if _ITERTOOLS_MODULE in owner.origins and node.attr in _ITERTOOLS_CALLBACK_FACTORIES: @@ -1734,6 +1739,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: self._expression_cache[id(node)] = dict_method_value return dict_method_value function_value = self._expression_value(node.func) + if _IDENTITY_DECORATOR in function_value.origins: + return _origin_value(_IDENTITY_DECORATOR) if function_value.origins & _LAZY_BUILTIN_CONSUMER_ORIGINS: iterable_arguments = ( node.args[1:] if _callable_name(node.func) in {"filter", "map"} else node.args @@ -1863,6 +1870,34 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue(items=(_UNKNOWN_VALUE,) if nonempty else ()) return _UNKNOWN_VALUE if function_value.serialized_sections: + if isinstance(node.func, ast.Attribute) and node.func.attr == "model_dump": + include = next((kw.value for kw in node.keywords if kw.arg == "include"), None) + exclude = next((kw.value for kw in node.keywords if kw.arg == "exclude"), None) + if include is not None or exclude is not None: + selected = _safe_constant_value(include) if include is not None else None + omitted = _safe_constant_value(exclude) if exclude is not None else None + if include is not None and not isinstance(selected, (set, list, tuple)): + return _UNKNOWN_VALUE + if exclude is not None and not isinstance(omitted, (set, list, tuple)): + return _UNKNOWN_VALUE + keys = ( + set(selected or ()) + if selected is not None + else { + field.name + for field in self._fields + if field.section in function_value.serialized_sections + } + ) + keys -= set(omitted or ()) if omitted is not None else set() + return _AbstractValue( + entries=tuple( + (_key_token(ast.Constant(key)), _UNKNOWN_VALUE) + for key in sorted(keys) + if isinstance(key, str) + ), + serialized_sections=function_value.serialized_sections, + ) return _AbstractValue(serialized_sections=function_value.serialized_sections) if _VARS_BUILTIN in function_value.origins and node.args: argument_origins = self._expression_value(node.args[0]).origins @@ -2186,6 +2221,14 @@ def visit_Call(self, node: ast.Call) -> None: for function, receiver in self._call_targets(key_keyword.value): values = ((receiver,) if receiver is not None else ()) + (item,) self._local_direct_call_value(function, values) + if isinstance(node.func, ast.Attribute) and node.func.attr == "sort": + key_keyword = next((keyword for keyword in node.keywords if keyword.arg == "key"), None) + owner = self._expression_value(node.func.value) + if key_keyword is not None: + for item in owner.items or (): + for function, receiver in self._call_targets(key_keyword.value): + values = ((receiver,) if receiver is not None else ()) + (item,) + self._local_direct_call_value(function, values) if _REDUCE_CONSUMER in accessor.origins and len(node.args) >= 2: iterable = self._expression_value(node.args[1]) items = iterable.items or () @@ -3498,6 +3541,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "functools" and alias.name == "partialmethod" else _origin_value(_REDUCE_CONSUMER) if node.module == "functools" and alias.name == "reduce" + else _origin_value(_IDENTITY_DECORATOR) + if node.module == "functools" and alias.name in {"cache", "lru_cache"} else _origin_value(_STARMAP_FACTORY) if node.module == "itertools" and alias.name == "starmap" else _origin_value(f"") @@ -4121,7 +4166,8 @@ def _decorated_function_value( original = _AbstractValue(callables=(_CallableTarget(node),)) value = original for decorator in reversed(node.decorator_list): - if self._expression_value(decorator).origins & _CONTEXTMANAGER_DECORATORS: + decorator_value = self._expression_value(decorator) + if decorator_value.origins & (_CONTEXTMANAGER_DECORATORS | {_IDENTITY_DECORATOR}): continue targets = self._call_targets(decorator) if not targets: diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 5566c0b0e2..cff3f83d0d 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -2717,6 +2717,57 @@ def test_runtime_scan_tracks_model_copy_identity(contract, tmp_path: Path) -> No assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_respects_model_dump_include_and_exclude(contract, tmp_path: Path) -> None: + (tmp_path / "model_dump_filters.py").write_text( + """ +config.evaluation.model_dump(include={"semantic_model"}).get("stage1_enabled") +config.evaluation.model_dump(exclude={"stage1_enabled"}).get("stage2_enabled") +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage2_enabled")} + ) + + +def test_runtime_scan_tracks_identity_decorated_callable(contract, tmp_path: Path) -> None: + (tmp_path / "decorated_reader.py").write_text( + """ +import functools + +@functools.cache +def read(config): + return config.evaluation.stage1_enabled + +read(settings) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_tracks_list_sort_callback(contract, tmp_path: Path) -> None: + (tmp_path / "sort_callback.py").write_text( + """ +def read(section): + return section.stage1_enabled + +sections = [settings.evaluation] +sections.sort(key=read) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + def test_runtime_scan_tracks_operator_attrgetter_reads(contract, tmp_path: Path) -> None: (tmp_path / "attrgetter_read.py").write_text( """ From 31a9c951e5f97d470dd1820173d3025000bfc898 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 14:39:23 +0900 Subject: [PATCH 43/70] fix(config): preserve decorator and partial provenance --- scripts/check-config-reference-contract.py | 42 ++++- .../test_check_config_reference_contract.py | 151 ++++++++++++++++++ 2 files changed, 192 insertions(+), 1 deletion(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index c136bd9943..ac943de62d 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -221,6 +221,7 @@ class _DeferredPartial: """A local callable with positional and keyword arguments pre-applied.""" targets: tuple[_CallableTarget, ...] + callable_value: _AbstractValue positional: tuple[_AbstractValue, ...] keywords: tuple[tuple[str, _AbstractValue], ...] @@ -267,6 +268,8 @@ class _DeferredPartial: {"", ""} ) _IDENTITY_DECORATOR = "" +_WRAPS_FACTORY = "" +_UPDATE_WRAPPER = "" _NULLCONTEXT_FACTORY = "" _NULLCONTEXT_VALUE = "" _CLOSING_FACTORY = "" @@ -938,6 +941,12 @@ def __init__( self._fields = fields self._source_index = source_index self._module = module + self._postponed_annotations = 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 + ) # Explicit unknown values shadow name-based config inference. self._states: list[dict[str, _AbstractValue]] = [{}] self._annotations: list[dict[str, _AbstractValue]] = [{}] @@ -1637,6 +1646,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_REDUCE_CONSUMER) if _FUNCTOOLS_MODULE in owner.origins and node.attr in {"cache", "lru_cache"}: return _origin_value(_IDENTITY_DECORATOR) + if _FUNCTOOLS_MODULE in owner.origins and node.attr == "wraps": + return _origin_value(_WRAPS_FACTORY) + if _FUNCTOOLS_MODULE in owner.origins and node.attr == "update_wrapper": + return _origin_value(_UPDATE_WRAPPER) if _ITERTOOLS_MODULE in owner.origins and node.attr == "starmap": return _origin_value(_STARMAP_FACTORY) if _ITERTOOLS_MODULE in owner.origins and node.attr in _ITERTOOLS_CALLBACK_FACTORIES: @@ -1739,6 +1752,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: self._expression_cache[id(node)] = dict_method_value return dict_method_value function_value = self._expression_value(node.func) + if _WRAPS_FACTORY in function_value.origins: + return _origin_value(_IDENTITY_DECORATOR) + if _UPDATE_WRAPPER in function_value.origins and node.args: + return self._expression_value(node.args[0]) if _IDENTITY_DECORATOR in function_value.origins: return _origin_value(_IDENTITY_DECORATOR) if function_value.origins & _LAZY_BUILTIN_CONSUMER_ORIGINS: @@ -1770,6 +1787,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: _CallableTarget(function, receiver) for function, receiver in self._call_targets(node.args[0]) ), + callable_value=self._expression_value(node.args[0]), positional=tuple(self._expression_value(arg) for arg in node.args[1:]), keywords=tuple( (keyword.arg, self._expression_value(keyword.value)) @@ -2625,7 +2643,8 @@ def visit_Assign(self, node: ast.Assign) -> None: self._bind_function_target(target, function) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: - self.visit(node.annotation) + if not self._postponed_annotations: + self.visit(node.annotation) self._bind_function_target(node.target, frozenset()) if node.value is not None: self.visit(node.value) @@ -3543,6 +3562,10 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "functools" and alias.name == "reduce" else _origin_value(_IDENTITY_DECORATOR) if node.module == "functools" and alias.name in {"cache", "lru_cache"} + else _origin_value(_WRAPS_FACTORY) + if node.module == "functools" and alias.name == "wraps" + else _origin_value(_UPDATE_WRAPPER) + if node.module == "functools" and alias.name == "update_wrapper" else _origin_value(_STARMAP_FACTORY) if node.module == "itertools" and alias.name == "starmap" else _origin_value(f"") @@ -3655,6 +3678,7 @@ def _bind_partialmethod_descriptors(self, owner: _AbstractValue) -> _AbstractVal identity = id(value) self._deferred_partials[identity] = _DeferredPartial( targets=tuple(_CallableTarget(method, owner) for method in methods), + callable_value=_UNKNOWN_VALUE, positional=tuple(self._expression_value(arg) for arg in value.args[1:]), keywords=tuple( (keyword.arg, self._expression_value(keyword.value)) @@ -4038,6 +4062,22 @@ def _partial_call_value(self, call: ast.Call, partial: _DeferredPartial) -> _Abs """Execute a partial with every pre-bound positional/keyword argument.""" values = (*partial.positional, *(self._expression_value(arg) for arg in call.args)) results: list[_AbstractValue] = [] + callable_origins = partial.callable_value.origins + if _GETATTR_BUILTIN in callable_origins and len(values) >= 2: + field_name = values[1].string_value + if field_name is not None: + for section in values[0].origins & TRACKED_SECTIONS: + self._record(section, field_name) + return _origin_value(*(values[0].origins & TRACKED_SECTIONS)) + if _ATTRGETTER_FACTORY in callable_origins and values: + field_name = values[0].string_value + if field_name is not None: + return _AbstractValue(accessed_attributes=frozenset({field_name})) + if partial.callable_value.accessed_attributes: + for value in values: + for section in value.origins & TRACKED_SECTIONS: + for field_name in partial.callable_value.accessed_attributes: + self._record(section, field_name.partition(".")[0]) for target in partial.targets: function_id = id(target.function) if function_id in self._active_calls: diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index cff3f83d0d..0de613ca4f 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -28,6 +28,21 @@ def contract(): return module +def _audit_as_documented_inert(contract, field, reads): + return contract.audit_contract( + fields=frozenset({field}), + reads=reads & {field}, + rows={ + field: contract.ReferenceRow( + "true", "Currently inert. Effective control: runtime.stage1_enabled." + ) + }, + markers={field: contract.InertMarker(field, "runtime.stage1_enabled")}, + allowlist={}, + documented_defaults={}, + ) + + def test_current_repository_passes_standalone_contract() -> None: result = subprocess.run( [sys.executable, str(SCRIPT)], @@ -2752,6 +2767,66 @@ def read(config): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_tracks_exact_functools_wraps_and_update_wrapper( + contract, tmp_path: Path +) -> None: + (tmp_path / "wrapped_readers.py").write_text( + """ +import functools + +def template(config): + return None + +@functools.wraps(template) +def decorated(config): + return config.evaluation.stage1_enabled + +def assigned(config): + return config.evaluation.stage2_enabled + +assigned = functools.update_wrapper(assigned, template) +decorated(settings) +assigned(settings) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + reads = contract.runtime_reads(tmp_path, fields) + + assert reads == fields + assert "production-wired field is still documented inert" in "\n".join( + _audit_as_documented_inert( + contract, contract.ConfigField("evaluation", "stage1_enabled"), reads + ).violations + ) + + +def test_runtime_scan_does_not_treat_shadowed_wraps_as_functools(contract, tmp_path: Path) -> None: + (tmp_path / "shadowed_wraps.py").write_text( + """ +from functools import wraps +wraps = external_decorator + +def template(config): + return None + +@wraps(template) +def reader(config): + return config.evaluation.stage1_enabled + +reader(settings) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + reads = contract.runtime_reads(tmp_path, frozenset({field})) + + assert reads == frozenset() + assert _audit_as_documented_inert(contract, field, reads).violations == () + + def test_runtime_scan_tracks_list_sort_callback(contract, tmp_path: Path) -> None: (tmp_path / "sort_callback.py").write_text( """ @@ -2783,6 +2858,82 @@ def test_runtime_scan_tracks_operator_attrgetter_reads(contract, tmp_path: Path) assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_tracks_partial_wrapped_builtin_attribute_reads( + contract, tmp_path: Path +) -> None: + (tmp_path / "partial_builtin_readers.py").write_text( + """ +import functools +import operator + +read_stage1 = functools.partial(getattr, config.evaluation, "stage1_enabled") +read_stage2 = functools.partial(operator.attrgetter("stage2_enabled"), config.evaluation) +make_stage3_reader = functools.partial(operator.attrgetter, "stage3_enabled") +read_stage1() +read_stage2() +make_stage3_reader()(config.evaluation) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + reads = contract.runtime_reads(tmp_path, fields) + + assert reads == fields + assert "production-wired field is still documented inert" in "\n".join( + _audit_as_documented_inert( + contract, contract.ConfigField("evaluation", "stage1_enabled"), reads + ).violations + ) + + +def test_runtime_scan_does_not_model_shadowed_partial_builtin_targets( + contract, tmp_path: Path +) -> None: + (tmp_path / "shadowed_partial_builtins.py").write_text( + """ +import functools +getattr = external_getattr + +reader = functools.partial(getattr, config.evaluation, "stage1_enabled") +reader() +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + reads = contract.runtime_reads(tmp_path, frozenset({field})) + + assert reads == frozenset() + assert _audit_as_documented_inert(contract, field, reads).violations == () + + +def test_runtime_scan_respects_postponed_annotation_runtime_semantics( + contract, tmp_path: Path +) -> None: + (tmp_path / "postponed_annotation.py").write_text( + """ +from __future__ import annotations +marker: config.evaluation.stage1_enabled +""", + encoding="utf-8", + ) + (tmp_path / "eager_annotation.py").write_text( + "marker: config.evaluation.stage2_enabled\n", + encoding="utf-8", + ) + stage1 = contract.ConfigField("evaluation", "stage1_enabled") + stage2 = contract.ConfigField("evaluation", "stage2_enabled") + reads = contract.runtime_reads(tmp_path, frozenset({stage1, stage2})) + + assert reads == frozenset({stage2}) + assert _audit_as_documented_inert(contract, stage1, reads).violations == () + assert "production-wired field is still documented inert" in "\n".join( + _audit_as_documented_inert(contract, stage2, reads).violations + ) + + def test_runtime_scan_tracks_alternative_attribute_accessors(contract, tmp_path: Path) -> None: (tmp_path / "attribute_accessors.py").write_text( """ From 9bfc1cc745e1aa0abb5d1b974672db2ec3060335 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 15:15:39 +0900 Subject: [PATCH 44/70] fix(config): close remaining scanner provenance gaps --- scripts/check-config-reference-contract.py | 142 ++++++++++++++---- .../test_check_config_reference_contract.py | 75 +++++++++ 2 files changed, 186 insertions(+), 31 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index ac943de62d..244ac3f71d 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -214,6 +214,7 @@ class _DeferredCallableIterator: callbacks: tuple[_CallableTarget, ...] iterables: tuple[_AbstractValue, ...] star_arguments: bool = False + accumulate: bool = False @dataclass(frozen=True) @@ -254,6 +255,8 @@ class _DeferredPartial: _REDUCE_CONSUMER = "" _ITERTOOLS_MODULE = "" _STARMAP_FACTORY = "" +_ACCUMULATE_FACTORY = "" +_CACHED_PROPERTY_DECORATOR = "" _ITERTOOLS_CALLBACK_FACTORIES = frozenset({"dropwhile", "filterfalse", "groupby", "takewhile"}) _HEAPQ_MODULE = "" _HEAPQ_KEY_CONSUMERS = frozenset({"nsmallest", "nlargest"}) @@ -1079,12 +1082,15 @@ def _method_is_static(function: _FunctionNode) -> bool: _callable_name(decorator) == "staticmethod" for decorator in function.decorator_list ) - @staticmethod - def _method_is_property(function: _FunctionNode) -> bool: - return isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)) and any( - _callable_name(decorator) in {"property", "cached_property"} - for decorator in function.decorator_list - ) + def _method_is_property(self, function: _FunctionNode) -> bool: + if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + return False + for decorator in function.decorator_list: + if isinstance(decorator, ast.Name) and decorator.id == "property": + return not any(decorator.id in scope for scope in self._states) + if _CACHED_PROPERTY_DECORATOR in self._expression_value(decorator).origins: + return True + return False def _call_targets( self, node: ast.AST @@ -1646,12 +1652,16 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_REDUCE_CONSUMER) if _FUNCTOOLS_MODULE in owner.origins and node.attr in {"cache", "lru_cache"}: return _origin_value(_IDENTITY_DECORATOR) + if _FUNCTOOLS_MODULE in owner.origins and node.attr == "cached_property": + return _origin_value(_CACHED_PROPERTY_DECORATOR) if _FUNCTOOLS_MODULE in owner.origins and node.attr == "wraps": return _origin_value(_WRAPS_FACTORY) if _FUNCTOOLS_MODULE in owner.origins and node.attr == "update_wrapper": return _origin_value(_UPDATE_WRAPPER) if _ITERTOOLS_MODULE in owner.origins and node.attr == "starmap": return _origin_value(_STARMAP_FACTORY) + if _ITERTOOLS_MODULE in owner.origins and node.attr == "accumulate": + return _origin_value(_ACCUMULATE_FACTORY) if _ITERTOOLS_MODULE in owner.origins and node.attr in _ITERTOOLS_CALLBACK_FACTORIES: return _origin_value(f"") if _HEAPQ_MODULE in owner.origins and node.attr in _HEAPQ_KEY_CONSUMERS: @@ -1678,7 +1688,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: sections = TRACKED_SECTIONS if node.attr == "__dict__" and sections: return _AbstractValue(serialized_sections=sections) - if node.attr == "model_dump" and sections: + if node.attr in {"model_dump", "model_dump_json"} and sections: return _AbstractValue(serialized_sections=sections) if node.attr == "model_copy" and sections: return _origin_value(*(f"{_MODEL_COPY_PREFIX}{section}>" for section in sections)) @@ -1718,17 +1728,29 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: ) methods = self._source_index.methods(owner.classes, node.attr) if resolved_modules or resolved_classes or imported_functions or methods: - callable_targets = [ - *(_CallableTarget(function) for function in imported_functions), - *( + method_targets: list[_CallableTarget] = [] + receiver = self._descriptor_receiver(owner) + for function in methods: + if ( + isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)) + and function.decorator_list + and not self._method_is_static(function) + ): + decorated = self._decorated_function_value(function) + method_targets.extend( + _CallableTarget(target.function, target.receiver or receiver) + for target in decorated.callables + ) + continue + method_targets.append( _CallableTarget( function, - None - if self._method_is_static(function) - else self._descriptor_receiver(owner), + None if self._method_is_static(function) else receiver, ) - for function in methods - ), + ) + callable_targets = [ + *(_CallableTarget(function) for function in imported_functions), + *method_targets, ] return _AbstractValue( modules=frozenset(resolved_modules), @@ -1807,6 +1829,19 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: star_arguments=True, ) return _AbstractValue(identity=frozenset({identity})) + if _ACCUMULATE_FACTORY in function_value.origins and node.args: + callback_node = node.args[1] if len(node.args) >= 2 else None + if callback_node is not None and (callbacks := self._call_targets(callback_node)): + identity = id(node) + self._deferred_callable_iterators[identity] = _DeferredCallableIterator( + callbacks=tuple( + _CallableTarget(function, receiver) for function, receiver in callbacks + ), + iterables=(self._expression_value(node.args[0]),), + accumulate=True, + ) + return _AbstractValue(identity=frozenset({identity})) + return self._expression_value(node.args[0]) itertools_factory = next( ( name @@ -1888,26 +1923,38 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue(items=(_UNKNOWN_VALUE,) if nonempty else ()) return _UNKNOWN_VALUE if function_value.serialized_sections: - if isinstance(node.func, ast.Attribute) and node.func.attr == "model_dump": + if isinstance(node.func, ast.Attribute) and node.func.attr in { + "model_dump", + "model_dump_json", + }: + is_json = node.func.attr == "model_dump_json" include = next((kw.value for kw in node.keywords if kw.arg == "include"), None) exclude = next((kw.value for kw in node.keywords if kw.arg == "exclude"), None) + selected = _safe_constant_value(include) if include is not None else None + omitted = _safe_constant_value(exclude) if exclude is not None else None + filters_are_exact = ( + include is None or isinstance(selected, (set, list, tuple)) + ) and (exclude is None or isinstance(omitted, (set, list, tuple))) + all_keys = { + field.name + for field in self._fields + if field.section in function_value.serialized_sections + } + keys = ( + (set(selected or ()) if selected is not None else set(all_keys)) + if filters_are_exact + else set(all_keys) + ) + if filters_are_exact: + keys -= set(omitted or ()) if omitted is not None else set() + if is_json: + for section in function_value.serialized_sections: + for key in keys: + self._record(section, key) + return _UNKNOWN_VALUE if include is not None or exclude is not None: - selected = _safe_constant_value(include) if include is not None else None - omitted = _safe_constant_value(exclude) if exclude is not None else None - if include is not None and not isinstance(selected, (set, list, tuple)): - return _UNKNOWN_VALUE - if exclude is not None and not isinstance(omitted, (set, list, tuple)): + if not filters_are_exact: return _UNKNOWN_VALUE - keys = ( - set(selected or ()) - if selected is not None - else { - field.name - for field in self._fields - if field.section in function_value.serialized_sections - } - ) - keys -= set(omitted or ()) if omitted is not None else set() return _AbstractValue( entries=tuple( (_key_token(ast.Constant(key)), _UNKNOWN_VALUE) @@ -2188,6 +2235,35 @@ def _consume_deferred_callable_iterator(self, node: ast.AST, *, mode: str = "ful iterable.items == () or iterable.truth is False for iterable in deferred.iterables ): continue + if deferred.accumulate: + iterable = deferred.iterables[0] + if mode == "one_turn": + continue + if iterable.items is not None: + if len(iterable.items) < 2: + continue + accumulated = iterable.items[0] + for item in iterable.items[1:]: + for target in deferred.callbacks: + values = ( + (target.receiver, accumulated, item) + if target.receiver is not None + else (accumulated, item) + ) + accumulated = self._local_direct_call_value(target.function, values) + continue + for target in deferred.callbacks: + values = ( + ( + target.receiver, + _conservative_value(iterable), + _conservative_value(iterable), + ) + if target.receiver is not None + else (_conservative_value(iterable), _conservative_value(iterable)) + ) + self._local_direct_call_value(target.function, values) + continue item_groups = [iterable.items for iterable in deferred.iterables] if all(items is not None for items in item_groups): count = min(len(items) for items in item_groups if items is not None) @@ -3568,6 +3644,10 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "functools" and alias.name == "update_wrapper" else _origin_value(_STARMAP_FACTORY) if node.module == "itertools" and alias.name == "starmap" + else _origin_value(_ACCUMULATE_FACTORY) + if node.module == "itertools" and alias.name == "accumulate" + else _origin_value(_CACHED_PROPERTY_DECORATOR) + if node.module == "functools" and alias.name == "cached_property" else _origin_value(f"") if node.module == "itertools" and alias.name in _ITERTOOLS_CALLBACK_FACTORIES else _origin_value(f"") diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 0de613ca4f..fdcaf3250e 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -377,6 +377,35 @@ def test_runtime_scan_does_not_execute_empty_standard_library_callback_consumers assert contract.runtime_reads(tmp_path, fields) == frozenset() +def test_runtime_scan_executes_consumed_itertools_accumulate_callbacks( + contract, tmp_path: Path +) -> None: + (tmp_path / "accumulate_callbacks.py").write_text( + """ +import itertools +from itertools import accumulate + +def pick(left, section): + return section.stage1_enabled + +list(itertools.accumulate((None, config.evaluation), pick)) +list(accumulate((None, config.evaluation), lambda left, section: section.stage2_enabled)) +list(accumulate((), lambda left, section: section.stage3_enabled)) +list(accumulate((config.evaluation,), lambda left, section: section.semantic_model)) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled", "semantic_model") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields - { + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("evaluation", "semantic_model"), + } + + def test_runtime_scan_tracks_exit_stack_and_async_context_manager_entries( contract, tmp_path: Path ) -> None: @@ -482,6 +511,29 @@ def value(self): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_rejects_shadowed_property_decorators(contract, tmp_path: Path) -> None: + (tmp_path / "shadowed_property.py").write_text( + """ +def property(fn): + return lambda self: None + +class Reader: + def __init__(self, section): + self.section = section + + @property + def value(self): + return self.section.stage1_enabled + +Reader(config.evaluation).value() +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + def test_runtime_scan_tracks_exact_serialized_mapping_consumers(contract, tmp_path: Path) -> None: (tmp_path / "serialized_consumers.py").write_text( """ @@ -2767,6 +2819,29 @@ def read(config): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_tracks_model_dump_json_serialization_filters( + contract, tmp_path: Path +) -> None: + (tmp_path / "model_dump_json.py").write_text( + """ +config.evaluation.model_dump_json(include={"stage1_enabled"}) +config.evaluation.model_dump_json(exclude={"stage1_enabled", "stage3_enabled"}) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + } + ) + + def test_runtime_scan_tracks_exact_functools_wraps_and_update_wrapper( contract, tmp_path: Path ) -> None: From cb97a5d681957372785a0c9c99fa1045c7fc4d9a Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 15:30:13 +0900 Subject: [PATCH 45/70] fix(config): resolve builtin scanner identities --- scripts/check-config-reference-contract.py | 48 +++++++++-- .../test_check_config_reference_contract.py | 82 +++++++++++++++++++ 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 244ac3f71d..70b93bf4fd 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -242,6 +242,8 @@ class _DeferredPartial: _METHODCALLER_FACTORY = "" _OPERATOR_GETITEM = "" _BUILTINS_MODULE = "" +_DICT_BUILTIN = "" +_CLASSMETHOD_DECORATOR = "" _GETATTR_BUILTIN = "" _BOUND_GETATTRIBUTE_PREFIX = " bool: return True return False + def _method_is_classmethod(self, function: _FunctionNode) -> bool: + if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + return False + for decorator in function.decorator_list: + if isinstance(decorator, ast.Name) and decorator.id == "classmethod": + return not any(decorator.id in scope for scope in self._states) + if _CLASSMETHOD_DECORATOR in self._expression_value(decorator).origins: + return True + return False + def _call_targets( self, node: ast.AST ) -> tuple[tuple[_FunctionNode, _AbstractValue | None], ...]: @@ -1581,6 +1593,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if node.id == "hasattr" and not self._name_is_bound("hasattr") else _origin_value(_OBJECT_BUILTIN) if node.id == "object" and not self._name_is_bound("object") + else _origin_value(_DICT_BUILTIN) + if node.id == "dict" and not self._name_is_bound("dict") + else _origin_value(_CLASSMETHOD_DECORATOR) + if node.id == "classmethod" and not self._name_is_bound("classmethod") else _origin_value(_VARS_BUILTIN) if node.id == "vars" and not self._name_is_bound("vars") else _origin_value(_RANGE_BUILTIN) @@ -1634,6 +1650,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(f"") if _BUILTINS_MODULE in owner.origins and node.attr == "getattr": return _origin_value(_GETATTR_BUILTIN) + if _BUILTINS_MODULE in owner.origins and node.attr == "dict": + return _origin_value(_DICT_BUILTIN) + if _BUILTINS_MODULE in owner.origins and node.attr == "classmethod": + return _origin_value(_CLASSMETHOD_DECORATOR) if _BUILTINS_MODULE in owner.origins and node.attr == "hasattr": return _origin_value(_HASATTR_BUILTIN) if _OBJECT_BUILTIN in owner.origins and node.attr == "__getattribute__": @@ -1731,6 +1751,14 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: method_targets: list[_CallableTarget] = [] receiver = self._descriptor_receiver(owner) for function in methods: + if self._method_is_classmethod(function): + method_targets.append( + _CallableTarget( + function, + _AbstractValue(classes=owner.classes), + ) + ) + continue if ( isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)) and function.decorator_list @@ -1880,9 +1908,9 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if function_value.origins & {_ATTRGETTER_FACTORY, _ITEMGETTER_FACTORY}: return _AbstractValue( accessed_attributes=frozenset( - argument.value + field_name for argument in node.args - if isinstance(argument, ast.Constant) and isinstance(argument.value, str) + if (field_name := self._expression_value(argument).string_value) is not None ) ) if _METHODCALLER_FACTORY in function_value.origins: @@ -2049,7 +2077,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: instance_classes=constructor_classes, ) return self._bind_partialmethod_descriptors(instance) - if isinstance(node.func, ast.Name) and node.func.id == "dict": + if _DICT_BUILTIN in function_value.origins: if node.args: sections = self._expression_value(node.args[0]).origins & TRACKED_SECTIONS if sections: @@ -2357,9 +2385,13 @@ def visit_Call(self, node: ast.Call) -> None: value = self._expression_value( argument.value if isinstance(argument, ast.Starred) else argument ) - for section in (value.origins & TRACKED_SECTIONS) | value.serialized_sections: - for name in accessor.accessed_attributes: - self._record(section, name.partition(".")[0]) + for name in accessor.accessed_attributes: + parts = name.split(".") + if _CONFIG_ROOT in value.origins and len(parts) >= 2: + if parts[0] in TRACKED_SECTIONS: + self._record(parts[0], parts[1]) + for section in (value.origins & TRACKED_SECTIONS) | value.serialized_sections: + self._record(section, parts[0]) if isinstance(node.func, ast.Attribute) and node.func.attr == "format_map" and node.args: template = self._expression_value(node.func.value).string_value mapping = self._expression_value(node.args[0]) @@ -3622,6 +3654,10 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "operator" and alias.name == "getitem" else _origin_value(_GETATTR_BUILTIN) if node.module == "builtins" and alias.name == "getattr" + else _origin_value(_DICT_BUILTIN) + if node.module == "builtins" and alias.name == "dict" + else _origin_value(_CLASSMETHOD_DECORATOR) + if node.module == "builtins" and alias.name == "classmethod" else _origin_value(_HASATTR_BUILTIN) if node.module == "builtins" and alias.name == "hasattr" else _origin_value(_VARS_BUILTIN) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index fdcaf3250e..123404f4fc 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -256,6 +256,35 @@ def test_runtime_scan_tracks_concrete_mapping_serializers(contract, tmp_path: Pa assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_tracks_exact_builtin_dict_aliases_and_rejects_shadowing( + contract, tmp_path: Path +) -> None: + (tmp_path / "dict_identity.py").write_text( + """ +import builtins + +to_mapping = dict +to_mapping(config.evaluation)["stage1_enabled"] +builtins.dict(config.evaluation)["stage1_enabled"] + +def shadow(dict): + return dict(config.evaluation)["stage2_enabled"] + +dict = None +dict(config.evaluation)["stage3_enabled"] +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == { + contract.ConfigField("evaluation", "stage1_enabled") + } + + @pytest.mark.parametrize( "expression", ( @@ -2933,6 +2962,59 @@ def test_runtime_scan_tracks_operator_attrgetter_reads(contract, tmp_path: Path) assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_tracks_constant_and_dotted_operator_getters(contract, tmp_path: Path) -> None: + (tmp_path / "operator_constants.py").write_text( + """ +import operator +from operator import attrgetter, itemgetter + +FIELD = "stage1_enabled" +attrgetter(FIELD)(config.evaluation) +operator.attrgetter("evaluation.stage2_enabled")(config) +itemgetter(FIELD)(config.evaluation.model_dump()) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_tracks_unshadowed_classmethod_and_rejects_shadowing( + contract, tmp_path: Path +) -> None: + (tmp_path / "classmethod_read.py").write_text( + """ +class Reader: + @classmethod + def read(cls, section): + return section.stage1_enabled + +Reader.read(config.evaluation) + +def classmethod(fn): + return lambda *args: None + +class Shadow: + @classmethod + def read(cls, section): + return section.stage2_enabled + +Shadow.read(config.evaluation) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == { + contract.ConfigField("evaluation", "stage1_enabled") + } + + def test_runtime_scan_tracks_partial_wrapped_builtin_attribute_reads( contract, tmp_path: Path ) -> None: From 73e180d9b345157206b33c9c3feb8f490742d15e Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 15:46:12 +0900 Subject: [PATCH 46/70] fix(config): preserve descriptor and callback identity --- scripts/check-config-reference-contract.py | 65 +++++++++++++++-- .../test_check_config_reference_contract.py | 71 +++++++++++++++++++ 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 70b93bf4fd..dfc250c0e1 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -244,6 +244,8 @@ class _DeferredPartial: _BUILTINS_MODULE = "" _DICT_BUILTIN = "" _CLASSMETHOD_DECORATOR = "" +_STATICMETHOD_DECORATOR = "" +_PROPERTY_DECORATOR = "" _GETATTR_BUILTIN = "" _BOUND_GETATTRIBUTE_PREFIX = " _FunctionSet: return self._function_value(node.body) | self._function_value(node.orelse) return frozenset() - @staticmethod - def _method_is_static(function: _FunctionNode) -> bool: - return isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)) and any( - _callable_name(decorator) == "staticmethod" for decorator in function.decorator_list - ) + def _method_is_static(self, function: _FunctionNode) -> bool: + if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + return False + for decorator in function.decorator_list: + if isinstance(decorator, ast.Name) and decorator.id == "staticmethod": + return not any(decorator.id in scope for scope in self._states) + if _STATICMETHOD_DECORATOR in self._expression_value(decorator).origins: + return True + return False def _method_is_property(self, function: _FunctionNode) -> bool: if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): @@ -1090,6 +1096,8 @@ def _method_is_property(self, function: _FunctionNode) -> bool: for decorator in function.decorator_list: if isinstance(decorator, ast.Name) and decorator.id == "property": return not any(decorator.id in scope for scope in self._states) + if _PROPERTY_DECORATOR in self._expression_value(decorator).origins: + return True if _CACHED_PROPERTY_DECORATOR in self._expression_value(decorator).origins: return True return False @@ -1597,6 +1605,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if node.id == "dict" and not self._name_is_bound("dict") else _origin_value(_CLASSMETHOD_DECORATOR) if node.id == "classmethod" and not self._name_is_bound("classmethod") + else _origin_value(_STATICMETHOD_DECORATOR) + if node.id == "staticmethod" and not self._name_is_bound("staticmethod") + else _origin_value(_PROPERTY_DECORATOR) + if node.id == "property" and not self._name_is_bound("property") else _origin_value(_VARS_BUILTIN) if node.id == "vars" and not self._name_is_bound("vars") else _origin_value(_RANGE_BUILTIN) @@ -1654,6 +1666,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_DICT_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr == "classmethod": return _origin_value(_CLASSMETHOD_DECORATOR) + if _BUILTINS_MODULE in owner.origins and node.attr == "staticmethod": + return _origin_value(_STATICMETHOD_DECORATOR) + if _BUILTINS_MODULE in owner.origins and node.attr == "property": + return _origin_value(_PROPERTY_DECORATOR) if _BUILTINS_MODULE in owner.origins and node.attr == "hasattr": return _origin_value(_HASATTR_BUILTIN) if _OBJECT_BUILTIN in owner.origins and node.attr == "__getattribute__": @@ -2215,6 +2231,15 @@ def visit_Attribute(self, node: ast.Attribute) -> None: if isinstance(node.ctx, ast.Load): self._record_possible_exception(before) + def visit_Expr(self, node: ast.Expr) -> None: + self.visit(node.value) + if isinstance(node.value, ast.Attribute) and isinstance(node.value.ctx, ast.Load): + # A descriptor executes even when its result is discarded. Most + # expression consumers ask for the abstract value themselves, but + # a standalone attribute expression otherwise only visits its + # owner and would skip a property getter entirely. + self._expression_value(node.value) + def visit_IfExp(self, node: ast.IfExp) -> None: self.visit(node.test) truth = self._static_truth(node.test) @@ -2333,10 +2358,32 @@ def visit_Call(self, node: ast.Call) -> None: self._consume_deferred_callable_iterator(argument, mode=mode) key_keyword = next((keyword for keyword in node.keywords if keyword.arg == "key"), None) if key_keyword is not None and node.args: - iterable_index = 1 if accessor.origins & _HEAPQ_KEY_CONSUMER_ORIGINS else 0 + if accessor.origins & _HEAPQ_KEY_CONSUMER_ORIGINS: + limit = _safe_constant_value(node.args[0]) + if isinstance(limit, int) and limit <= 0: + iterable_index = None + else: + iterable_index = 1 + elif ( + "" in accessor.origins + or "" in accessor.origins + ): + iterable_index = 0 + if len(node.args) > 1 and all( + self._expression_value(argument).items is None for argument in node.args + ): + for argument in node.args: + for function, receiver in self._call_targets(key_keyword.value): + values = ((receiver,) if receiver is not None else ()) + ( + self._expression_value(argument), + ) + self._local_direct_call_value(function, values) + iterable_index = None + else: + iterable_index = 0 iterable = ( self._expression_value(node.args[iterable_index]) - if len(node.args) > iterable_index + if iterable_index is not None and len(node.args) > iterable_index else _UNKNOWN_VALUE ) for item in iterable.items or (): @@ -3658,6 +3705,10 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "builtins" and alias.name == "dict" else _origin_value(_CLASSMETHOD_DECORATOR) if node.module == "builtins" and alias.name == "classmethod" + else _origin_value(_STATICMETHOD_DECORATOR) + if node.module == "builtins" and alias.name == "staticmethod" + else _origin_value(_PROPERTY_DECORATOR) + if node.module == "builtins" and alias.name == "property" else _origin_value(_HASATTR_BUILTIN) if node.module == "builtins" and alias.name == "hasattr" else _origin_value(_VARS_BUILTIN) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 123404f4fc..e8bc33dbff 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -383,6 +383,33 @@ def star(prefix, section): assert contract.runtime_reads(tmp_path, fields) == fields +def test_runtime_scan_models_min_max_and_heap_key_callback_cardinality( + contract, tmp_path: Path +) -> None: + (tmp_path / "key_callback_cardinality.py").write_text( + """ +import heapq + +def read_stage1(section): + return section.stage1_enabled + +def read_stage2(section): + return section.stage2_enabled + +max(config.evaluation, config.evaluation, key=read_stage1) +heapq.nsmallest(0, [config.evaluation], key=read_stage2) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == { + contract.ConfigField("evaluation", "stage1_enabled") + } + + def test_runtime_scan_does_not_execute_empty_standard_library_callback_consumers( contract, tmp_path: Path ) -> None: @@ -563,6 +590,50 @@ def value(self): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() +def test_runtime_scan_resolves_builtin_descriptor_aliases_and_replacements( + contract, tmp_path: Path +) -> None: + (tmp_path / "descriptor_identity.py").write_text( + """ +from builtins import property as prop +from builtins import staticmethod as sm + +class Reader: + @sm + def static(section): + return section.stage2_enabled + + def __init__(self, section): + self.section = section + + @prop + def value(self): + return self.section.stage3_enabled + +Reader.static(config.evaluation) +Reader(config.evaluation).value + +staticmethod = lambda fn: lambda *args: None + +class Shadow: + @staticmethod + def static(section): + return section.stage1_enabled + +Shadow.static(config.evaluation) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields - { + contract.ConfigField("evaluation", "stage1_enabled") + } + + def test_runtime_scan_tracks_exact_serialized_mapping_consumers(contract, tmp_path: Path) -> None: (tmp_path / "serialized_consumers.py").write_text( """ From fdf1595d98f6f4f000269d4911149b129797b6d8 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 16:06:46 +0900 Subject: [PATCH 47/70] fix(config): model iterator and exit callback reachability --- scripts/check-config-reference-contract.py | 47 +++++++++++++++++-- .../test_check_config_reference_contract.py | 47 +++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index dfc250c0e1..ea36c5defd 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -267,6 +267,8 @@ class _DeferredPartial: _HEAPQ_KEY_CONSUMER_ORIGINS = frozenset( f"" for name in _HEAPQ_KEY_CONSUMERS ) +_ATEXIT_MODULE = "" +_ATEXIT_REGISTER = "" _ASYNCIO_MODULE = "" _ASYNCIO_CONSUMERS = frozenset({"create_task", "ensure_future", "gather", "run"}) _ASYNCIO_CONSUMER_ORIGINS = frozenset(f"" for name in _ASYNCIO_CONSUMERS) @@ -1678,6 +1680,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_VARS_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr == "range": return _origin_value(_RANGE_BUILTIN) + if _ATEXIT_MODULE in owner.origins and node.attr == "register": + return _origin_value(_ATEXIT_REGISTER) if _BUILTINS_MODULE in owner.origins and node.attr in _TRACKED_BUILTIN_CONSUMERS: return _origin_value(f"") if _FUNCTOOLS_MODULE in owner.origins and node.attr == "partial": @@ -2253,8 +2257,9 @@ def visit_BoolOp(self, node: ast.BoolOp) -> None: for value in self._reachable_bool_values(node): self.visit(value) - def _consume_deferred_generator(self, node: ast.AST, *, mode: str = "full") -> None: - value = self._expression_value(node) + def _consume_deferred_generator_value( + self, value: _AbstractValue, *, mode: str = "full" + ) -> None: for identity in value.identity: deferred = self._deferred_generators.get(identity) if deferred is None: @@ -2270,6 +2275,28 @@ def _consume_deferred_generator(self, node: ast.AST, *, mode: str = "full") -> N generator_identity=identity, ) + def _consume_deferred_generator(self, node: ast.AST, *, mode: str = "full") -> None: + self._consume_deferred_generator_value(self._expression_value(node), mode=mode) + + def _consume_local_iterator(self, node: ast.AST, *, mode: str = "full") -> None: + owner = self._expression_value(node) + for function in self._source_index.methods(owner.instance_classes, "__iter__"): + scoped = self._bound_direct_arguments(function, (self._descriptor_receiver(owner),)) + if _is_generator_function(function): + self._visit_function_body( + function, + scoped, + consume_generator=True, + generator_consumer_mode=mode, + generator_identity=id(node) ^ id(function), + ) + continue + returned = self._local_direct_call_value( + function, + (self._descriptor_receiver(owner),), + ) + self._consume_deferred_generator_value(returned, mode=mode) + def _consume_deferred_coroutine(self, node: ast.AST) -> None: value = self._expression_value(node) for identity in value.identity: @@ -2356,6 +2383,7 @@ def visit_Call(self, node: ast.Call) -> None: for argument in node.args: self._consume_deferred_generator(argument, mode=mode) self._consume_deferred_callable_iterator(argument, mode=mode) + self._consume_local_iterator(argument, mode=mode) key_keyword = next((keyword for keyword in node.keywords if keyword.arg == "key"), None) if key_keyword is not None and node.args: if accessor.origins & _HEAPQ_KEY_CONSUMER_ORIGINS: @@ -2416,6 +2444,13 @@ def visit_Call(self, node: ast.Call) -> None: if accessor.origins & _ASYNCIO_CONSUMER_ORIGINS: for argument in node.args: self._consume_deferred_coroutine(argument) + if _ATEXIT_REGISTER in accessor.origins and node.args: + callback_arguments = tuple( + self._expression_value(argument) for argument in node.args[1:] + ) + for function, receiver in self._call_targets(node.args[0]): + values = ((receiver,) if receiver is not None else ()) + callback_arguments + self._local_direct_call_value(function, values) if ( isinstance(node.func, ast.Attribute) and node.func.attr == "send" @@ -3650,6 +3685,8 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: if alias.name == "itertools" else _origin_value(_HEAPQ_MODULE) if alias.name == "heapq" + else _origin_value(_ATEXIT_MODULE) + if alias.name == "atexit" else _origin_value(_ASYNCIO_MODULE) if alias.name == "asyncio" else _origin_value(_CONTEXTLIB_MODULE) @@ -3739,6 +3776,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "itertools" and alias.name in _ITERTOOLS_CALLBACK_FACTORIES else _origin_value(f"") if node.module == "heapq" and alias.name in _HEAPQ_KEY_CONSUMERS + else _origin_value(_ATEXIT_REGISTER) + if node.module == "atexit" and alias.name == "register" else _origin_value(f"") if node.module == "asyncio" and alias.name in _ASYNCIO_CONSUMERS else _origin_value(_NULLCONTEXT_FACTORY) @@ -4465,7 +4504,9 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: try: for statement in node.body: self.visit(statement) - self._class_member_values[id(node)] = tuple(self._states[-1].values()) + self._class_member_values[id(node)] = tuple( + value for name, value in self._states[-1].items() if not name.startswith("_") + ) finally: self._nonlocal_names.pop() self._global_names.pop() diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index e8bc33dbff..2d4e9ce9bc 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -383,6 +383,39 @@ def star(prefix, section): assert contract.runtime_reads(tmp_path, fields) == fields +def test_runtime_scan_consumes_local_iterator_protocols(contract, tmp_path: Path) -> None: + (tmp_path / "local_iterator.py").write_text( + """ +class _Reader: + def __iter__(self): + yield settings.evaluation.stage1_enabled + +list(_Reader()) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_executes_registered_exit_callbacks(contract, tmp_path: Path) -> None: + (tmp_path / "exit_callback.py").write_text( + """ +import atexit + +def _reader(section): + return section.stage2_enabled + +atexit.register(_reader, settings.evaluation) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage2_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + def test_runtime_scan_models_min_max_and_heap_key_callback_cardinality( contract, tmp_path: Path ) -> None: @@ -3609,6 +3642,20 @@ def _never_called(config): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() +def test_runtime_scan_ignores_uninvoked_private_class_members(contract, tmp_path: Path) -> None: + (tmp_path / "uninvoked_private_member.py").write_text( + """ +class PublicReader: + def _never_called(self, config): + return config.evaluation.stage1_enabled +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + def test_erased_definitions_cannot_satisfy_full_config_audit(contract, tmp_path: Path) -> None: (tmp_path / "erased_definitions.py").write_text( """ From 1c02cd88c79b2a6ca88ce65420ff1507cfd36dc7 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 16:21:39 +0900 Subject: [PATCH 48/70] fix(ci): preserve accessor callback provenance --- scripts/check-config-reference-contract.py | 112 +++++++++++++----- .../test_check_config_reference_contract.py | 57 +++++++++ 2 files changed, 140 insertions(+), 29 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index ea36c5defd..92efd7b1ca 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -212,6 +212,7 @@ class _DeferredCallableIterator: """A lazy map/filter adapter and the values captured at construction.""" callbacks: tuple[_CallableTarget, ...] + callback_value: _AbstractValue iterables: tuple[_AbstractValue, ...] star_arguments: bool = False accumulate: bool = False @@ -1128,6 +1129,29 @@ def _call_targets( targets.extend((function, None) for function in self._function_value(node)) return tuple(targets) + @staticmethod + def _is_accessor_callback(value: _AbstractValue) -> bool: + return bool(value.accessed_attributes or _GETATTR_BUILTIN in value.origins) + + def _consume_accessor_callback( + self, callback: _AbstractValue, arguments: tuple[_AbstractValue, ...] + ) -> None: + """Apply modeled builtin/accessor callbacks to their runtime arguments.""" + if _GETATTR_BUILTIN in callback.origins and len(arguments) >= 2: + field_name = arguments[1].string_value + if field_name is not None: + for section in arguments[0].origins & TRACKED_SECTIONS: + self._record(section, field_name) + for field_name in callback.accessed_attributes: + first = field_name.partition(".")[0] + for argument in arguments: + if _CONFIG_ROOT in argument.origins and "." in field_name: + section, _, field = field_name.partition(".") + if section in TRACKED_SECTIONS: + self._record(section, field) + for section in argument.origins & TRACKED_SECTIONS: + self._record(section, first) + def _call_has_relevant_provenance( self, node: ast.Call, @@ -1832,21 +1856,22 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: iterable_arguments = ( node.args[1:] if _callable_name(node.func) in {"filter", "map"} else node.args ) - if ( - _callable_name(node.func) in {"filter", "map"} - and node.args - and (callbacks := self._call_targets(node.args[0])) - ): - identity = id(node) - self._deferred_callable_iterators[identity] = _DeferredCallableIterator( - callbacks=tuple( - _CallableTarget(function, receiver) for function, receiver in callbacks - ), - iterables=tuple( - self._expression_value(argument) for argument in iterable_arguments - ), - ) - return _AbstractValue(identity=frozenset({identity})) + if _callable_name(node.func) in {"filter", "map"} and node.args: + callback_value = self._expression_value(node.args[0]) + callbacks = self._call_targets(node.args[0]) + if callbacks or self._is_accessor_callback(callback_value): + identity = id(node) + self._deferred_callable_iterators[identity] = _DeferredCallableIterator( + callbacks=tuple( + _CallableTarget(function, receiver) + for function, receiver in callbacks + ), + callback_value=callback_value, + iterables=tuple( + self._expression_value(argument) for argument in iterable_arguments + ), + ) + return _AbstractValue(identity=frozenset({identity})) return _join_values( *(self._expression_value(argument) for argument in iterable_arguments) ) @@ -1873,18 +1898,28 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: _CallableTarget(function, receiver) for function, receiver in self._call_targets(node.args[0]) ), + callback_value=self._expression_value(node.args[0]), iterables=(self._expression_value(node.args[1]),), star_arguments=True, ) return _AbstractValue(identity=frozenset({identity})) if _ACCUMULATE_FACTORY in function_value.origins and node.args: callback_node = node.args[1] if len(node.args) >= 2 else None - if callback_node is not None and (callbacks := self._call_targets(callback_node)): + callback_value = ( + self._expression_value(callback_node) + if callback_node is not None + else _UNKNOWN_VALUE + ) + callbacks = self._call_targets(callback_node) if callback_node is not None else () + if callback_node is not None and ( + callbacks or self._is_accessor_callback(callback_value) + ): identity = id(node) self._deferred_callable_iterators[identity] = _DeferredCallableIterator( callbacks=tuple( _CallableTarget(function, receiver) for function, receiver in callbacks ), + callback_value=callback_value, iterables=(self._expression_value(node.args[0]),), accumulate=True, ) @@ -1907,19 +1942,20 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: callback_node = key elif len(node.args) >= 2: callback_node, iterable_node = node.args[:2] - if ( - callback_node is not None - and iterable_node is not None - and (callbacks := self._call_targets(callback_node)) - ): - identity = id(node) - self._deferred_callable_iterators[identity] = _DeferredCallableIterator( - callbacks=tuple( - _CallableTarget(function, receiver) for function, receiver in callbacks - ), - iterables=(self._expression_value(iterable_node),), - ) - return _AbstractValue(identity=frozenset({identity})) + if callback_node is not None and iterable_node is not None: + callback_value = self._expression_value(callback_node) + callbacks = self._call_targets(callback_node) + if callbacks or self._is_accessor_callback(callback_value): + identity = id(node) + self._deferred_callable_iterators[identity] = _DeferredCallableIterator( + callbacks=tuple( + _CallableTarget(function, receiver) + for function, receiver in callbacks + ), + callback_value=callback_value, + iterables=(self._expression_value(iterable_node),), + ) + return _AbstractValue(identity=frozenset({identity})) return ( self._expression_value(iterable_node) if iterable_node is not None @@ -2324,6 +2360,9 @@ def _consume_deferred_callable_iterator(self, node: ast.AST, *, mode: str = "ful continue accumulated = iterable.items[0] for item in iterable.items[1:]: + self._consume_accessor_callback( + deferred.callback_value, (accumulated, item) + ) for target in deferred.callbacks: values = ( (target.receiver, accumulated, item) @@ -2343,6 +2382,10 @@ def _consume_deferred_callable_iterator(self, node: ast.AST, *, mode: str = "ful else (_conservative_value(iterable), _conservative_value(iterable)) ) self._local_direct_call_value(target.function, values) + self._consume_accessor_callback( + deferred.callback_value, + (_conservative_value(iterable), _conservative_value(iterable)), + ) continue item_groups = [iterable.items for iterable in deferred.iterables] if all(items is not None for items in item_groups): @@ -2361,6 +2404,7 @@ def _consume_deferred_callable_iterator(self, node: ast.AST, *, mode: str = "ful if deferred.star_arguments and len(arguments) == 1: item = arguments[0] arguments = item.items or (_conservative_value(item),) + self._consume_accessor_callback(deferred.callback_value, arguments) for target in deferred.callbacks: values = ( (target.receiver, *arguments) if target.receiver is not None else arguments @@ -2401,6 +2445,10 @@ def visit_Call(self, node: ast.Call) -> None: self._expression_value(argument).items is None for argument in node.args ): for argument in node.args: + self._consume_accessor_callback( + self._expression_value(key_keyword.value), + (self._expression_value(argument),), + ) for function, receiver in self._call_targets(key_keyword.value): values = ((receiver,) if receiver is not None else ()) + ( self._expression_value(argument), @@ -2415,6 +2463,9 @@ def visit_Call(self, node: ast.Call) -> None: else _UNKNOWN_VALUE ) for item in iterable.items or (): + self._consume_accessor_callback( + self._expression_value(key_keyword.value), (item,) + ) for function, receiver in self._call_targets(key_keyword.value): values = ((receiver,) if receiver is not None else ()) + (item,) self._local_direct_call_value(function, values) @@ -2423,6 +2474,9 @@ def visit_Call(self, node: ast.Call) -> None: owner = self._expression_value(node.func.value) if key_keyword is not None: for item in owner.items or (): + self._consume_accessor_callback( + self._expression_value(key_keyword.value), (item,) + ) for function, receiver in self._call_targets(key_keyword.value): values = ((receiver,) if receiver is not None else ()) + (item,) self._local_direct_call_value(function, values) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 2d4e9ce9bc..84ed0ec7a1 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3066,6 +3066,63 @@ def test_runtime_scan_tracks_operator_attrgetter_reads(contract, tmp_path: Path) assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_executes_accessor_callbacks_in_eager_consumers( + contract, tmp_path: Path +) -> None: + (tmp_path / "accessor_callbacks.py").write_text( + """ +import operator + +list(map(operator.attrgetter("stage1_enabled"), [config.evaluation])) +key = operator.attrgetter("stage2_enabled") +sorted([config.evaluation], key=key) +list(map(getattr, [config.evaluation], ["stage3_enabled"])) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + reads = contract.runtime_reads(tmp_path, fields) + + assert reads == fields + for field in fields: + assert _audit_as_documented_inert(contract, field, reads).violations + + +def test_runtime_scan_does_not_execute_stale_or_shadowed_accessor_callbacks( + contract, tmp_path: Path +) -> None: + (tmp_path / "inactive_accessor_callbacks.py").write_text( + """ +import operator + +pending = map(operator.attrgetter("stage1_enabled"), [config.evaluation]) +key = operator.attrgetter("stage2_enabled") +key = lambda section: None +sorted([config.evaluation], key=key) + +def getattr(section, name): + return None + +list(map(getattr, [config.evaluation], ["stage3_enabled"])) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + reads = contract.runtime_reads(tmp_path, fields) + + assert reads == frozenset() + for field in fields: + assert _audit_as_documented_inert(contract, field, reads).violations == () + + def test_runtime_scan_tracks_constant_and_dotted_operator_getters(contract, tmp_path: Path) -> None: (tmp_path / "operator_constants.py").write_text( """ From 9c2d260baeb3b75e2439f6c4d163d130f1054b5f Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 16:42:04 +0900 Subject: [PATCH 49/70] fix(ci): model iterator consumption and local raises --- scripts/check-config-reference-contract.py | 120 +++++++++++++++--- .../test_check_config_reference_contract.py | 95 ++++++++++++++ 2 files changed, 200 insertions(+), 15 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 92efd7b1ca..368a3c4560 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -939,6 +939,15 @@ class _FlowResult: abrupt: tuple[_AbruptPath, ...] = () +@dataclass(frozen=True) +class _FunctionExecution: + """Exact local-call values and whether control can return to the caller.""" + + values: tuple[_AbstractValue, ...] + returns_to_caller: bool + raises: tuple[_AbruptPath, ...] = () + + class _RuntimeReadVisitor(ast.NodeVisitor): """Collect config reads with conservative flow- and binding-aware provenance.""" @@ -964,6 +973,7 @@ def __init__( self._global_names: list[set[str]] = [set()] self._nonlocal_names: list[set[str]] = [set()] self._active_calls: set[int] = set() + self._call_executions: dict[int, list[_FunctionExecution]] = {} self._expression_cache: dict[int, _AbstractValue] = {} self._flow_abrupts: list[list[_AbruptPath]] = [[]] self._path_reachable = True @@ -1181,6 +1191,42 @@ def _call_has_relevant_provenance( return True return bound_receiver is not None and bool(_contained_origins(bound_receiver) & tracked) + def _direct_raise_execution(self, function: _FunctionNode) -> _FunctionExecution | None: + """Recognize an exact local helper whose first reachable action raises.""" + if isinstance(function, ast.Lambda): + return None + for statement in function.body: + if ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Constant) + and isinstance(statement.value.value, str) + ): + continue + if isinstance( + statement, + (ast.Pass, ast.Import, ast.ImportFrom, ast.Assign, ast.AnnAssign), + ): + continue + if not isinstance(statement, ast.Raise): + return None + exception_type = ( + self._exception_name(statement.exc) if statement.exc is not None else None + ) + leaf = exception_type.rsplit(".", 1)[-1] if exception_type else None + return _FunctionExecution( + (), + False, + ( + _AbruptPath( + "raise", + self._binding_snapshot(), + exception_type=leaf, + exception_upper_bound=leaf or "BaseException", + ), + ), + ) + return None + @staticmethod def _named_value( pairs: tuple[tuple[str, _AbstractValue], ...] | None, name: str @@ -2096,11 +2142,13 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: } if model_copy_sections: return _origin_value(*model_copy_sections) - values = [ - self._local_call_value(node, function, bound_receiver) - for function, bound_receiver in self._call_targets(node.func) - if self._call_has_relevant_provenance(node, function, bound_receiver) - ] + values: list[_AbstractValue] = [] + for function, bound_receiver in self._call_targets(node.func): + if self._call_has_relevant_provenance(node, function, bound_receiver): + values.append(self._local_call_value(node, function, bound_receiver)) + continue + if execution := self._direct_raise_execution(function): + self._call_executions.setdefault(id(node), []).append(execution) if values: value = _join_values(*values) self._expression_cache[id(node)] = value @@ -2414,6 +2462,9 @@ def _consume_deferred_callable_iterator(self, node: ast.AST, *, mode: str = "ful def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() accessor = self._expression_value(node.func) + if isinstance(node.func, ast.Attribute) and node.func.attr == "extend": + for argument in node.args: + self._consume_deferred_callable_iterator(argument) if accessor.origins & _EAGER_CONSUMER_ORIGINS: mode = ( "one_turn" @@ -2565,6 +2616,22 @@ def visit_Call(self, node: ast.Call) -> None: for section in self._expression_value(node.args[0]).serialized_sections: self._record(section, field_name) self.generic_visit(node) + executions = self._call_executions.pop(id(node), []) + if executions and all(not execution.returns_to_caller for execution in executions): + for execution in executions: + for path in execution.raises: + self._append_abrupt( + self._flow_abrupts[-1], + _AbruptPath( + "raise", + before, + exception_type=path.exception_type, + exception_exclusions=path.exception_exclusions, + exception_upper_bound=path.exception_upper_bound, + ), + ) + self._path_reachable = False + return self._record_possible_exception(before) def visit_Await(self, node: ast.Await) -> None: @@ -2588,11 +2655,20 @@ def visit_Await(self, node: ast.Await) -> None: def visit_Starred(self, node: ast.Starred) -> None: """Iterable unpacking eagerly consumes a deferred generator.""" self._consume_deferred_generator(node.value) + self._consume_deferred_callable_iterator(node.value) self.generic_visit(node) def visit_YieldFrom(self, node: ast.YieldFrom) -> None: """``yield from`` eagerly consumes its delegated iterable.""" self._consume_deferred_generator(node.value) + self._consume_deferred_callable_iterator(node.value) + self.generic_visit(node) + + def visit_Compare(self, node: ast.Compare) -> None: + """Membership checks consume their right-hand iterable.""" + for operator, comparator in zip(node.ops, node.comparators, strict=True): + if isinstance(operator, (ast.In, ast.NotIn)): + self._consume_deferred_callable_iterator(comparator, mode="one_turn") self.generic_visit(node) def visit_Subscript(self, node: ast.Subscript) -> None: @@ -2875,6 +2951,8 @@ def _assign_store_target(self, target: ast.expr, value: _AbstractValue) -> None: self._replace_shared_value(owner, replacement, target.value) def visit_Assign(self, node: ast.Assign) -> None: + if any(isinstance(target, (ast.Tuple, ast.List)) for target in node.targets): + self._consume_deferred_callable_iterator(node.value) self.visit(node.value) value = self._expression_value(node.value) annotation_value = self._annotation_value(node.value) @@ -2994,6 +3072,7 @@ def visit_For(self, node: ast.For) -> None: else: mode = "one_turn" if node.body and isinstance(node.body[0], ast.Break) else "full" self._consume_deferred_generator(node.iter, mode=mode) + self._consume_deferred_callable_iterator(node.iter, mode=mode) self.visit(node.iter) iterable = self._expression_value(node.iter) zero_iterations_possible = self._static_truth(node.iter) is not True @@ -3560,6 +3639,8 @@ def _visit_comprehension( node: ast.ListComp | ast.SetComp | ast.GeneratorExp | ast.DictComp, ) -> None: first, *remaining = node.generators + if not isinstance(node, ast.GeneratorExp): + self._consume_deferred_callable_iterator(first.iter) self.visit(first.iter) first_value = self._expression_value(first.iter) self._states.append({}) @@ -3577,6 +3658,7 @@ def _visit_comprehension( self._expression_cache[id(node)] = _UNKNOWN_VALUE return for generator in remaining: + self._consume_deferred_callable_iterator(generator.iter) self.visit(generator.iter) if not self._bind_iteration_target( generator.target, self._expression_value(generator.iter) @@ -3611,6 +3693,7 @@ def _consume_generator_expression( ) -> None: """Advance one generator-expression identity with runtime consumption semantics.""" first, *remaining = node.generators + self._consume_deferred_callable_iterator(first.iter, mode=mode) candidates = self._iteration_values(self._expression_value(first.iter)) start = self._deferred_generator_positions.get(identity, 0) consumed = 0 @@ -4213,7 +4296,7 @@ def _visit_function_body( consume_generator: bool = False, generator_consumer_mode: str = "full", generator_identity: int | None = None, - ) -> tuple[_AbstractValue, ...]: + ) -> _FunctionExecution: scoped = {**self._closure_bindings.get(id(node), {}), **scoped} self._states.append(scoped) self._annotations.append({}) @@ -4248,15 +4331,21 @@ def _visit_function_body( try: if isinstance(node, ast.Lambda): self.visit(node.body) - return (self._expression_value(node.body),) + return _FunctionExecution((self._expression_value(node.body),), True) if _is_generator_function(node) and not consume_generator: - return () + return _FunctionExecution((), True) result = self._visit_binding_branch(node.body, self._binding_snapshot()) - return tuple( + values = tuple( path.return_value or _UNKNOWN_VALUE for path in result.abrupt if path.kind == "return" ) + raises = tuple(path for path in result.abrupt if path.kind == "raise") + return _FunctionExecution( + values, + result.fallthrough is not None or bool(values), + raises, + ) finally: if generator_identity is not None: advanced = self._generator_advanced_yields.pop() @@ -4292,13 +4381,14 @@ def _local_call_value( return _UNKNOWN_VALUE self._active_calls.add(function_id) try: - returned = self._visit_function_body( + execution = self._visit_function_body( function, scoped, ) finally: self._active_calls.remove(function_id) - return _join_values(*returned) + self._call_executions.setdefault(id(call), []).append(execution) + return _join_values(*execution.values) def _local_direct_call_value( self, @@ -4310,13 +4400,13 @@ def _local_direct_call_value( return _UNKNOWN_VALUE self._active_calls.add(function_id) try: - returned = self._visit_function_body( + execution = self._visit_function_body( function, self._bound_direct_arguments(function, values), ) finally: self._active_calls.remove(function_id) - return _join_values(*returned) + return _join_values(*execution.values) def _partial_call_value(self, call: ast.Call, partial: _DeferredPartial) -> _AbstractValue: """Execute a partial with every pre-bound positional/keyword argument.""" @@ -4357,10 +4447,10 @@ def _partial_call_value(self, call: ast.Call, partial: _DeferredPartial) -> _Abs scoped[name] = value self._active_calls.add(function_id) try: - returned = self._visit_function_body(target.function, scoped) + execution = self._visit_function_body(target.function, scoped) finally: self._active_calls.remove(function_id) - results.extend(returned) + results.extend(execution.values) return _join_values(*results) def visit_Return(self, node: ast.Return) -> None: diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 84ed0ec7a1..702e6544a9 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3123,6 +3123,101 @@ def getattr(section, name): assert _audit_as_documented_inert(contract, field, reads).violations == () +def test_runtime_scan_consumes_accessor_maps_through_iterable_protocols( + contract, tmp_path: Path +) -> None: + (tmp_path / "iterable_accessor_callbacks.py").write_text( + """ +import operator + +for value in map(operator.attrgetter("stage1_enabled"), [config.evaluation]): + pass +[stage2] = map(operator.attrgetter("stage2_enabled"), [config.evaluation]) +None in map(operator.attrgetter("stage3_enabled"), [config.evaluation]) +values = [] +values.extend(map(operator.attrgetter("semantic_model"), [config.evaluation])) +list(value for value in map(operator.attrgetter("assertion_extraction_model"), [config.evaluation])) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ( + "stage1_enabled", + "stage2_enabled", + "stage3_enabled", + "semantic_model", + "assertion_extraction_model", + ) + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_keeps_empty_and_unconsumed_accessor_maps_inert( + contract, tmp_path: Path +) -> None: + (tmp_path / "inactive_iterable_callbacks.py").write_text( + """ +import operator + +pending = map(operator.attrgetter("stage1_enabled"), [config.evaluation]) +for value in map(operator.attrgetter("stage2_enabled"), []): + pass +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset() + + +def test_runtime_scan_propagates_exact_local_raise_reachability(contract, tmp_path: Path) -> None: + (tmp_path / "exported_nonreturning.py").write_text( + """ +def stop(): + raise RuntimeError("stop") + +def exported(): + stop() + return config.evaluation.stage1_enabled +""", + encoding="utf-8", + ) + (tmp_path / "module_nonreturning.py").write_text( + """ +def stop(): + raise RuntimeError("stop") + +stop() +config.evaluation.stage2_enabled +""", + encoding="utf-8", + ) + (tmp_path / "caught_nonreturning.py").write_text( + """ +def stop(): + raise RuntimeError("stop") + +try: + stop() +except RuntimeError: + config.evaluation.stage3_enabled +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + reads = contract.runtime_reads(tmp_path, fields) + + assert reads == frozenset({contract.ConfigField("evaluation", "stage3_enabled")}) + + def test_runtime_scan_tracks_constant_and_dotted_operator_getters(contract, tmp_path: Path) -> None: (tmp_path / "operator_constants.py").write_text( """ From 7d1d2ae3ce4383353cff4773d637a8d58a4f6171 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 16:54:46 +0900 Subject: [PATCH 50/70] fix(ci): follow local iterable and conditional exits --- scripts/check-config-reference-contract.py | 83 ++++++++++--------- .../test_check_config_reference_contract.py | 63 ++++++++++++++ 2 files changed, 108 insertions(+), 38 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 368a3c4560..216be521d5 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -1191,41 +1191,32 @@ def _call_has_relevant_provenance( return True return bound_receiver is not None and bool(_contained_origins(bound_receiver) & tracked) - def _direct_raise_execution(self, function: _FunctionNode) -> _FunctionExecution | None: - """Recognize an exact local helper whose first reachable action raises.""" + def _function_is_syntactically_nonreturning(self, function: _FunctionNode) -> bool: + """Select exact local helpers whose reachable suite cannot return.""" if isinstance(function, ast.Lambda): - return None - for statement in function.body: - if ( - isinstance(statement, ast.Expr) - and isinstance(statement.value, ast.Constant) - and isinstance(statement.value.value, str) - ): - continue - if isinstance( - statement, - (ast.Pass, ast.Import, ast.ImportFrom, ast.Assign, ast.AnnAssign), - ): - continue - if not isinstance(statement, ast.Raise): - return None - exception_type = ( - self._exception_name(statement.exc) if statement.exc is not None else None - ) - leaf = exception_type.rsplit(".", 1)[-1] if exception_type else None - return _FunctionExecution( - (), - False, - ( - _AbruptPath( - "raise", - self._binding_snapshot(), - exception_type=leaf, - exception_upper_bound=leaf or "BaseException", - ), - ), - ) - return None + return False + + def suite_terminates(statements: list[ast.stmt]) -> bool: + for statement in statements: + if isinstance(statement, ast.Raise): + return True + if isinstance(statement, ast.Return): + return False + if isinstance(statement, ast.If): + truth = self._static_truth(statement.test) + if truth is not None: + if suite_terminates(statement.body if truth else statement.orelse): + return True + continue + if ( + statement.orelse + and suite_terminates(statement.body) + and suite_terminates(statement.orelse) + ): + return True + return False + + return suite_terminates(function.body) @staticmethod def _named_value( @@ -2147,8 +2138,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if self._call_has_relevant_provenance(node, function, bound_receiver): values.append(self._local_call_value(node, function, bound_receiver)) continue - if execution := self._direct_raise_execution(function): - self._call_executions.setdefault(id(node), []).append(execution) + if self._function_is_syntactically_nonreturning(function): + values.append(self._local_call_value(node, function, bound_receiver)) if values: value = _join_values(*values) self._expression_cache[id(node)] = value @@ -2364,7 +2355,8 @@ def _consume_deferred_generator(self, node: ast.AST, *, mode: str = "full") -> N def _consume_local_iterator(self, node: ast.AST, *, mode: str = "full") -> None: owner = self._expression_value(node) - for function in self._source_index.methods(owner.instance_classes, "__iter__"): + iterator_methods = self._source_index.methods(owner.instance_classes, "__iter__") + for function in iterator_methods: scoped = self._bound_direct_arguments(function, (self._descriptor_receiver(owner),)) if _is_generator_function(function): self._visit_function_body( @@ -2380,6 +2372,14 @@ def _consume_local_iterator(self, node: ast.AST, *, mode: str = "full") -> None: (self._descriptor_receiver(owner),), ) self._consume_deferred_generator_value(returned, mode=mode) + self._consume_deferred_callable_iterator_value(returned, mode=mode) + if iterator_methods: + return + for function in self._source_index.methods(owner.instance_classes, "__getitem__"): + self._local_direct_call_value( + function, + (self._descriptor_receiver(owner), _UNKNOWN_VALUE), + ) def _consume_deferred_coroutine(self, node: ast.AST) -> None: value = self._expression_value(node) @@ -2391,7 +2391,14 @@ def _consume_deferred_coroutine(self, node: ast.AST) -> None: def _consume_deferred_callable_iterator(self, node: ast.AST, *, mode: str = "full") -> None: """Execute callbacks only when their lazy map/filter is consumed.""" - for identity in self._expression_value(node).identity: + self._consume_deferred_callable_iterator_value(self._expression_value(node), mode=mode) + + def _consume_deferred_callable_iterator_value( + self, value: _AbstractValue, *, mode: str = "full" + ) -> None: + """Execute callbacks captured by an already-resolved lazy iterator value.""" + + for identity in value.identity: deferred = self._deferred_callable_iterators.get(identity) if deferred is None: continue diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 702e6544a9..8b9d18ceab 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3218,6 +3218,69 @@ def stop(): assert reads == frozenset({contract.ConfigField("evaluation", "stage3_enabled")}) +def test_runtime_scan_consumes_callable_iterators_returned_by_local_protocols( + contract, tmp_path: Path +) -> None: + (tmp_path / "local_iterable_protocol.py").write_text( + """ +import operator + +class Wrapper: + def __init__(self, source): + self.source = source + + def __iter__(self): + return iter(self.source) + +class Fallback: + def __getitem__(self, index): + return settings.evaluation.stage2_enabled + +list(Wrapper(map(operator.attrgetter("stage1_enabled"), [settings.evaluation]))) +list(Fallback()) +pending = Wrapper(map(operator.attrgetter("stage3_enabled"), [settings.evaluation])) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields - { + contract.ConfigField("evaluation", "stage3_enabled") + } + + +def test_runtime_scan_propagates_conditional_exact_local_termination( + contract, tmp_path: Path +) -> None: + (tmp_path / "conditional_nonreturning.py").write_text( + """ +def stop(): + if True: + raise RuntimeError("stop") + +def exported(): + stop() + return settings.evaluation.stage1_enabled + +try: + stop() +except RuntimeError: + settings.evaluation.stage2_enabled +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage2_enabled")} + ) + + def test_runtime_scan_tracks_constant_and_dotted_operator_getters(contract, tmp_path: Path) -> None: (tmp_path / "operator_constants.py").write_text( """ From 8087c89427274f76cae60b153f6de4a1180f769c Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 17:09:57 +0900 Subject: [PATCH 51/70] fix(ci): model iterator and context cleanup protocols --- scripts/check-config-reference-contract.py | 46 ++++++- .../test_check_config_reference_contract.py | 119 ++++++++++++++++++ 2 files changed, 159 insertions(+), 6 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 216be521d5..3d60d4c161 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -2373,13 +2373,26 @@ def _consume_local_iterator(self, node: ast.AST, *, mode: str = "full") -> None: ) self._consume_deferred_generator_value(returned, mode=mode) self._consume_deferred_callable_iterator_value(returned, mode=mode) + for next_function in self._source_index.methods(returned.instance_classes, "__next__"): + self._local_direct_call_value( + next_function, + (self._descriptor_receiver(returned),), + ) if iterator_methods: return for function in self._source_index.methods(owner.instance_classes, "__getitem__"): - self._local_direct_call_value( + first = self._local_direct_call_execution( function, - (self._descriptor_receiver(owner), _UNKNOWN_VALUE), + ( + self._descriptor_receiver(owner), + _AbstractValue(literal=_key_token(ast.Constant(0)), truth=False), + ), ) + if first.returns_to_caller: + self._local_direct_call_value( + function, + (self._descriptor_receiver(owner), _UNKNOWN_VALUE), + ) def _consume_deferred_coroutine(self, node: ast.AST) -> None: value = self._expression_value(node) @@ -4080,9 +4093,11 @@ def _context_entry_value(self, context_value: _AbstractValue) -> _AbstractValue: return _join_values(*entries) def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: + context_values: list[_AbstractValue] = [] for item in node.items: self.visit(item.context_expr) context_value = self._expression_value(item.context_expr) + context_values.append(context_value) for identity in context_value.identity: deferred = self._deferred_generators.get(identity) if ( @@ -4094,13 +4109,25 @@ def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: ) ): self._consume_deferred_generator(item.context_expr, mode="one_turn") + entry_value = self._context_entry_value(context_value) if item.optional_vars is not None: self._visit_store_target(item.optional_vars) - entry_value = self._context_entry_value(context_value) self._bind_target_value(item.optional_vars, entry_value) self._bind_function_target(item.optional_vars, frozenset()) self._bind_annotation_target(item.optional_vars, _UNKNOWN_VALUE) - self._apply_flow_result(self._visit_binding_branch(node.body, self._binding_snapshot())) + body_result = self._visit_binding_branch(node.body, self._binding_snapshot()) + for context_value in reversed(context_values): + receiver = self._descriptor_receiver(context_value) + for method_name in ("__exit__", "__aexit__"): + for function in self._source_index.methods( + context_value.instance_classes, method_name + ): + self._local_direct_call_value( + function, + (receiver, _UNKNOWN_VALUE, _UNKNOWN_VALUE, _UNKNOWN_VALUE), + ) + self._consume_deferred_generator_value(context_value, mode="full") + self._apply_flow_result(body_result) def visit_With(self, node: ast.With) -> None: self._visit_with(node) @@ -4402,9 +4429,16 @@ def _local_direct_call_value( function: _FunctionNode, values: tuple[_AbstractValue, ...], ) -> _AbstractValue: + return _join_values(*self._local_direct_call_execution(function, values).values) + + def _local_direct_call_execution( + self, + function: _FunctionNode, + values: tuple[_AbstractValue, ...], + ) -> _FunctionExecution: function_id = id(function) if function_id in self._active_calls: - return _UNKNOWN_VALUE + return _FunctionExecution((_UNKNOWN_VALUE,), True) self._active_calls.add(function_id) try: execution = self._visit_function_body( @@ -4413,7 +4447,7 @@ def _local_direct_call_value( ) finally: self._active_calls.remove(function_id) - return _join_values(*execution.values) + return execution def _partial_call_value(self, call: ast.Call, partial: _DeferredPartial) -> _AbstractValue: """Execute a partial with every pre-bound positional/keyword argument.""" diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 8b9d18ceab..f45e27a574 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -399,6 +399,65 @@ def __iter__(self): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_consumes_local_next_but_not_unconsumed_iterators( + contract, tmp_path: Path +) -> None: + (tmp_path / "local_next.py").write_text( + """ +class Reader: + def __init__(self, section): + self.section = section + + def __iter__(self): + return self + + def __next__(self): + return self.section.stage1_enabled + +class Pending: + def __iter__(self): + return self + + def __next__(self): + return settings.evaluation.stage2_enabled + +list(Reader(settings.evaluation)) +pending = Pending() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage1_enabled")} + ) + + +def test_runtime_scan_stops_sequence_fallback_after_first_index_error( + contract, tmp_path: Path +) -> None: + (tmp_path / "empty_sequence.py").write_text( + """ +class Empty: + def __init__(self, section): + self.section = section + + def __getitem__(self, index): + if index == 0: + raise IndexError + return self.section.stage1_enabled + +list(Empty(settings.evaluation)) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + def test_runtime_scan_executes_registered_exit_callbacks(contract, tmp_path: Path) -> None: (tmp_path / "exit_callback.py").write_text( """ @@ -577,6 +636,66 @@ def __enter__(self): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() +def test_runtime_scan_executes_context_exit_and_generator_continuation( + contract, tmp_path: Path +) -> None: + (tmp_path / "context_cleanup.py").write_text( + """ +from contextlib import contextmanager + +class Box: + def __init__(self, section): + self.section = section + + def __enter__(self): + return self + + def __exit__(self, *args): + return self.section.stage1_enabled + +@contextmanager +def managed(section): + yield None + section.stage2_enabled + +class PendingBox: + def __enter__(self): + return self + + def __exit__(self, *args): + settings.evaluation.stage3_enabled + +@contextmanager +def pending_managed(): + yield None + settings.evaluation.semantic_model + +with Box(settings.evaluation): + pass + +with managed(settings.evaluation): + pass + +Box(settings.evaluation) +managed(settings.evaluation) +PendingBox() +pending_managed() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled", "semantic_model") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + } + ) + + def test_runtime_scan_invokes_cached_property_getters(contract, tmp_path: Path) -> None: (tmp_path / "cached_property_read.py").write_text( """ From 1e5d2cba56f1af5d0d203c10385fda8e40488494 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 17:29:49 +0900 Subject: [PATCH 52/70] fix(ci): resolve imported data provenance --- scripts/check-config-reference-contract.py | 72 +++++++++++++++++++ .../test_check_config_reference_contract.py | 72 ++++++++++++++++++- 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 3d60d4c161..26fa9b42b1 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -814,6 +814,44 @@ def resolve_classes( target, target_name = edge return self.resolve_classes(target, target_name, seen | {key}) + def resolve_value_expression( + self, + module: _IndexedModule, + name: str, + seen: frozenset[tuple[str, str]] = frozenset(), + ) -> tuple[_IndexedModule, ast.expr] | None: + """Resolve a final explicit module data binding or re-export.""" + key = (module.name, name) + if key in seen: + return None + edge = self._reexports.get(key) + if edge is not None: + target, target_name = edge + return self.resolve_value_expression(target, target_name, seen | {key}) + resolved: tuple[_IndexedModule, ast.expr] | None = None + for statement in module.tree.body: + if isinstance(statement, ast.Assign): + assigned = frozenset().union( + *(self._assigned_names(target) for target in statement.targets) + ) + if name in assigned: + resolved = (module, statement.value) + elif isinstance(statement, ast.AnnAssign) and name in self._assigned_names( + statement.target + ): + resolved = (module, statement.value) if statement.value is not None else None + elif isinstance( + statement, (ast.AugAssign, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + ): + bound = ( + self._assigned_names(statement.target) + if isinstance(statement, ast.AugAssign) + else frozenset({statement.name}) + ) + if name in bound: + resolved = None + return resolved + def _index_class_bases(self, module: _IndexedModule) -> None: class_bindings: dict[str, frozenset[ast.ClassDef]] = {} module_bindings: dict[str, _IndexedModule] = {} @@ -3877,6 +3915,9 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: f"{module.name}.{alias.name}" if module is not None else alias.name, self._module, ) + imported_value = ( + self._imported_data_value(module, alias.name) if module is not None else None + ) self._functions[-1][bound] = functions self._states[-1][bound] = ( _type_checking_value() @@ -3948,6 +3989,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "contextlib" and alias.name == "ExitStack" else _origin_value("") if node.module == "collections" and alias.name == "deque" + else imported_value + if imported_value is not None else _AbstractValue( classes=classes, modules=( @@ -3958,6 +4001,35 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: ) ) + def _imported_data_value( + self, + module: _IndexedModule, + name: str, + seen: frozenset[tuple[str, str]] = frozenset(), + ) -> _AbstractValue | None: + """Evaluate bounded imported constants and callback containers.""" + key = (module.name, name) + if key in seen: + return None + resolved = self._source_index.resolve_value_expression(module, name) + if resolved is None: + return None + owner, expression = resolved + scoped: dict[str, _AbstractValue] = {} + for candidate in ast.walk(expression): + if not isinstance(candidate, ast.Name) or candidate.id in scoped: + continue + functions = self._source_index.resolve_functions(owner, candidate.id) + if functions: + scoped[candidate.id] = _AbstractValue( + callables=tuple(_CallableTarget(function) for function in functions) + ) + continue + nested = self._imported_data_value(owner, candidate.id, seen | {key}) + if nested is not None: + scoped[candidate.id] = nested + return self._value_in_scope(expression, scoped) + def visit_Import(self, node: ast.Import) -> None: self._bind_import(node, runtime=True) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index f45e27a574..2882d3e6c4 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -579,7 +579,15 @@ async def read(config): contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") ) - assert contract.runtime_reads(tmp_path, fields) == fields + reads = contract.runtime_reads(tmp_path, fields) + assert reads == fields + report = _audit_as_documented_inert( + contract, contract.ConfigField("evaluation", "stage1_enabled"), reads + ) + assert "evaluation.stage1_enabled: conflicting config-field dispositions" in report.violations + assert "evaluation.stage1_enabled: production-wired field is still documented inert" in ( + report.violations + ) def test_runtime_scan_tracks_local_and_closing_context_entry_protocols( @@ -633,7 +641,9 @@ def __enter__(self): ) field = contract.ConfigField("evaluation", "stage1_enabled") - assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + reads = contract.runtime_reads(tmp_path, frozenset({field})) + assert reads == frozenset() + assert _audit_as_documented_inert(contract, field, reads).violations == () def test_runtime_scan_executes_context_exit_and_generator_continuation( @@ -3420,6 +3430,64 @@ def test_runtime_scan_tracks_constant_and_dotted_operator_getters(contract, tmp_ assert contract.runtime_reads(tmp_path, fields) == fields +def test_runtime_scan_resolves_imported_constants_and_callback_containers( + contract, tmp_path: Path +) -> None: + (tmp_path / "names.py").write_text('FIELD = "stage1_enabled"\n', encoding="utf-8") + (tmp_path / "callbacks.py").write_text( + """ +def read_stage(section): + return section.stage2_enabled + +_READERS = {"stage": read_stage} +""", + encoding="utf-8", + ) + (tmp_path / "imported_values.py").write_text( + """ +from names import FIELD +from callbacks import _READERS + +getattr(settings.evaluation, FIELD) +_READERS["stage"](settings.evaluation) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + reads = contract.runtime_reads(tmp_path, fields) + assert reads == fields + report = _audit_as_documented_inert( + contract, contract.ConfigField("evaluation", "stage1_enabled"), reads + ) + assert "evaluation.stage1_enabled: conflicting config-field dispositions" in report.violations + assert "evaluation.stage1_enabled: production-wired field is still documented inert" in ( + report.violations + ) + + +def test_runtime_scan_keeps_imported_values_lazy_when_unused(contract, tmp_path: Path) -> None: + (tmp_path / "callbacks.py").write_text( + """ +def read_stage(section): + return section.stage3_enabled + +_READERS = {"stage": read_stage} +""", + encoding="utf-8", + ) + (tmp_path / "unused_imported_values.py").write_text( + "from callbacks import _READERS\npending = _READERS\n", encoding="utf-8" + ) + field = contract.ConfigField("evaluation", "stage3_enabled") + + reads = contract.runtime_reads(tmp_path, frozenset({field})) + assert reads == frozenset() + assert _audit_as_documented_inert(contract, field, reads).violations == () + + def test_runtime_scan_tracks_unshadowed_classmethod_and_rejects_shadowing( contract, tmp_path: Path ) -> None: From f0bc1e20a64d44862c1ceec539548acbe111feb2 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 17:47:50 +0900 Subject: [PATCH 53/70] fix(config-audit): resolve qualified imported data --- scripts/check-config-reference-contract.py | 9 +++ .../test_check_config_reference_contract.py | 60 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 26fa9b42b1..f662772f11 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -1841,6 +1841,15 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: and node.attr == "section" ): return _origin_value("evaluation") + imported_data = tuple( + value + for module_name in owner.modules + if (module := self._source_index.resolve_module(module_name, self._module)) + is not None + if (value := self._imported_data_value(module, node.attr)) is not None + ) + if imported_data: + return _join_values(*imported_data) resolved_modules = { child.name for module_name in owner.modules diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 2882d3e6c4..a525db7342 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3488,6 +3488,66 @@ def read_stage(section): assert _audit_as_documented_inert(contract, field, reads).violations == () +def test_runtime_scan_resolves_module_qualified_data_values(contract, tmp_path: Path) -> None: + (tmp_path / "names.py").write_text('FIELD = "stage1_enabled"\n', encoding="utf-8") + (tmp_path / "callbacks.py").write_text( + """ +def read_stage(section): + return section.stage2_enabled + +READERS = {"stage": read_stage} +""", + encoding="utf-8", + ) + (tmp_path / "qualified_values.py").write_text( + """ +import names +import callbacks as cb + +getattr(settings.evaluation, names.FIELD) +cb.READERS["stage"](settings.evaluation) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + reads = contract.runtime_reads(tmp_path, fields) + assert reads == fields + for field in fields: + report = _audit_as_documented_inert(contract, field, reads) + assert f"{field.section}.{field.name}: conflicting config-field dispositions" in ( + report.violations + ) + assert ( + f"{field.section}.{field.name}: production-wired field is still documented inert" + in (report.violations) + ) + + +def test_runtime_scan_keeps_module_qualified_data_lazy_when_unused( + contract, tmp_path: Path +) -> None: + (tmp_path / "callbacks.py").write_text( + """ +def read_stage(section): + return section.stage3_enabled + +READERS = {"stage": read_stage} +""", + encoding="utf-8", + ) + (tmp_path / "unused_qualified_values.py").write_text( + "import callbacks\npending = callbacks.READERS\n", encoding="utf-8" + ) + field = contract.ConfigField("evaluation", "stage3_enabled") + + reads = contract.runtime_reads(tmp_path, frozenset({field})) + assert reads == frozenset() + assert _audit_as_documented_inert(contract, field, reads).violations == () + + def test_runtime_scan_tracks_unshadowed_classmethod_and_rejects_shadowing( contract, tmp_path: Path ) -> None: From 82fb571577773bbbf2cdd85d055b540b0d3f2310 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 18:08:21 +0900 Subject: [PATCH 54/70] fix(config-audit): resolve serialized mapping key aliases --- scripts/check-config-reference-contract.py | 30 ++++++---------- .../test_check_config_reference_contract.py | 35 ++++++++++++++++--- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index f662772f11..f6bf6dcb4a 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -1558,19 +1558,17 @@ def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: if not node.args: return default key = node.args[0] - if not isinstance(key, ast.Constant): + key_value = self._expression_value(key).string_value + if key_value is None: if method == "setdefault": return self._dynamic_setdefault_value(node) return _join_values(*values, default) - token = _key_token(key) + token = _key_token(ast.Constant(key_value)) entries = dict(owner.entries or ()) if owner.serialized_sections: - field_name = key.value if isinstance(key, ast.Constant) else None - if isinstance(field_name, str) and ( - owner.entries is None or token in entries or _DYNAMIC_KEY in entries - ): + if owner.entries is None or token in entries or _DYNAMIC_KEY in entries: for section in owner.serialized_sections: - self._record(section, field_name) + self._record(section, key_value) selected = entries.get(token) wildcard = entries.get(_DYNAMIC_KEY) if method == "get": @@ -2674,11 +2672,7 @@ def visit_Call(self, node: ast.Call) -> None: for section in self._expression_value(node.args[0]).origins & TRACKED_SECTIONS: self._record(section, field_name) if _OPERATOR_GETITEM in accessor.origins and len(node.args) >= 2: - field_name = ( - node.args[1].value - if isinstance(node.args[1], ast.Constant) and isinstance(node.args[1].value, str) - else None - ) + field_name = self._expression_value(node.args[1]).string_value if field_name is not None: for section in self._expression_value(node.args[0]).serialized_sections: self._record(section, field_name) @@ -2740,14 +2734,12 @@ def visit_Compare(self, node: ast.Compare) -> None: def visit_Subscript(self, node: ast.Subscript) -> None: before = self._binding_snapshot() - if ( - isinstance(node.ctx, ast.Load) - and isinstance(node.slice, ast.Constant) - and isinstance(node.slice.value, str) - ): + if isinstance(node.ctx, ast.Load): owner = self._expression_value(node.value) - for section in owner.serialized_sections | (owner.origins & TRACKED_SECTIONS): - self._record(section, node.slice.value) + field_name = self._expression_value(node.slice).string_value + if field_name is not None: + for section in owner.serialized_sections | (owner.origins & TRACKED_SECTIONS): + self._record(section, field_name) self.generic_visit(node) if isinstance(node.ctx, ast.Load): self._record_possible_exception(before) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index a525db7342..cffe6b753b 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -332,7 +332,9 @@ def entered(config): fields = frozenset( contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") ) - assert contract.runtime_reads(tmp_path, fields) == fields + assert contract.runtime_reads(tmp_path, fields) == fields - { + contract.ConfigField("evaluation", "semantic_model") + } def test_runtime_scan_executes_eager_key_callback_only_for_nonempty_input( @@ -3691,6 +3693,33 @@ def test_runtime_scan_tracks_alternative_attribute_accessors(contract, tmp_path: } +def test_runtime_scan_tracks_constant_keys_for_serialized_mapping_accessors( + contract, tmp_path: Path +) -> None: + (tmp_path / "constant_mapping_keys.py").write_text( + """ +SUBSCRIPT_FIELD = "stage1_enabled" +GET_FIELD = "stage2_enabled" + +settings.evaluation.model_dump()[SUBSCRIPT_FIELD] +settings.evaluation.model_dump().get(GET_FIELD) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + reads = contract.runtime_reads(tmp_path, fields) + assert reads == fields + for field in fields: + report = _audit_as_documented_inert(contract, field, reads) + assert ( + f"{field.section}.{field.name}: production-wired field is still documented inert" + in (report.violations) + ) + + def test_runtime_scan_tracks_class_pattern_keyword_attributes(contract, tmp_path: Path) -> None: (tmp_path / "class_patterns.py").write_text( """ @@ -3742,9 +3771,7 @@ def test_runtime_scan_tracks_operator_getitem_over_serialized_sections( for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled", "semantic_model") ) - assert contract.runtime_reads(tmp_path, fields) == fields - { - contract.ConfigField("evaluation", "semantic_model") - } + assert contract.runtime_reads(tmp_path, fields) == fields def test_runtime_scan_resolves_constant_indirected_getattr_and_ignores_shadowed_builtin( From 56c8b5c16cbcb64e8a3449603f8979bebea8298b Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 18:24:01 +0900 Subject: [PATCH 55/70] fix(config): resolve class and starred accessor reads --- scripts/check-config-reference-contract.py | 44 ++++++++++++++--- .../test_check_config_reference_contract.py | 47 +++++++++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index f6bf6dcb4a..90d6cd6472 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -2527,6 +2527,17 @@ def _consume_deferred_callable_iterator_value( def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() accessor = self._expression_value(node.func) + accessor_arguments: list[_AbstractValue] = [] + accessor_arguments_exact = True + for argument in node.args: + if not isinstance(argument, ast.Starred): + accessor_arguments.append(self._expression_value(argument)) + continue + expanded = self._expression_value(argument.value).items + if expanded is None: + accessor_arguments_exact = False + continue + accessor_arguments.extend(expanded) if isinstance(node.func, ast.Attribute) and node.func.attr == "extend": for argument in node.args: self._consume_deferred_callable_iterator(argument) @@ -2666,15 +2677,21 @@ def visit_Call(self, node: ast.Call) -> None: for parameter in parameters: for section in mapping.serialized_sections: self._record(section, parameter.arg) - if accessor.origins & {_GETATTR_BUILTIN, _HASATTR_BUILTIN} and len(node.args) >= 2: - field_name = self._expression_value(node.args[1]).string_value + tracked_accessor = bool( + accessor.origins & {_GETATTR_BUILTIN, _HASATTR_BUILTIN, _OPERATOR_GETITEM} + ) + if tracked_accessor and not accessor_arguments_exact: + for field in self._fields: + self._record(field.section, field.name) + if accessor.origins & {_GETATTR_BUILTIN, _HASATTR_BUILTIN} and len(accessor_arguments) >= 2: + field_name = accessor_arguments[1].string_value if field_name is not None: - for section in self._expression_value(node.args[0]).origins & TRACKED_SECTIONS: + for section in accessor_arguments[0].origins & TRACKED_SECTIONS: self._record(section, field_name) - if _OPERATOR_GETITEM in accessor.origins and len(node.args) >= 2: - field_name = self._expression_value(node.args[1]).string_value + if _OPERATOR_GETITEM in accessor.origins and len(accessor_arguments) >= 2: + field_name = accessor_arguments[1].string_value if field_name is not None: - for section in self._expression_value(node.args[0]).serialized_sections: + for section in accessor_arguments[0].serialized_sections: self._record(section, field_name) self.generic_visit(node) executions = self._call_executions.pop(id(node), []) @@ -4762,6 +4779,16 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: try: for statement in node.body: self.visit(statement) + method_names = { + statement.name + for statement in node.body + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + class_attributes = tuple( + (name, value) + for name, value in self._states[-1].items() + if name not in method_names + ) self._class_member_values[id(node)] = tuple( value for name, value in self._states[-1].items() if not name.startswith("_") ) @@ -4771,7 +4798,10 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: self._functions.pop() self._annotations.pop() self._states.pop() - self._states[-1][node.name] = _AbstractValue(classes=frozenset({node})) + self._states[-1][node.name] = _AbstractValue( + classes=frozenset({node}), + attributes=class_attributes, + ) self._annotations[-1][node.name] = _UNKNOWN_VALUE self._functions[-1][node.name] = frozenset() diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index cffe6b753b..778c0eb359 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3720,6 +3720,53 @@ def test_runtime_scan_tracks_constant_keys_for_serialized_mapping_accessors( ) +def test_runtime_scan_tracks_class_constants_and_callback_registries( + contract, tmp_path: Path +) -> None: + (tmp_path / "class_values.py").write_text( + """ +def read_stage(section): + return section.stage2_enabled + +class Fields: + STAGE = "stage1_enabled" + +class Registry: + READERS = {"main": read_stage} + +getattr(config.evaluation, Fields.STAGE) +Registry.READERS["main"](config.evaluation) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + reads = contract.runtime_reads(tmp_path, fields) + assert reads == fields + + +def test_runtime_scan_expands_static_starred_accessor_arguments(contract, tmp_path: Path) -> None: + (tmp_path / "starred_accessors.py").write_text( + """ +import operator + +GETATTR_ARGS = (config.evaluation, "stage1_enabled") +GETITEM_ARGS = (config.evaluation.model_dump(), "stage2_enabled") + +getattr(*GETATTR_ARGS) +operator.getitem(*GETITEM_ARGS) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + def test_runtime_scan_tracks_class_pattern_keyword_attributes(contract, tmp_path: Path) -> None: (tmp_path / "class_patterns.py").write_text( """ From 18d9924574dc57a26207c78764007100154b7c57 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 18:25:39 +0900 Subject: [PATCH 56/70] test(config): cover provenance overreach --- .../test_check_config_reference_contract.py | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 778c0eb359..779abce5bf 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -583,6 +583,8 @@ async def read(config): reads = contract.runtime_reads(tmp_path, fields) assert reads == fields + for field in fields: + assert _audit_as_documented_inert(contract, field, reads).violations report = _audit_as_documented_inert( contract, contract.ConfigField("evaluation", "stage1_enabled"), reads ) @@ -621,7 +623,45 @@ def __exit__(self, *args): contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") ) - assert contract.runtime_reads(tmp_path, fields) == fields + reads = contract.runtime_reads(tmp_path, fields) + assert reads == fields + for field in fields: + assert _audit_as_documented_inert(contract, field, reads).violations + + +def test_runtime_scan_does_not_overreach_class_or_starred_data_provenance( + contract, tmp_path: Path +) -> None: + (tmp_path / "inactive_class_and_starred_values.py").write_text( + """ +import operator + +def read_stage(section): + return section.stage2_enabled + +class Fields: + STAGE = "stage1_enabled" + +class Registry: + READERS = {"main": read_stage} + +GETATTR_ARGS = (report.evaluation, Fields.STAGE) +GETITEM_ARGS = (report.evaluation.model_dump(), "stage2_enabled") + +getattr(*GETATTR_ARGS) +operator.getitem(*GETITEM_ARGS) +Registry.READERS["main"](report.evaluation) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + reads = contract.runtime_reads(tmp_path, fields) + assert reads == frozenset() + for field in fields: + assert _audit_as_documented_inert(contract, field, reads).violations == () def test_runtime_scan_does_not_enter_unconsumed_context_objects(contract, tmp_path: Path) -> None: From 125b54654d0f5b7b6ed3a98a60a0154a713ca7cc Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 18:45:18 +0900 Subject: [PATCH 57/70] fix(config): evaluate final imported bindings --- scripts/check-config-reference-contract.py | 70 ++++++++++----- .../test_check_config_reference_contract.py | 88 +++++++++++++++++++ 2 files changed, 135 insertions(+), 23 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 90d6cd6472..a55f6558c4 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -174,6 +174,7 @@ class _AbstractValue: identity: frozenset[int] = frozenset() literal: str | None = None string_value: str | None = None + string_values: frozenset[str] = frozenset() truth: bool | None = None classes: frozenset[ast.ClassDef] = frozenset() instance_classes: frozenset[ast.ClassDef] = frozenset() @@ -573,6 +574,7 @@ def _conservative_value(value: _AbstractValue) -> _AbstractValue: callables=value.callables, serialized_sections=value.serialized_sections, accessed_attributes=value.accessed_attributes, + string_values=value.string_values, ) @@ -646,6 +648,13 @@ def join_entries( if all(value.string_value == values[0].string_value for value in values) else None ), + string_values=frozenset().union( + *( + value.string_values + | ({value.string_value} if value.string_value is not None else set()) + for value in values + ) + ), truth=( values[0].truth if all(value.truth == values[0].truth for value in values) else None ), @@ -693,6 +702,8 @@ def __init__(self, source_root: Path, trees: Mapping[Path, ast.Module]) -> None: self._paths: dict[Path, _IndexedModule] = {} self._reexports: dict[tuple[str, str], tuple[_IndexedModule, str]] = {} self._class_bases: dict[int, tuple[ast.ClassDef, ...]] = {} + self._data_states: dict[str, dict[str, _AbstractValue]] = {} + self._data_states_in_progress: set[str] = set() for path, tree in trees.items(): relative = path.relative_to(source_root).with_suffix("") parts = list(relative.parts) @@ -852,6 +863,31 @@ def resolve_value_expression( resolved = None return resolved + def resolve_data_value( + self, + module: _IndexedModule, + name: str, + fields: frozenset[ConfigField], + ) -> _AbstractValue | None: + """Evaluate final module data with the runtime visitor's flow semantics.""" + cached = self._data_states.get(module.name) + if cached is not None: + return cached.get(name) + if module.name in self._data_states_in_progress: + return None + self._data_states_in_progress.add(module.name) + visitor = _RuntimeReadVisitor(fields, self, module) + try: + for statement in module.tree.body: + if not visitor._path_reachable: + break + visitor.visit(statement) + state = dict(visitor._states[0]) + self._data_states[module.name] = state + return state.get(name) + finally: + self._data_states_in_progress.remove(module.name) + def _index_class_bases(self, module: _IndexedModule) -> None: class_bindings: dict[str, frozenset[ast.ClassDef]] = {} module_bindings: dict[str, _IndexedModule] = {} @@ -1683,6 +1719,9 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue( literal=_key_token(node), string_value=node.value if isinstance(node.value, str) else None, + string_values=( + frozenset({node.value}) if isinstance(node.value, str) else frozenset() + ), truth=bool(node.value), ) constant_value = _safe_constant_value(node) @@ -1690,6 +1729,7 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue( literal=_key_token(ast.Constant(constant_value)), string_value=constant_value, + string_values=frozenset({constant_value}), truth=bool(constant_value), ) if isinstance(node, ast.Name): @@ -2684,13 +2724,13 @@ def visit_Call(self, node: ast.Call) -> None: for field in self._fields: self._record(field.section, field.name) if accessor.origins & {_GETATTR_BUILTIN, _HASATTR_BUILTIN} and len(accessor_arguments) >= 2: - field_name = accessor_arguments[1].string_value - if field_name is not None: + field_names = accessor_arguments[1].string_values + for field_name in field_names: for section in accessor_arguments[0].origins & TRACKED_SECTIONS: self._record(section, field_name) if _OPERATOR_GETITEM in accessor.origins and len(accessor_arguments) >= 2: - field_name = accessor_arguments[1].string_value - if field_name is not None: + field_names = accessor_arguments[1].string_values + for field_name in field_names: for section in accessor_arguments[0].serialized_sections: self._record(section, field_name) self.generic_visit(node) @@ -3749,8 +3789,9 @@ def _visit_comprehension( if isinstance(node, ast.DictComp): self.visit(node.key) self.visit(node.value) + key = self._expression_value(node.key).literal or _DYNAMIC_KEY result = _AbstractValue( - entries=((_key_token(node.key), self._expression_value(node.value)),), + entries=((key, self._expression_value(node.value)),), identity=frozenset({id(node)}), ) else: @@ -4029,24 +4070,7 @@ def _imported_data_value( key = (module.name, name) if key in seen: return None - resolved = self._source_index.resolve_value_expression(module, name) - if resolved is None: - return None - owner, expression = resolved - scoped: dict[str, _AbstractValue] = {} - for candidate in ast.walk(expression): - if not isinstance(candidate, ast.Name) or candidate.id in scoped: - continue - functions = self._source_index.resolve_functions(owner, candidate.id) - if functions: - scoped[candidate.id] = _AbstractValue( - callables=tuple(_CallableTarget(function) for function in functions) - ) - continue - nested = self._imported_data_value(owner, candidate.id, seen | {key}) - if nested is not None: - scoped[candidate.id] = nested - return self._value_in_scope(expression, scoped) + return self._source_index.resolve_data_value(module, name, self._fields) def visit_Import(self, node: ast.Import) -> None: self._bind_import(node, runtime=True) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 779abce5bf..00fdb1b660 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3590,6 +3590,94 @@ def read_stage(section): assert _audit_as_documented_inert(contract, field, reads).violations == () +def test_runtime_scan_resolves_final_imported_mutations_and_control_flow( + contract, tmp_path: Path +) -> None: + (tmp_path / "mutated_values.py").write_text( + """ +def _old(section): + return section.stage1_enabled + +def _new(section): + return section.stage2_enabled + +READERS = {"main": _old} +READERS["main"] = _new + +if runtime_flag: + FIELD = "stage2_enabled" +else: + FIELD = "stage3_enabled" +""", + encoding="utf-8", + ) + (tmp_path / "use_mutated_values.py").write_text( + """ +from mutated_values import FIELD, READERS + +READERS["main"](settings.evaluation) +getattr(settings.evaluation, FIELD) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + reads = contract.runtime_reads(tmp_path, fields) + expected = fields - {contract.ConfigField("evaluation", "stage1_enabled")} + assert reads == expected + for field in expected: + assert _audit_as_documented_inert(contract, field, reads).violations + assert ( + _audit_as_documented_inert( + contract, contract.ConfigField("evaluation", "stage1_enabled"), reads + ).violations + == () + ) + + +def test_runtime_scan_resolves_dictionary_comprehension_registry_keys( + contract, tmp_path: Path +) -> None: + (tmp_path / "comprehension_registry.py").write_text( + """ +def _read(section): + return section.stage2_enabled + +READERS = {name: callback for name, callback in [("main", _read)]} +READERS["main"](settings.evaluation) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage2_enabled") + + reads = contract.runtime_reads(tmp_path, frozenset({field})) + assert reads == frozenset({field}) + assert _audit_as_documented_inert(contract, field, reads).violations + + +def test_runtime_scan_does_not_overreach_dictionary_comprehension_registry( + contract, tmp_path: Path +) -> None: + (tmp_path / "inactive_comprehension_registry.py").write_text( + """ +def _read(section): + return section.stage2_enabled + +READERS = {name: callback for name, callback in [("main", _read)]} +READERS["main"](report.evaluation) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage2_enabled") + + reads = contract.runtime_reads(tmp_path, frozenset({field})) + assert reads == frozenset() + assert _audit_as_documented_inert(contract, field, reads).violations == () + + def test_runtime_scan_tracks_unshadowed_classmethod_and_rejects_shadowing( contract, tmp_path: Path ) -> None: From 7637315260e0982506a10aeb145f0c62a93eac7c Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 19:04:09 +0900 Subject: [PATCH 58/70] fix: close config scanner soundness gaps --- scripts/check-config-reference-contract.py | 44 +++++++++ .../test_check_config_reference_contract.py | 91 +++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index a55f6558c4..71d4ca4c28 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -1596,6 +1596,16 @@ def _dict_method_value(self, node: ast.Call) -> _AbstractValue | None: key = node.args[0] key_value = self._expression_value(key).string_value if key_value is None: + # A runtime-computed key can select any serialized field. Do not + # silently treat this as no read: fail closed by recording the + # complete section contract (or the bounded string candidates). + candidates = self._expression_value(key).string_values + names = candidates or { + field.name for field in self._fields if field.section in owner.serialized_sections + } + for section in owner.serialized_sections: + for name in names: + self._record(section, name) if method == "setdefault": return self._dynamic_setdefault_value(node) return _join_values(*values, default) @@ -2302,6 +2312,16 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if entry is not None: return entry return entries.get(_DYNAMIC_KEY, _UNKNOWN_VALUE) + if owner.serialized_sections: + candidates = self._expression_value(node.slice).string_values + names = candidates or { + field.name + for field in self._fields + if field.section in owner.serialized_sections + } + for section in owner.serialized_sections: + for name in names: + self._record(section, name) candidates = [*(owner.items or ()), *(value for _, value in owner.entries or ())] return _join_values(*candidates) if isinstance(node, (ast.Tuple, ast.List, ast.Set)): @@ -2567,6 +2587,23 @@ def _consume_deferred_callable_iterator_value( def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() accessor = self._expression_value(node.func) + # ``str(value)``, ``len(value)``, and ``bool(value)`` dispatch through + # implicit special-method lookup. Attribute resolution alone cannot + # see those calls, so execute the exact local protocol implementation + # when the builtin has not been shadowed. + protocol = ( + {"str": "__str__", "len": "__len__", "bool": "__bool__"}.get(node.func.id) + if isinstance(node.func, ast.Name) and not self._name_is_bound(node.func.id) + else None + ) + if protocol is not None and node.args: + owner = self._expression_value(node.args[0]) + receiver = self._descriptor_receiver(owner) + for function in self._source_index.methods(owner.instance_classes, protocol): + self._local_direct_call_value( + function, + () if self._method_is_static(function) else (receiver,), + ) accessor_arguments: list[_AbstractValue] = [] accessor_arguments_exact = True for argument in node.args: @@ -2801,6 +2838,13 @@ def visit_Subscript(self, node: ast.Subscript) -> None: if isinstance(node.ctx, ast.Load): self._record_possible_exception(before) + def visit_Assert(self, node: ast.Assert) -> None: + """Visit the assertion message only on paths where it can execute.""" + + self.visit(node.test) + if node.msg is not None and self._static_truth(node.test) is not True: + self.visit(node.msg) + @staticmethod def _join_states( *states: Mapping[str, _AbstractValue], diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 00fdb1b660..1045dd564d 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3063,6 +3063,97 @@ def test_runtime_scan_tracks_serialized_mapping_method_reads(contract, tmp_path: assert contract.runtime_reads(tmp_path, fields) == fields +def test_runtime_scan_fails_closed_for_unknown_serialized_mapping_keys( + contract, tmp_path: Path +) -> None: + (tmp_path / "dynamic_serialized_keys.py").write_text( + """ +def runtime_key(): + return external_key() + +key = runtime_key() +config.evaluation.model_dump().get(key) +config.evaluation.model_dump()[key] +""", + encoding="utf-8", + ) + fields = contract.schema_fields() + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + field for field in fields if field.section == "evaluation" + ) + + +def test_runtime_scan_executes_implicit_local_special_method_protocols( + contract, tmp_path: Path +) -> None: + (tmp_path / "implicit_protocols.py").write_text( + """ +class Wrapper: + def __init__(self, section): + self.section = section + + def __str__(self): + return self.section.stage1_enabled + + def __len__(self): + return self.section.stage2_enabled + + def __bool__(self): + return self.section.stage3_enabled + +wrapped = Wrapper(config.evaluation) +str(wrapped) +len(wrapped) +bool(wrapped) + +str = external_str +len = external_len +bool = external_bool +str(Wrapper(config.consensus)) +len(Wrapper(config.consensus)) +bool(Wrapper(config.consensus)) +""", + encoding="utf-8", + ) + fields = frozenset( + { + *( + contract.ConfigField("evaluation", name) + for name in ( + "stage1_enabled", + "stage2_enabled", + "stage3_enabled", + ) + ), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == fields - { + contract.ConfigField("consensus", "models") + } + + +def test_runtime_scan_skips_statically_unreachable_assert_messages( + contract, tmp_path: Path +) -> None: + (tmp_path / "assert_messages.py").write_text( + """ +assert True, config.evaluation.stage1_enabled +assert False, config.evaluation.stage2_enabled +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage2_enabled")} + ) + + def test_runtime_scan_tracks_bound_getattribute_reads(contract, tmp_path: Path) -> None: (tmp_path / "bound_getattribute.py").write_text( """ From fb17d2f91cdec6321fa494304817287fa7a36e93 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 19:18:59 +0900 Subject: [PATCH 59/70] fix: model implicit config protocols --- scripts/check-config-reference-contract.py | 78 ++++++++++++++++--- .../test_check_config_reference_contract.py | 62 +++++++++++++++ 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 71d4ca4c28..c94684d71b 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -1213,6 +1213,24 @@ def _call_targets( targets.extend((function, None) for function in self._function_value(node)) return tuple(targets) + def _invoke_implicit_protocol( + self, + owner: _AbstractValue, + method_name: str, + arguments: tuple[_AbstractValue, ...] = (), + ) -> _AbstractValue: + """Execute a local implementation selected by Python implicitly.""" + receiver = self._descriptor_receiver(owner) + return _join_values( + *( + self._local_direct_call_value( + function, + (() if self._method_is_static(function) else (receiver,)) + arguments, + ) + for function in self._source_index.methods(owner.instance_classes, method_name) + ) + ) + @staticmethod def _is_accessor_callback(value: _AbstractValue) -> bool: return bool(value.accessed_attributes or _GETATTR_BUILTIN in value.origins) @@ -2587,23 +2605,36 @@ def _consume_deferred_callable_iterator_value( def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() accessor = self._expression_value(node.func) - # ``str(value)``, ``len(value)``, and ``bool(value)`` dispatch through - # implicit special-method lookup. Attribute resolution alone cannot + # Builtins below dispatch through implicit special-method lookup. + # Attribute resolution alone cannot # see those calls, so execute the exact local protocol implementation # when the builtin has not been shadowed. protocol = ( - {"str": "__str__", "len": "__len__", "bool": "__bool__"}.get(node.func.id) + { + "str": "__str__", + "repr": "__repr__", + "ascii": "__repr__", + "len": "__len__", + "bool": "__bool__", + "hash": "__hash__", + }.get(node.func.id) if isinstance(node.func, ast.Name) and not self._name_is_bound(node.func.id) else None ) if protocol is not None and node.args: - owner = self._expression_value(node.args[0]) - receiver = self._descriptor_receiver(owner) - for function in self._source_index.methods(owner.instance_classes, protocol): - self._local_direct_call_value( - function, - () if self._method_is_static(function) else (receiver,), - ) + self._invoke_implicit_protocol(self._expression_value(node.args[0]), protocol) + if ( + isinstance(node.func, ast.Name) + and node.func.id == "format" + and not self._name_is_bound(node.func.id) + and node.args + ): + specification = ( + self._expression_value(node.args[1]) if len(node.args) > 1 else _UNKNOWN_VALUE + ) + self._invoke_implicit_protocol( + self._expression_value(node.args[0]), "__format__", (specification,) + ) accessor_arguments: list[_AbstractValue] = [] accessor_arguments_exact = True for argument in node.args: @@ -2824,8 +2855,31 @@ def visit_Compare(self, node: ast.Compare) -> None: for operator, comparator in zip(node.ops, node.comparators, strict=True): if isinstance(operator, (ast.In, ast.NotIn)): self._consume_deferred_callable_iterator(comparator, mode="one_turn") + self._invoke_implicit_protocol( + self._expression_value(comparator), + "__contains__", + (self._expression_value(node.left),), + ) self.generic_visit(node) + def visit_FormattedValue(self, node: ast.FormattedValue) -> None: + """Execute local formatting protocols selected by f-strings.""" + self.visit(node.value) + owner = self._expression_value(node.value) + if node.conversion == ord("s"): + self._invoke_implicit_protocol(owner, "__str__") + elif node.conversion in {ord("r"), ord("a")}: + self._invoke_implicit_protocol(owner, "__repr__") + else: + specification = ( + self._expression_value(node.format_spec) + if node.format_spec is not None + else _UNKNOWN_VALUE + ) + self._invoke_implicit_protocol(owner, "__format__", (specification,)) + if node.format_spec is not None: + self.visit(node.format_spec) + def visit_Subscript(self, node: ast.Subscript) -> None: before = self._binding_snapshot() if isinstance(node.ctx, ast.Load): @@ -3281,6 +3335,10 @@ def visit_For(self, node: ast.For) -> None: ) def visit_AsyncFor(self, node: ast.AsyncFor) -> None: + iterable = self._expression_value(node.iter) + iterator = self._invoke_implicit_protocol(iterable, "__aiter__") + self._invoke_implicit_protocol(iterator, "__anext__") + self._invoke_implicit_protocol(iterable, "__anext__") self.visit_For(node) def visit_While(self, node: ast.While) -> None: diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 1045dd564d..89aaf479e9 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3135,6 +3135,68 @@ def __bool__(self): } +def test_runtime_scan_executes_implicit_membership_iteration_hash_and_format_protocols( + contract, tmp_path: Path +) -> None: + (tmp_path / "implicit_protocols_extended.py").write_text( + """ +class Reader: + def __init__(self, section): + self.section = section + + def __contains__(self, item): + return self.section.stage1_enabled + + def __hash__(self): + return self.section.stage2_enabled + + def __format__(self, spec): + return self.section.stage3_enabled + +class AsyncReader: + def __init__(self, section): + self.section = section + + def __aiter__(self): + return self + + async def __anext__(self): + return self.section.stage4_enabled + +reader = Reader(config.evaluation) +1 in reader +hash(reader) +format(reader, "") +f"{reader}" + +async def consume(): + async for item in AsyncReader(config.evaluation): + break + +unused = Reader(config.consensus) +""", + encoding="utf-8", + ) + fields = frozenset( + { + *( + contract.ConfigField("evaluation", name) + for name in ( + "stage1_enabled", + "stage2_enabled", + "stage3_enabled", + "stage4_enabled", + ) + ), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == fields - { + contract.ConfigField("consensus", "models") + } + + def test_runtime_scan_skips_statically_unreachable_assert_messages( contract, tmp_path: Path ) -> None: From c80c44f841f7f9640e9e2bf7ae7d5ad9fcd40cf6 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 19:36:41 +0900 Subject: [PATCH 60/70] fix: close implicit scanner dispatch gaps --- scripts/check-config-reference-contract.py | 38 +++++++++- .../test_check_config_reference_contract.py | 71 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index c94684d71b..202c9ffd11 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -1800,6 +1800,11 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _AbstractValue(callables=(_CallableTarget(node),)) if isinstance(node, ast.Attribute): owner = self._expression_value(node.value) + self._invoke_implicit_protocol( + owner, + "__getattribute__", + (_AbstractValue(string_value=node.attr, string_values=frozenset({node.attr})),), + ) attribute = self._named_value(owner.attributes, node.attr) if attribute is not None: return attribute @@ -2617,6 +2622,10 @@ def visit_Call(self, node: ast.Call) -> None: "len": "__len__", "bool": "__bool__", "hash": "__hash__", + "int": "__int__", + "float": "__float__", + "complex": "__complex__", + "bytes": "__bytes__", }.get(node.func.id) if isinstance(node.func, ast.Name) and not self._name_is_bound(node.func.id) else None @@ -2862,6 +2871,30 @@ def visit_Compare(self, node: ast.Compare) -> None: ) self.generic_visit(node) + def visit_BinOp(self, node: ast.BinOp) -> None: + """Execute local numeric protocols selected by binary operators.""" + protocols = { + ast.Add: ("__add__", "__radd__"), + ast.Sub: ("__sub__", "__rsub__"), + ast.Mult: ("__mul__", "__rmul__"), + ast.MatMult: ("__matmul__", "__rmatmul__"), + ast.Div: ("__truediv__", "__rtruediv__"), + ast.FloorDiv: ("__floordiv__", "__rfloordiv__"), + ast.Mod: ("__mod__", "__rmod__"), + ast.Pow: ("__pow__", "__rpow__"), + ast.LShift: ("__lshift__", "__rlshift__"), + ast.RShift: ("__rshift__", "__rrshift__"), + ast.BitOr: ("__or__", "__ror__"), + ast.BitXor: ("__xor__", "__rxor__"), + ast.BitAnd: ("__and__", "__rand__"), + } + direct, reflected = protocols[type(node.op)] + left = self._expression_value(node.left) + right = self._expression_value(node.right) + self._invoke_implicit_protocol(left, direct, (right,)) + self._invoke_implicit_protocol(right, reflected, (left,)) + self.generic_visit(node) + def visit_FormattedValue(self, node: ast.FormattedValue) -> None: """Execute local formatting protocols selected by f-strings.""" self.visit(node.value) @@ -4628,6 +4661,9 @@ def _local_call_value( return _AbstractValue(identity=frozenset({id(call)})) function_id = id(function) if function_id in self._active_calls: + self._call_executions.setdefault(id(call), []).append( + _FunctionExecution((_UNKNOWN_VALUE,), False) + ) return _UNKNOWN_VALUE self._active_calls.add(function_id) try: @@ -4654,7 +4690,7 @@ def _local_direct_call_execution( ) -> _FunctionExecution: function_id = id(function) if function_id in self._active_calls: - return _FunctionExecution((_UNKNOWN_VALUE,), True) + return _FunctionExecution((_UNKNOWN_VALUE,), False) self._active_calls.add(function_id) try: execution = self._visit_function_body( diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 89aaf479e9..296e1971a6 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3197,6 +3197,77 @@ async def consume(): } +def test_runtime_scan_executes_numeric_and_getattribute_protocols(contract, tmp_path: Path) -> None: + (tmp_path / "implicit_numeric_protocols.py").write_text( + """ +class Reader: + def __init__(self, section): + self.section = section + + def __int__(self): + return self.section.stage1_enabled + + def __add__(self, other): + return self.section.stage2_enabled + + def __getattribute__(self, name): + return self.section.stage3_enabled + +reader = Reader(config.evaluation) +int(reader) +reader + 1 +reader.value +unused = Reader(config.consensus) +""", + encoding="utf-8", + ) + fields = frozenset( + { + *( + contract.ConfigField("evaluation", name) + for name in ( + "stage1_enabled", + "stage2_enabled", + "stage3_enabled", + ) + ), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == fields - { + contract.ConfigField("consensus", "models") + } + + +def test_runtime_scan_does_not_continue_past_unconditional_recursion( + contract, tmp_path: Path +) -> None: + (tmp_path / "recursive_readers.py").write_text( + """ +def self_recursive(config): + self_recursive(config) + config.evaluation.stage1_enabled + +def first(config): + second(config) + config.evaluation.stage2_enabled + +def second(config): + first(config) + +self_recursive(config) +first(config) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset() + + def test_runtime_scan_skips_statically_unreachable_assert_messages( contract, tmp_path: Path ) -> None: From 8328af7e59385f23043a4c01d817814c27a691e1 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 19:54:34 +0900 Subject: [PATCH 61/70] fix(audit): model implicit Python dispatch precisely --- scripts/check-config-reference-contract.py | 104 +++++++++- .../test_check_config_reference_contract.py | 184 ++++++++++++++++++ 2 files changed, 280 insertions(+), 8 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 202c9ffd11..fa6e2f3c23 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -231,6 +231,7 @@ class _DeferredPartial: _UNKNOWN_VALUE = _AbstractValue() _STATIC_UNKNOWN = object() +_NOT_IMPLEMENTED = "" _ANNOTATION_MODULE = "" _TYPE_CHECKING_FALSE = "" @@ -992,6 +993,21 @@ def inherited(class_node: ast.ClassDef, seen: frozenset[int]) -> _FunctionSet: return frozenset().union(*(inherited(class_node, frozenset()) for class_node in classes)) + def is_strict_subclass(self, child: ast.ClassDef, parent: ast.ClassDef) -> bool: + """Return whether an indexed class is a strict descendant of another.""" + + pending = list(self._class_bases.get(id(child), ())) + seen: set[int] = set() + while pending: + candidate = pending.pop() + if candidate is parent: + return True + if id(candidate) in seen: + continue + seen.add(id(candidate)) + pending.extend(self._class_bases.get(id(candidate), ())) + return False + @dataclass(frozen=True) class _AbruptPath: @@ -1055,6 +1071,7 @@ def __init__( self._caught_exception_stack: list[tuple[_AbruptPath, ...]] = [] self._function_body_depth = 0 self._class_member_values: dict[int, tuple[_AbstractValue, ...]] = {} + self._class_attributes: dict[int, tuple[tuple[str, _AbstractValue], ...]] = {} self._closure_bindings: dict[int, dict[str, _AbstractValue]] = {} self._deferred_generators: dict[int, _DeferredGenerator] = {} self._deferred_coroutines: dict[int, _DeferredCoroutine] = {} @@ -1762,7 +1779,9 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: ) if isinstance(node, ast.Name): value = ( - _origin_value(_GETATTR_BUILTIN) + _origin_value(_NOT_IMPLEMENTED) + if node.id == "NotImplemented" and not self._name_is_bound("NotImplemented") + else _origin_value(_GETATTR_BUILTIN) if node.id == "getattr" and not self._name_is_bound("getattr") else _origin_value(_HASATTR_BUILTIN) if node.id == "hasattr" and not self._name_is_bound("hasattr") @@ -1806,8 +1825,44 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: (_AbstractValue(string_value=node.attr, string_values=frozenset({node.attr})),), ) attribute = self._named_value(owner.attributes, node.attr) + class_attribute = _join_values( + *( + value + for class_node in owner.instance_classes + if ( + value := self._named_value( + self._class_attributes.get(id(class_node)), node.attr + ) + ) + is not None + ) + ) + descriptor_methods = self._source_index.methods( + class_attribute.instance_classes, "__get__" + ) + data_descriptor = bool( + self._source_index.methods(class_attribute.instance_classes, "__set__") + or self._source_index.methods(class_attribute.instance_classes, "__delete__") + ) + if descriptor_methods and (attribute is None or data_descriptor): + descriptor = self._descriptor_receiver(class_attribute) + return _join_values( + *( + self._local_direct_call_value( + function, + ( + descriptor, + self._descriptor_receiver(owner), + _AbstractValue(classes=owner.instance_classes), + ), + ) + for function in descriptor_methods + ) + ) if attribute is not None: return attribute + if class_attribute != _UNKNOWN_VALUE: + return class_attribute property_getters = tuple( function for function in self._source_index.methods(owner.instance_classes, node.attr) @@ -2832,6 +2887,21 @@ def visit_Call(self, node: ast.Call) -> None: def visit_Await(self, node: ast.Await) -> None: """Awaiting ``anext`` consumes one turn of an async generator.""" self._consume_deferred_coroutine(node.value) + owner = self._expression_value(node.value) + receiver = self._descriptor_receiver(owner) + for function in self._source_index.methods(owner.instance_classes, "__await__"): + if _is_generator_function(function): + self._visit_function_body( + function, + self._bound_direct_arguments(function, (receiver,)), + consume_generator=True, + generator_consumer_mode="full", + generator_identity=id(node) ^ id(function), + ) + continue + returned = self._local_direct_call_value(function, (receiver,)) + self._consume_deferred_generator_value(returned) + self._consume_deferred_callable_iterator_value(returned) if isinstance(node.value, ast.Call): accessor = self._expression_value(node.value.func) if accessor.origins & _ASYNC_BUILTIN_CONSUMER_ORIGINS: @@ -2864,11 +2934,15 @@ def visit_Compare(self, node: ast.Compare) -> None: for operator, comparator in zip(node.ops, node.comparators, strict=True): if isinstance(operator, (ast.In, ast.NotIn)): self._consume_deferred_callable_iterator(comparator, mode="one_turn") - self._invoke_implicit_protocol( - self._expression_value(comparator), - "__contains__", - (self._expression_value(node.left),), - ) + owner = self._expression_value(comparator) + if self._source_index.methods(owner.instance_classes, "__contains__"): + self._invoke_implicit_protocol( + owner, + "__contains__", + (self._expression_value(node.left),), + ) + else: + self._consume_local_iterator(comparator, mode="one_turn") self.generic_visit(node) def visit_BinOp(self, node: ast.BinOp) -> None: @@ -2891,8 +2965,21 @@ def visit_BinOp(self, node: ast.BinOp) -> None: direct, reflected = protocols[type(node.op)] left = self._expression_value(node.left) right = self._expression_value(node.right) - self._invoke_implicit_protocol(left, direct, (right,)) - self._invoke_implicit_protocol(right, reflected, (left,)) + direct_methods = self._source_index.methods(left.instance_classes, direct) + reflected_methods = self._source_index.methods(right.instance_classes, reflected) + reflected_precedes = any( + self._source_index.is_strict_subclass(right_class, left_class) + for right_class in right.instance_classes + for left_class in left.instance_classes + ) + if reflected_precedes and reflected_methods: + result = self._invoke_implicit_protocol(right, reflected, (left,)) + if _NOT_IMPLEMENTED in result.origins and direct_methods: + self._invoke_implicit_protocol(left, direct, (right,)) + else: + result = self._invoke_implicit_protocol(left, direct, (right,)) + if (not direct_methods or _NOT_IMPLEMENTED in result.origins) and reflected_methods: + self._invoke_implicit_protocol(right, reflected, (left,)) self.generic_visit(node) def visit_FormattedValue(self, node: ast.FormattedValue) -> None: @@ -4954,6 +5041,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: self._class_member_values[id(node)] = tuple( value for name, value in self._states[-1].items() if not name.startswith("_") ) + self._class_attributes[id(node)] = class_attributes finally: self._nonlocal_names.pop() self._global_names.pop() diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 296e1971a6..5988526fcb 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3197,6 +3197,190 @@ async def consume(): } +def test_runtime_scan_executes_descriptors_only_when_attribute_resolution_selects_them( + contract, tmp_path: Path +) -> None: + (tmp_path / "descriptor_protocol.py").write_text( + """ +class Descriptor: + def __init__(self, section): + self.section = section + + def __get__(self, instance, owner): + return self.section.stage1_enabled + +class Active: + value = Descriptor(config.evaluation) + +class Unused: + value = Descriptor(config.consensus) + +class Shadowed: + value = Descriptor(config.consensus) + +Active().value +Unused() +shadowed = Shadowed() +shadowed.value = object() +shadowed.value +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage1_enabled")} + ) + + +def test_runtime_scan_models_membership_fallback_order_without_overreach( + contract, tmp_path: Path +) -> None: + (tmp_path / "membership_fallback.py").write_text( + """ +class IterReader: + def __init__(self, section): + self.section = section + + def __iter__(self): + if self.section.stage1_enabled: + yield 1 + +class ItemReader: + def __init__(self, section): + self.section = section + + def __getitem__(self, index): + if self.section.stage2_enabled: + return index + raise IndexError + +class ContainsReader: + def __init__(self, section): + self.section = section + + def __contains__(self, item): + return self.section.stage3_enabled + + def __iter__(self): + return iter((self.section.stage4_enabled,)) + +1 in IterReader(config.evaluation) +1 in ItemReader(config.evaluation) +1 in ContainsReader(config.evaluation) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ( + "stage1_enabled", + "stage2_enabled", + "stage3_enabled", + "stage4_enabled", + ) + ) + + assert contract.runtime_reads(tmp_path, fields) == fields - { + contract.ConfigField("evaluation", "stage4_enabled") + } + + +def test_runtime_scan_executes_custom_awaitables_only_when_awaited( + contract, tmp_path: Path +) -> None: + (tmp_path / "custom_awaitable.py").write_text( + """ +import asyncio + +class Awaitable: + def __init__(self, section): + self.section = section + + def __await__(self): + if self.section.stage1_enabled: + yield + return None + +async def consume(): + await Awaitable(config.evaluation) + pending = Awaitable(config.consensus) + pending = object() + +Awaitable(config.consensus) +asyncio.run(consume()) +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage1_enabled")} + ) + + +def test_runtime_scan_dispatches_reflected_operators_only_when_python_would( + contract, tmp_path: Path +) -> None: + (tmp_path / "reflected_operator.py").write_text( + """ +class Left: + def __add__(self, other): + return 1 + +class Fallback: + def __add__(self, other): + return NotImplemented + +class Right: + def __init__(self, section): + self.section = section + + def __radd__(self, other): + return self.section.stage1_enabled + +class Base: + def __init__(self, section): + self.section = section + + def __add__(self, other): + return self.section.stage2_enabled + +class Child(Base): + def __radd__(self, other): + return self.section.stage3_enabled + +Left() + Right(config.consensus) +Fallback() + Right(config.evaluation) +Base(config.evaluation) + Child(config.evaluation) +""", + encoding="utf-8", + ) + fields = frozenset( + { + *(contract.ConfigField("evaluation", f"stage{index}_enabled") for index in range(1, 4)), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + } + ) + + def test_runtime_scan_executes_numeric_and_getattribute_protocols(contract, tmp_path: Path) -> None: (tmp_path / "implicit_numeric_protocols.py").write_text( """ From cc6dbe0ec1b6224f4ffa7c45c8435cc3e0035eb5 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 20:19:32 +0900 Subject: [PATCH 62/70] fix(audit): cover truth and protocol dispatch --- scripts/check-config-reference-contract.py | 102 +++++++++++++- .../test_check_config_reference_contract.py | 131 ++++++++++++++++++ 2 files changed, 226 insertions(+), 7 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index fa6e2f3c23..e816f89b06 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -1248,6 +1248,13 @@ def _invoke_implicit_protocol( ) ) + def _invoke_truth_protocol(self, owner: _AbstractValue) -> None: + """Execute Python's ``__bool__``/``__len__`` truth-test fallback.""" + if self._source_index.methods(owner.instance_classes, "__bool__"): + self._invoke_implicit_protocol(owner, "__bool__") + else: + self._invoke_implicit_protocol(owner, "__len__") + @staticmethod def _is_accessor_callback(value: _AbstractValue) -> bool: return bool(value.accessed_attributes or _GETATTR_BUILTIN in value.origins) @@ -2201,7 +2208,10 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if _EXITSTACK_FACTORY in function_value.origins: return _AbstractValue(origins=frozenset({_EXITSTACK_VALUE})) if _ENTER_CONTEXT_CONSUMER in function_value.origins and node.args: - return self._context_entry_value(self._expression_value(node.args[0])) + return self._context_entry_value( + self._expression_value(node.args[0]), + asynchronous=False, + ) if _TYPING_CAST in function_value.origins and len(node.args) >= 2: return self._expression_value(node.args[1]) if function_value.origins & _COPY_IDENTITY_TRANSFORMS and node.args: @@ -2504,6 +2514,7 @@ def visit_Expr(self, node: ast.Expr) -> None: def visit_IfExp(self, node: ast.IfExp) -> None: self.visit(node.test) + self._invoke_truth_protocol(self._expression_value(node.test)) truth = self._static_truth(node.test) if truth is None: self.visit(node.body) @@ -2512,9 +2523,25 @@ def visit_IfExp(self, node: ast.IfExp) -> None: self.visit(node.body if truth else node.orelse) def visit_BoolOp(self, node: ast.BoolOp) -> None: + for value in node.values[:-1]: + self._invoke_truth_protocol(self._expression_value(value)) for value in self._reachable_bool_values(node): self.visit(value) + def visit_UnaryOp(self, node: ast.UnaryOp) -> None: + self.visit(node.operand) + owner = self._expression_value(node.operand) + protocols = { + ast.USub: "__neg__", + ast.UAdd: "__pos__", + ast.Invert: "__invert__", + } + protocol = protocols.get(type(node.op)) + if protocol is not None: + self._invoke_implicit_protocol(owner, protocol) + elif isinstance(node.op, ast.Not): + self._invoke_truth_protocol(owner) + def _consume_deferred_generator_value( self, value: _AbstractValue, *, mode: str = "full" ) -> None: @@ -2768,6 +2795,10 @@ def visit_Call(self, node: ast.Call) -> None: for function, receiver in self._call_targets(key_keyword.value): values = ((receiver,) if receiver is not None else ()) + (item,) self._local_direct_call_value(function, values) + if isinstance(node.func, ast.Name) and node.func.id == "iter" and node.args: + self._invoke_implicit_protocol(self._expression_value(node.args[0]), "__iter__") + if isinstance(node.func, ast.Name) and node.func.id == "next" and node.args: + self._invoke_implicit_protocol(self._expression_value(node.args[0]), "__next__") if isinstance(node.func, ast.Attribute) and node.func.attr == "sort": key_keyword = next((keyword for keyword in node.keywords if keyword.arg == "key"), None) owner = self._expression_value(node.func.value) @@ -2943,6 +2974,25 @@ def visit_Compare(self, node: ast.Compare) -> None: ) else: self._consume_local_iterator(comparator, mode="one_turn") + else: + protocols = { + ast.Lt: ("__lt__", "__gt__"), + ast.LtE: ("__le__", "__ge__"), + ast.Gt: ("__gt__", "__lt__"), + ast.GtE: ("__ge__", "__le__"), + ast.Eq: ("__eq__", "__eq__"), + ast.NotEq: ("__ne__", "__ne__"), + } + selected = protocols.get(type(operator)) + if selected is None: + continue + direct, reflected = selected + left = self._expression_value(node.left) + right = self._expression_value(comparator) + direct_methods = self._source_index.methods(left.instance_classes, direct) + self._invoke_implicit_protocol(left, direct, (right,)) + if not direct_methods: + self._invoke_implicit_protocol(right, reflected, (left,)) self.generic_visit(node) def visit_BinOp(self, node: ast.BinOp) -> None: @@ -3004,6 +3054,11 @@ def visit_Subscript(self, node: ast.Subscript) -> None: before = self._binding_snapshot() if isinstance(node.ctx, ast.Load): owner = self._expression_value(node.value) + self._invoke_implicit_protocol( + owner, + "__getitem__", + (self._expression_value(node.slice),), + ) field_name = self._expression_value(node.slice).string_value if field_name is not None: for section in owner.serialized_sections | (owner.origins & TRACKED_SECTIONS): @@ -3016,6 +3071,7 @@ def visit_Assert(self, node: ast.Assert) -> None: """Visit the assertion message only on paths where it can execute.""" self.visit(node.test) + self._invoke_truth_protocol(self._expression_value(node.test)) if node.msg is not None and self._static_truth(node.test) is not True: self.visit(node.msg) @@ -3330,6 +3386,26 @@ def visit_AugAssign(self, node: ast.AugAssign) -> None: owner = self._expression_value(node.target) self.visit(node.target) self.visit(node.value) + protocols = { + ast.Add: "__iadd__", + ast.Sub: "__isub__", + ast.Mult: "__imul__", + ast.MatMult: "__imatmul__", + ast.Div: "__itruediv__", + ast.FloorDiv: "__ifloordiv__", + ast.Mod: "__imod__", + ast.Pow: "__ipow__", + ast.LShift: "__ilshift__", + ast.RShift: "__irshift__", + ast.BitOr: "__ior__", + ast.BitXor: "__ixor__", + ast.BitAnd: "__iand__", + } + self._invoke_implicit_protocol( + owner, + protocols[type(node.op)], + (self._expression_value(node.value),), + ) if isinstance(node.op, ast.BitOr): right = self._expression_value(node.value) entries = dict(owner.entries or ()) @@ -3350,6 +3426,7 @@ def visit_AugAssign(self, node: ast.AugAssign) -> None: def visit_If(self, node: ast.If) -> None: self.visit(node.test) + self._invoke_truth_protocol(self._expression_value(node.test)) initial = self._binding_snapshot() truth = self._static_truth(node.test) if truth is not None: @@ -3464,6 +3541,7 @@ def visit_AsyncFor(self, node: ast.AsyncFor) -> None: def visit_While(self, node: ast.While) -> None: entry = self._binding_snapshot() self.visit(node.test) + self._invoke_truth_protocol(self._expression_value(node.test)) truth = self._static_truth(node.test) if truth is False: tested_state = self._binding_snapshot() @@ -3478,6 +3556,7 @@ def visit_While(self, node: ast.While) -> None: while True: self._restore_bindings(header) self.visit(node.test) + self._invoke_truth_protocol(self._expression_value(node.test)) tested_state = self._binding_snapshot() body_result = self._visit_binding_branch(node.body, tested_state) back_edges = [path.bindings for path in body_result.abrupt if path.kind == "continue"] @@ -3492,6 +3571,7 @@ def visit_While(self, node: ast.While) -> None: if truth is not True: self._restore_bindings(header) self.visit(node.test) + self._invoke_truth_protocol(self._expression_value(node.test)) normal_entry = self._binding_snapshot() normal_result = ( self._visit_binding_branch(node.orelse, normal_entry) @@ -4391,7 +4471,9 @@ def _bind_partialmethod_descriptors(self, owner: _AbstractValue) -> _AbstractVal attributes[target.id] = descriptor return self._attribute_replacement(owner, tuple(sorted(attributes.items()))) - def _context_entry_value(self, context_value: _AbstractValue) -> _AbstractValue: + def _context_entry_value( + self, context_value: _AbstractValue, *, asynchronous: bool + ) -> _AbstractValue: if _NULLCONTEXT_VALUE in context_value.origins and context_value.items: return context_value.items[0] if _CLOSING_VALUE in context_value.origins and context_value.items: @@ -4401,7 +4483,7 @@ def _context_entry_value(self, context_value: _AbstractValue) -> _AbstractValue: receiver = self._descriptor_receiver(context_value) entries: list[_AbstractValue] = [ self._local_direct_call_value(function, (receiver,)) - for method_name in ("__enter__", "__aenter__") + for method_name in (("__aenter__",) if asynchronous else ("__enter__",)) for function in self._source_index.methods(context_value.instance_classes, method_name) ] for identity in context_value.identity: @@ -4411,7 +4493,8 @@ def _context_entry_value(self, context_value: _AbstractValue) -> _AbstractValue: ): continue is_context_manager = any( - _callable_name(decorator) in {"contextmanager", "asynccontextmanager"} + _callable_name(decorator) + == ("asynccontextmanager" if asynchronous else "contextmanager") for decorator in deferred.node.decorator_list ) if not is_context_manager: @@ -4429,6 +4512,7 @@ def _context_entry_value(self, context_value: _AbstractValue) -> _AbstractValue: return _join_values(*entries) def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: + asynchronous = isinstance(node, ast.AsyncWith) context_values: list[_AbstractValue] = [] for item in node.items: self.visit(item.context_expr) @@ -4440,12 +4524,16 @@ def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: deferred is not None and isinstance(deferred.node, (ast.FunctionDef, ast.AsyncFunctionDef)) and any( - _callable_name(decorator) in {"contextmanager", "asynccontextmanager"} + _callable_name(decorator) + == ("asynccontextmanager" if asynchronous else "contextmanager") for decorator in deferred.node.decorator_list ) ): self._consume_deferred_generator(item.context_expr, mode="one_turn") - entry_value = self._context_entry_value(context_value) + entry_value = self._context_entry_value( + context_value, + asynchronous=asynchronous, + ) if item.optional_vars is not None: self._visit_store_target(item.optional_vars) self._bind_target_value(item.optional_vars, entry_value) @@ -4454,7 +4542,7 @@ def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: body_result = self._visit_binding_branch(node.body, self._binding_snapshot()) for context_value in reversed(context_values): receiver = self._descriptor_receiver(context_value) - for method_name in ("__exit__", "__aexit__"): + for method_name in ("__aexit__",) if asynchronous else ("__exit__",): for function in self._source_index.methods( context_value.instance_classes, method_name ): diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 5988526fcb..116d7891e9 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3238,6 +3238,137 @@ class Shadowed: ) +def test_runtime_scan_executes_truth_and_fallback_len_protocols_only_when_tested( + contract, tmp_path: Path +) -> None: + (tmp_path / "truth_protocols.py").write_text( + """ +class Reader: + def __init__(self, section): + self.section = section + + def __bool__(self): + return self.section.stage1_enabled + +class LenReader: + def __init__(self, section): + self.section = section + + def __len__(self): + return self.section.stage2_enabled + +if Reader(config.evaluation): + pass +if not LenReader(config.evaluation): + pass +unused = Reader(config.consensus) +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("consensus", "models"), + } + ) + + assert contract.runtime_reads(tmp_path, fields) == { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + } + + +def test_runtime_scan_models_subscription_iter_next_and_unary_protocols( + contract, tmp_path: Path +) -> None: + (tmp_path / "ordinary_protocols.py").write_text( + """ +class Reader: + def __init__(self, section): + self.section = section + + def __getitem__(self, index): + return self.section.stage1_enabled + + def __iter__(self): + return self + + def __next__(self): + return self.section.stage2_enabled + + def __lt__(self, other): + return self.section.stage3_enabled + + def __neg__(self): + return self.section.stage4_enabled + + def __iadd__(self, other): + return self.section.stage5_enabled + +reader = Reader(config.evaluation) +reader[0] +iter(reader) +next(reader) +reader < 1 +-reader +reader += 1 +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", f"stage{index}_enabled") for index in range(1, 6) + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_selects_context_manager_protocol_by_syntax(contract, tmp_path: Path) -> None: + (tmp_path / "context_protocols.py").write_text( + """ +import asyncio + +class Reader: + def __init__(self, section): + self.section = section + + def __enter__(self): + return self.section.stage1_enabled + + def __exit__(self, exc_type, exc, tb): + return self.section.stage2_enabled + + async def __aenter__(self): + return self.section.stage3_enabled + + async def __aexit__(self, exc_type, exc, tb): + return self.section.stage4_enabled + +with Reader(config.evaluation): + pass + +async def consume(): + async with Reader(config.consensus): + pass + +asyncio.run(consume()) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField(section, f"stage{index}_enabled") + for section in ("evaluation", "consensus") + for index in range(1, 5) + ) + + assert contract.runtime_reads(tmp_path, fields) == { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("consensus", "stage3_enabled"), + contract.ConfigField("consensus", "stage4_enabled"), + } + + def test_runtime_scan_models_membership_fallback_order_without_overreach( contract, tmp_path: Path ) -> None: From 7722a0e38f6349736695276606d86819dff0d980 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 20:35:56 +0900 Subject: [PATCH 63/70] fix(config): model subscription mutation and sys exit --- scripts/check-config-reference-contract.py | 63 +++++++++++++- .../test_check_config_reference_contract.py | 86 +++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index e816f89b06..ec6bc2c461 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -272,6 +272,8 @@ class _DeferredPartial: ) _ATEXIT_MODULE = "" _ATEXIT_REGISTER = "" +_SYS_MODULE = "" +_SYS_EXIT = "" _ASYNCIO_MODULE = "" _ASYNCIO_CONSUMERS = frozenset({"create_task", "ensure_future", "gather", "run"}) _ASYNCIO_CONSUMER_ORIGINS = frozenset(f"" for name in _ASYNCIO_CONSUMERS) @@ -1914,6 +1916,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_RANGE_BUILTIN) if _ATEXIT_MODULE in owner.origins and node.attr == "register": return _origin_value(_ATEXIT_REGISTER) + if _SYS_MODULE in owner.origins and node.attr == "exit": + return _origin_value(_SYS_EXIT) if _BUILTINS_MODULE in owner.origins and node.attr in _TRACKED_BUILTIN_CONSUMERS: return _origin_value(f"") if _FUNCTOOLS_MODULE in owner.origins and node.attr == "partial": @@ -2897,6 +2901,17 @@ def visit_Call(self, node: ast.Call) -> None: for section in accessor_arguments[0].serialized_sections: self._record(section, field_name) self.generic_visit(node) + if _SYS_EXIT in accessor.origins: + self._append_abrupt( + self._flow_abrupts[-1], + _AbruptPath( + "raise", + self._binding_snapshot(), + exception_type="SystemExit", + ), + ) + self._path_reachable = False + return executions = self._call_executions.pop(id(node), []) if executions and all(not execution.returns_to_caller for execution in executions): for execution in executions: @@ -3304,6 +3319,41 @@ def _visit_store_target(self, target: ast.expr) -> None: elif isinstance(target, ast.Starred): self._visit_store_target(target.value) + def _invoke_subscription_store(self, target: ast.expr, value: _AbstractValue) -> None: + """Execute local ``__setitem__`` implementations selected by assignment.""" + if isinstance(target, ast.Subscript): + self._invoke_implicit_protocol( + self._expression_value(target.value), + "__setitem__", + (self._expression_value(target.slice), value), + ) + return + if isinstance(target, ast.Starred): + self._invoke_subscription_store(target.value, value) + return + if not isinstance(target, (ast.Tuple, ast.List)): + return + child_values = ( + value.items + if value.items is not None and len(value.items) == len(target.elts) + else tuple(_conservative_value(value) for _ in target.elts) + ) + for child, child_value in zip(target.elts, child_values, strict=True): + self._invoke_subscription_store(child, child_value) + + def _invoke_subscription_delete(self, target: ast.expr) -> None: + """Execute local ``__delitem__`` implementations selected by deletion.""" + if isinstance(target, ast.Subscript): + self._invoke_implicit_protocol( + self._expression_value(target.value), + "__delitem__", + (self._expression_value(target.slice),), + ) + return + if isinstance(target, (ast.Tuple, ast.List)): + for child in target.elts: + self._invoke_subscription_delete(child) + def _assign_store_target(self, target: ast.expr, value: _AbstractValue) -> None: if isinstance(target, ast.Attribute): owner = self._expression_value(target.value) @@ -3317,6 +3367,10 @@ def _assign_store_target(self, target: ast.expr, value: _AbstractValue) -> None: if not isinstance(target, ast.Subscript): return owner = self._expression_value(target.value) + if self._source_index.methods(owner.instance_classes, "__setitem__"): + # The local implementation above owns mutation semantics. Constructor + # arguments stored in ``items`` are call provenance, not sequence slots. + return if owner.entries is None and not owner.identity: return if owner.items is not None: @@ -3349,6 +3403,7 @@ def visit_Assign(self, node: ast.Assign) -> None: function = self._function_value(node.value) for target in node.targets: self._visit_store_target(target) + self._invoke_subscription_store(target, value) self._assign_store_target(target, value) self._bind_destructured(target, value) self._bind_annotation_target(target, annotation_value) @@ -3362,6 +3417,7 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: self.visit(node.value) self._visit_store_target(node.target) value = self._expression_value(node.value) + self._invoke_subscription_store(node.target, value) self._assign_store_target(node.target, value) self._bind_destructured(node.target, value) self._bind_annotation_target(node.target, self._annotation_value(node.value)) @@ -3380,7 +3436,8 @@ def visit_Delete(self, node: ast.Delete) -> None: self._annotations[index].pop(target.id, None) self._functions[index].pop(target.id, None) else: - self.visit(target) + self._visit_store_target(target) + self._invoke_subscription_delete(target) def visit_AugAssign(self, node: ast.AugAssign) -> None: owner = self._expression_value(node.target) @@ -4243,6 +4300,8 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: if alias.name == "heapq" else _origin_value(_ATEXIT_MODULE) if alias.name == "atexit" + else _origin_value(_SYS_MODULE) + if alias.name == "sys" else _origin_value(_ASYNCIO_MODULE) if alias.name == "asyncio" else _origin_value(_CONTEXTLIB_MODULE) @@ -4337,6 +4396,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "heapq" and alias.name in _HEAPQ_KEY_CONSUMERS else _origin_value(_ATEXIT_REGISTER) if node.module == "atexit" and alias.name == "register" + else _origin_value(_SYS_EXIT) + if node.module == "sys" and alias.name == "exit" else _origin_value(f"") if node.module == "asyncio" and alias.name in _ASYNCIO_CONSUMERS else _origin_value(_NULLCONTEXT_FACTORY) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 116d7891e9..84be1e90b4 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3323,6 +3323,44 @@ def __iadd__(self, other): assert contract.runtime_reads(tmp_path, fields) == fields +def test_runtime_scan_models_subscription_store_and_delete_protocols_without_overreach( + contract, tmp_path: Path +) -> None: + (tmp_path / "subscription_mutation_protocols.py").write_text( + """ +class Writer: + def __init__(self, section): + self.section = section + + def __setitem__(self, key, value): + return self.section.stage1_enabled + + def __delitem__(self, key): + return self.section.stage2_enabled + +writer = Writer(config.evaluation) +writer[0] = 1 +del writer[0] + +overwritten = Writer(config.consensus) +overwritten = {} +overwritten[0] = 1 +del overwritten[0] +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField(section, f"stage{index}_enabled") + for section in ("evaluation", "consensus") + for index in (1, 2) + ) + + assert contract.runtime_reads(tmp_path, fields) == { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + } + + def test_runtime_scan_selects_context_manager_protocol_by_syntax(contract, tmp_path: Path) -> None: (tmp_path / "context_protocols.py").write_text( """ @@ -5688,6 +5726,54 @@ def terminating_alias_path(config, report, enabled): ) +def test_runtime_scan_models_exact_sys_exit_aliases_without_shadowing_overreach( + contract, tmp_path: Path +) -> None: + (tmp_path / "sys_exit_paths.py").write_text( + """ +import sys +import sys as system +from sys import exit as stop + +def module_exit(config): + sys.exit(0) + return config.evaluation.stage1_enabled + +def aliased_module_exit(config): + system.exit(0) + return config.evaluation.stage2_enabled + +def imported_exit(config): + stop(0) + return config.evaluation.stage3_enabled + +def shadowed_module(config, sys): + sys.exit(0) + return config.evaluation.stage4_enabled + +def shadowed_import(config): + stop = lambda code: code + stop(0) + return config.evaluation.stage5_enabled + +try: + sys.exit(0) +except SystemExit: + caught = config.evaluation.stage6_enabled +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", f"stage{index}_enabled") for index in range(1, 7) + ) + + assert contract.runtime_reads(tmp_path, fields) == { + contract.ConfigField("evaluation", "stage4_enabled"), + contract.ConfigField("evaluation", "stage5_enabled"), + contract.ConfigField("evaluation", "stage6_enabled"), + } + + def test_every_schema_field_needs_exactly_one_disposition(contract) -> None: active = contract.ConfigField("evaluation", "active") inert = contract.ConfigField("evaluation", "inert") From b1d710db010b7d1491b605cc02f025fe4057d821 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 20:57:12 +0900 Subject: [PATCH 64/70] fix(config): complete protocol fallback dispatch --- scripts/check-config-reference-contract.py | 191 ++++++++++++++---- .../test_check_config_reference_contract.py | 105 ++++++++++ 2 files changed, 262 insertions(+), 34 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index ec6bc2c461..17b02193d9 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -244,6 +244,7 @@ class _DeferredPartial: _ITEMGETTER_FACTORY = "" _METHODCALLER_FACTORY = "" _OPERATOR_GETITEM = "" +_OPERATOR_INDEX = "" _BUILTINS_MODULE = "" _DICT_BUILTIN = "" _CLASSMETHOD_DECORATOR = "" @@ -274,6 +275,8 @@ class _DeferredPartial: _ATEXIT_REGISTER = "" _SYS_MODULE = "" _SYS_EXIT = "" +_OS_MODULE = "" +_OS_EXIT = "" _ASYNCIO_MODULE = "" _ASYNCIO_CONSUMERS = frozenset({"create_task", "ensure_future", "gather", "run"}) _ASYNCIO_CONSUMER_ORIGINS = frozenset(f"" for name in _ASYNCIO_CONSUMERS) @@ -1257,6 +1260,31 @@ def _invoke_truth_protocol(self, owner: _AbstractValue) -> None: else: self._invoke_implicit_protocol(owner, "__len__") + def _invoke_binary_protocol( + self, + left: _AbstractValue, + right: _AbstractValue, + direct: str, + reflected: str, + ) -> _AbstractValue: + """Model Python's reflected binary/rich-comparison dispatch order.""" + direct_methods = self._source_index.methods(left.instance_classes, direct) + reflected_methods = self._source_index.methods(right.instance_classes, reflected) + reflected_precedes = any( + self._source_index.is_strict_subclass(right_class, left_class) + for right_class in right.instance_classes + for left_class in left.instance_classes + ) + if reflected_precedes and reflected_methods: + result = self._invoke_implicit_protocol(right, reflected, (left,)) + if _NOT_IMPLEMENTED in result.origins and direct_methods: + return self._invoke_implicit_protocol(left, direct, (right,)) + return result + result = self._invoke_implicit_protocol(left, direct, (right,)) + if (not direct_methods or _NOT_IMPLEMENTED in result.origins) and reflected_methods: + return self._invoke_implicit_protocol(right, reflected, (left,)) + return result + @staticmethod def _is_accessor_callback(value: _AbstractValue) -> bool: return bool(value.accessed_attributes or _GETATTR_BUILTIN in value.origins) @@ -1892,6 +1920,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_METHODCALLER_FACTORY) if _OPERATOR_MODULE in owner.origins and node.attr == "getitem": return _origin_value(_OPERATOR_GETITEM) + if _OPERATOR_MODULE in owner.origins and node.attr == "index": + return _origin_value(_OPERATOR_INDEX) if _TYPING_MODULE in owner.origins and node.attr == "cast": return _origin_value(_TYPING_CAST) if _COPY_MODULE in owner.origins and node.attr in {"copy", "deepcopy"}: @@ -1918,6 +1948,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_ATEXIT_REGISTER) if _SYS_MODULE in owner.origins and node.attr == "exit": return _origin_value(_SYS_EXIT) + if _OS_MODULE in owner.origins and node.attr == "_exit": + return _origin_value(_OS_EXIT) if _BUILTINS_MODULE in owner.origins and node.attr in _TRACKED_BUILTIN_CONSUMERS: return _origin_value(f"") if _FUNCTOOLS_MODULE in owner.origins and node.attr == "partial": @@ -2706,18 +2738,94 @@ def visit_Call(self, node: ast.Call) -> None: "repr": "__repr__", "ascii": "__repr__", "len": "__len__", - "bool": "__bool__", "hash": "__hash__", - "int": "__int__", - "float": "__float__", - "complex": "__complex__", "bytes": "__bytes__", + "abs": "__abs__", + "bin": "__index__", + "oct": "__index__", + "hex": "__index__", }.get(node.func.id) if isinstance(node.func, ast.Name) and not self._name_is_bound(node.func.id) else None ) if protocol is not None and node.args: self._invoke_implicit_protocol(self._expression_value(node.args[0]), protocol) + if ( + isinstance(node.func, ast.Name) + and node.func.id == "bool" + and not self._name_is_bound(node.func.id) + and node.args + ): + self._invoke_truth_protocol(self._expression_value(node.args[0])) + if ( + isinstance(node.func, ast.Name) + and node.func.id in {"int", "float", "complex"} + and not self._name_is_bound(node.func.id) + and node.args + ): + owner = self._expression_value(node.args[0]) + candidates = { + "int": ("__int__", "__index__"), + "float": ("__float__", "__index__"), + "complex": ("__complex__", "__float__", "__index__"), + }[node.func.id] + for candidate in candidates: + if self._source_index.methods(owner.instance_classes, candidate): + self._invoke_implicit_protocol(owner, candidate) + break + if ( + isinstance(node.func, ast.Name) + and node.func.id == "reversed" + and not self._name_is_bound(node.func.id) + and node.args + ): + owner = self._expression_value(node.args[0]) + if self._source_index.methods(owner.instance_classes, "__reversed__"): + self._invoke_implicit_protocol(owner, "__reversed__") + else: + self._invoke_implicit_protocol(owner, "__len__") + self._invoke_implicit_protocol(owner, "__getitem__", (_UNKNOWN_VALUE,)) + if ( + isinstance(node.func, ast.Name) + and node.func.id == "round" + and not self._name_is_bound(node.func.id) + and node.args + ): + self._invoke_implicit_protocol( + self._expression_value(node.args[0]), + "__round__", + ((self._expression_value(node.args[1]),) if len(node.args) > 1 else ()), + ) + if ( + isinstance(node.func, ast.Name) + and node.func.id == "divmod" + and not self._name_is_bound(node.func.id) + and len(node.args) >= 2 + ): + self._invoke_binary_protocol( + self._expression_value(node.args[0]), + self._expression_value(node.args[1]), + "__divmod__", + "__rdivmod__", + ) + if _OPERATOR_INDEX in accessor.origins and node.args: + self._invoke_implicit_protocol(self._expression_value(node.args[0]), "__index__") + if ( + isinstance(node.func, ast.Name) + and node.func.id == "pow" + and not self._name_is_bound(node.func.id) + and len(node.args) >= 2 + ): + left = self._expression_value(node.args[0]) + right = self._expression_value(node.args[1]) + if len(node.args) == 2: + self._invoke_binary_protocol(left, right, "__pow__", "__rpow__") + else: + self._invoke_implicit_protocol( + left, + "__pow__", + (right, self._expression_value(node.args[2])), + ) if ( isinstance(node.func, ast.Name) and node.func.id == "format" @@ -2901,6 +3009,13 @@ def visit_Call(self, node: ast.Call) -> None: for section in accessor_arguments[0].serialized_sections: self._record(section, field_name) self.generic_visit(node) + if _OS_EXIT in accessor.origins: + self._append_abrupt( + self._flow_abrupts[-1], + _AbruptPath("exit", self._binding_snapshot()), + ) + self._path_reachable = False + return if _SYS_EXIT in accessor.origins: self._append_abrupt( self._flow_abrupts[-1], @@ -2977,6 +3092,7 @@ def visit_YieldFrom(self, node: ast.YieldFrom) -> None: def visit_Compare(self, node: ast.Compare) -> None: """Membership checks consume their right-hand iterable.""" + left_node = node.left for operator, comparator in zip(node.ops, node.comparators, strict=True): if isinstance(operator, (ast.In, ast.NotIn)): self._consume_deferred_callable_iterator(comparator, mode="one_turn") @@ -2985,7 +3101,7 @@ def visit_Compare(self, node: ast.Compare) -> None: self._invoke_implicit_protocol( owner, "__contains__", - (self._expression_value(node.left),), + (self._expression_value(left_node),), ) else: self._consume_local_iterator(comparator, mode="one_turn") @@ -2999,15 +3115,12 @@ def visit_Compare(self, node: ast.Compare) -> None: ast.NotEq: ("__ne__", "__ne__"), } selected = protocols.get(type(operator)) - if selected is None: - continue - direct, reflected = selected - left = self._expression_value(node.left) - right = self._expression_value(comparator) - direct_methods = self._source_index.methods(left.instance_classes, direct) - self._invoke_implicit_protocol(left, direct, (right,)) - if not direct_methods: - self._invoke_implicit_protocol(right, reflected, (left,)) + if selected is not None: + direct, reflected = selected + left = self._expression_value(left_node) + right = self._expression_value(comparator) + self._invoke_binary_protocol(left, right, direct, reflected) + left_node = comparator self.generic_visit(node) def visit_BinOp(self, node: ast.BinOp) -> None: @@ -3030,21 +3143,7 @@ def visit_BinOp(self, node: ast.BinOp) -> None: direct, reflected = protocols[type(node.op)] left = self._expression_value(node.left) right = self._expression_value(node.right) - direct_methods = self._source_index.methods(left.instance_classes, direct) - reflected_methods = self._source_index.methods(right.instance_classes, reflected) - reflected_precedes = any( - self._source_index.is_strict_subclass(right_class, left_class) - for right_class in right.instance_classes - for left_class in left.instance_classes - ) - if reflected_precedes and reflected_methods: - result = self._invoke_implicit_protocol(right, reflected, (left,)) - if _NOT_IMPLEMENTED in result.origins and direct_methods: - self._invoke_implicit_protocol(left, direct, (right,)) - else: - result = self._invoke_implicit_protocol(left, direct, (right,)) - if (not direct_methods or _NOT_IMPLEMENTED in result.origins) and reflected_methods: - self._invoke_implicit_protocol(right, reflected, (left,)) + self._invoke_binary_protocol(left, right, direct, reflected) self.generic_visit(node) def visit_FormattedValue(self, node: ast.FormattedValue) -> None: @@ -3458,11 +3557,29 @@ def visit_AugAssign(self, node: ast.AugAssign) -> None: ast.BitXor: "__ixor__", ast.BitAnd: "__iand__", } - self._invoke_implicit_protocol( - owner, - protocols[type(node.op)], - (self._expression_value(node.value),), - ) + in_place_name = protocols[type(node.op)] + right = self._expression_value(node.value) + in_place = self._invoke_implicit_protocol(owner, in_place_name, (right,)) + if ( + not self._source_index.methods(owner.instance_classes, in_place_name) + or _NOT_IMPLEMENTED in in_place.origins + ): + direct, reflected = { + ast.Add: ("__add__", "__radd__"), + ast.Sub: ("__sub__", "__rsub__"), + ast.Mult: ("__mul__", "__rmul__"), + ast.MatMult: ("__matmul__", "__rmatmul__"), + ast.Div: ("__truediv__", "__rtruediv__"), + ast.FloorDiv: ("__floordiv__", "__rfloordiv__"), + ast.Mod: ("__mod__", "__rmod__"), + ast.Pow: ("__pow__", "__rpow__"), + ast.LShift: ("__lshift__", "__rlshift__"), + ast.RShift: ("__rshift__", "__rrshift__"), + ast.BitOr: ("__or__", "__ror__"), + ast.BitXor: ("__xor__", "__rxor__"), + ast.BitAnd: ("__and__", "__rand__"), + }[type(node.op)] + self._invoke_binary_protocol(owner, right, direct, reflected) if isinstance(node.op, ast.BitOr): right = self._expression_value(node.value) entries = dict(owner.entries or ()) @@ -4302,6 +4419,8 @@ def _bind_import(self, node: ast.Import, *, runtime: bool) -> None: if alias.name == "atexit" else _origin_value(_SYS_MODULE) if alias.name == "sys" + else _origin_value(_OS_MODULE) + if alias.name == "os" else _origin_value(_ASYNCIO_MODULE) if alias.name == "asyncio" else _origin_value(_CONTEXTLIB_MODULE) @@ -4354,6 +4473,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "operator" and alias.name == "methodcaller" else _origin_value(_OPERATOR_GETITEM) if node.module == "operator" and alias.name == "getitem" + else _origin_value(_OPERATOR_INDEX) + if node.module == "operator" and alias.name == "index" else _origin_value(_GETATTR_BUILTIN) if node.module == "builtins" and alias.name == "getattr" else _origin_value(_DICT_BUILTIN) @@ -4398,6 +4519,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "atexit" and alias.name == "register" else _origin_value(_SYS_EXIT) if node.module == "sys" and alias.name == "exit" + else _origin_value(_OS_EXIT) + if node.module == "os" and alias.name == "_exit" else _origin_value(f"") if node.module == "asyncio" and alias.name in _ASYNCIO_CONSUMERS else _origin_value(_NULLCONTEXT_FACTORY) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 84be1e90b4..113c5c2bb5 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -3361,6 +3361,111 @@ def __delitem__(self, key): } +def test_runtime_scan_models_reflected_augmented_builtin_and_os_exit_protocols( + contract, tmp_path: Path +) -> None: + (tmp_path / "dispatch_edges.py").write_text( + """ +import operator +import os +from os import _exit as stop + +class Left: + def __init__(self, section): + self.section = section + + def __lt__(self, other): + return self.section.stage1_enabled + +class Right(Left): + def __gt__(self, other): + return self.section.stage2_enabled + +class Fallback: + def __init__(self, section): + self.section = section + + def __lt__(self, other): + return NotImplemented + + def __gt__(self, other): + return self.section.stage3_enabled + +class AddOnly: + def __init__(self, section): + self.section = section + + def __add__(self, other): + return self.section.stage4_enabled + +class InPlaceFallback: + def __init__(self, section): + self.section = section + + def __iadd__(self, other): + return NotImplemented + + def __add__(self, other): + return self.section.stage5_enabled + +class Builtins: + def __init__(self, section): + self.section = section + + def __abs__(self): + return self.section.stage6_enabled + + def __round__(self, ndigits=None): + return self.section.stage7_enabled + + def __divmod__(self, other): + return self.section.stage8_enabled + + def __reversed__(self): + return iter((self.section.stage9_enabled,)) + + def __index__(self): + return self.section.stage10_enabled + +Left(config.evaluation) < Right(config.evaluation) +Fallback(config.evaluation) < Fallback(config.evaluation) +add_only = AddOnly(config.evaluation) +add_only += 1 +in_place = InPlaceFallback(config.evaluation) +in_place += 1 +value = Builtins(config.evaluation) +abs(value) +round(value) +divmod(value, 1) +reversed(value) +operator.index(value) +unused = Builtins(config.consensus) + +def module_exit(config): + os._exit(0) + return config.evaluation.stage11_enabled + +def imported_exit(config): + stop(0) + return config.evaluation.stage12_enabled + +def shadowed(os): + os._exit(0) + return config.evaluation.stage13_enabled +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", f"stage{index}_enabled") for index in range(1, 14) + ) | frozenset( + contract.ConfigField("consensus", f"stage{index}_enabled") for index in range(6, 11) + ) + + assert contract.runtime_reads(tmp_path, fields) == { + contract.ConfigField("evaluation", f"stage{index}_enabled") for index in range(2, 11) + } | {contract.ConfigField("evaluation", "stage13_enabled")} + + def test_runtime_scan_selects_context_manager_protocol_by_syntax(contract, tmp_path: Path) -> None: (tmp_path / "context_protocols.py").write_text( """ From dde0ffd4ccbca604787b1d0c32926603e6eeef37 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 21:19:20 +0900 Subject: [PATCH 65/70] fix(contract): model protocol and context control flow --- scripts/check-config-reference-contract.py | 216 +++++++++++++----- .../test_check_config_reference_contract.py | 171 ++++++++++++++ 2 files changed, 327 insertions(+), 60 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 17b02193d9..d7e7af861a 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -257,6 +257,29 @@ class _DeferredPartial: _OBJECT_BUILTIN = "" _VARS_BUILTIN = "" _RANGE_BUILTIN = "" +_PROTOCOL_BUILTIN_PREFIX = " _AbstractValue: if node.id == "vars" and not self._name_is_bound("vars") else _origin_value(_RANGE_BUILTIN) if node.id == "range" and not self._name_is_bound("range") + else _origin_value(f"{_PROTOCOL_BUILTIN_PREFIX}{node.id}>") + if node.id in _PROTOCOL_BUILTINS and not self._name_is_bound(node.id) else _origin_value(f"") if node.id in _TRACKED_BUILTIN_CONSUMERS and not self._name_is_bound(node.id) else self._name_value(node.id) @@ -1944,6 +1969,8 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_VARS_BUILTIN) if _BUILTINS_MODULE in owner.origins and node.attr == "range": return _origin_value(_RANGE_BUILTIN) + if _BUILTINS_MODULE in owner.origins and node.attr in _PROTOCOL_BUILTINS: + return _origin_value(f"{_PROTOCOL_BUILTIN_PREFIX}{node.attr}>") if _ATEXIT_MODULE in owner.origins and node.attr == "register": return _origin_value(_ATEXIT_REGISTER) if _SYS_MODULE in owner.origins and node.attr == "exit": @@ -2244,10 +2271,11 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if _EXITSTACK_FACTORY in function_value.origins: return _AbstractValue(origins=frozenset({_EXITSTACK_VALUE})) if _ENTER_CONTEXT_CONSUMER in function_value.origins and node.args: - return self._context_entry_value( + entry_value, _entry_executions = self._context_entry_value( self._expression_value(node.args[0]), asynchronous=False, ) + return entry_value if _TYPING_CAST in function_value.origins and len(node.args) >= 2: return self._expression_value(node.args[1]) if function_value.origins & _COPY_IDENTITY_TRANSFORMS and node.args: @@ -2383,6 +2411,48 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if keyword.arg is not None else ("**", _conservative_value(value)) ) + metaclass_executions: list[_FunctionExecution] = [] + for class_node in constructor_classes: + class_value = _AbstractValue(classes=frozenset({class_node})) + metaclasses = frozenset( + metaclass + for keyword in class_node.keywords + if keyword.arg == "metaclass" + for metaclass in self._expression_value(keyword.value).classes + ) + for function in self._source_index.methods(metaclasses, "__call__"): + metaclass_executions.append( + self._local_direct_call_execution( + function, + (class_value, *items), + ) + ) + if metaclass_executions: + self._call_executions.setdefault(id(node), []).extend(metaclass_executions) + returned = tuple( + value for execution in metaclass_executions for value in execution.values + ) + return _join_values(*returned) if returned else _UNKNOWN_VALUE + + new_executions: list[_FunctionExecution] = [] + for class_node in constructor_classes: + class_value = _AbstractValue(classes=frozenset({class_node})) + for function in self._source_index.methods((class_node,), "__new__"): + new_executions.append( + self._local_direct_call_execution( + function, + (class_value, *items), + ) + ) + if new_executions: + self._call_executions.setdefault(id(node), []).extend(new_executions) + returned = tuple( + value for execution in new_executions for value in execution.values + ) + # Exact local ``__new__`` owns construction. In particular, + # an unrelated object returned here must not be fabricated + # back into an instance of the requested class. + return _join_values(*returned) if returned else _UNKNOWN_VALUE instance = _AbstractValue( items=tuple(items), attributes=tuple(attributes), @@ -2728,6 +2798,19 @@ def _consume_deferred_callable_iterator_value( def visit_Call(self, node: ast.Call) -> None: before = self._binding_snapshot() accessor = self._expression_value(node.func) + exact_builtins = { + origin[len(_PROTOCOL_BUILTIN_PREFIX) : -1] + for origin in accessor.origins + if origin.startswith(_PROTOCOL_BUILTIN_PREFIX) and origin.endswith(">") + } | { + origin[len("") + } + + def is_exact_builtin(name: str) -> bool: + return name in exact_builtins + # Builtins below dispatch through implicit special-method lookup. # Attribute resolution alone cannot # see those calls, so execute the exact local protocol implementation @@ -2744,64 +2827,40 @@ def visit_Call(self, node: ast.Call) -> None: "bin": "__index__", "oct": "__index__", "hex": "__index__", - }.get(node.func.id) - if isinstance(node.func, ast.Name) and not self._name_is_bound(node.func.id) + }.get(next(iter(exact_builtins), "")) + if len(exact_builtins) == 1 else None ) if protocol is not None and node.args: self._invoke_implicit_protocol(self._expression_value(node.args[0]), protocol) - if ( - isinstance(node.func, ast.Name) - and node.func.id == "bool" - and not self._name_is_bound(node.func.id) - and node.args - ): + if is_exact_builtin("bool") and node.args: self._invoke_truth_protocol(self._expression_value(node.args[0])) - if ( - isinstance(node.func, ast.Name) - and node.func.id in {"int", "float", "complex"} - and not self._name_is_bound(node.func.id) - and node.args - ): + if bool(exact_builtins & {"int", "float", "complex"}) and node.args: owner = self._expression_value(node.args[0]) + conversion = next(iter(exact_builtins & {"int", "float", "complex"})) candidates = { "int": ("__int__", "__index__"), "float": ("__float__", "__index__"), "complex": ("__complex__", "__float__", "__index__"), - }[node.func.id] + }[conversion] for candidate in candidates: if self._source_index.methods(owner.instance_classes, candidate): self._invoke_implicit_protocol(owner, candidate) break - if ( - isinstance(node.func, ast.Name) - and node.func.id == "reversed" - and not self._name_is_bound(node.func.id) - and node.args - ): + if is_exact_builtin("reversed") and node.args: owner = self._expression_value(node.args[0]) if self._source_index.methods(owner.instance_classes, "__reversed__"): self._invoke_implicit_protocol(owner, "__reversed__") else: self._invoke_implicit_protocol(owner, "__len__") self._invoke_implicit_protocol(owner, "__getitem__", (_UNKNOWN_VALUE,)) - if ( - isinstance(node.func, ast.Name) - and node.func.id == "round" - and not self._name_is_bound(node.func.id) - and node.args - ): + if is_exact_builtin("round") and node.args: self._invoke_implicit_protocol( self._expression_value(node.args[0]), "__round__", ((self._expression_value(node.args[1]),) if len(node.args) > 1 else ()), ) - if ( - isinstance(node.func, ast.Name) - and node.func.id == "divmod" - and not self._name_is_bound(node.func.id) - and len(node.args) >= 2 - ): + if is_exact_builtin("divmod") and len(node.args) >= 2: self._invoke_binary_protocol( self._expression_value(node.args[0]), self._expression_value(node.args[1]), @@ -2810,12 +2869,7 @@ def visit_Call(self, node: ast.Call) -> None: ) if _OPERATOR_INDEX in accessor.origins and node.args: self._invoke_implicit_protocol(self._expression_value(node.args[0]), "__index__") - if ( - isinstance(node.func, ast.Name) - and node.func.id == "pow" - and not self._name_is_bound(node.func.id) - and len(node.args) >= 2 - ): + if is_exact_builtin("pow") and len(node.args) >= 2: left = self._expression_value(node.args[0]) right = self._expression_value(node.args[1]) if len(node.args) == 2: @@ -2826,12 +2880,7 @@ def visit_Call(self, node: ast.Call) -> None: "__pow__", (right, self._expression_value(node.args[2])), ) - if ( - isinstance(node.func, ast.Name) - and node.func.id == "format" - and not self._name_is_bound(node.func.id) - and node.args - ): + if is_exact_builtin("format") and node.args: specification = ( self._expression_value(node.args[1]) if len(node.args) > 1 else _UNKNOWN_VALUE ) @@ -2907,9 +2956,9 @@ def visit_Call(self, node: ast.Call) -> None: for function, receiver in self._call_targets(key_keyword.value): values = ((receiver,) if receiver is not None else ()) + (item,) self._local_direct_call_value(function, values) - if isinstance(node.func, ast.Name) and node.func.id == "iter" and node.args: + if is_exact_builtin("iter") and node.args: self._invoke_implicit_protocol(self._expression_value(node.args[0]), "__iter__") - if isinstance(node.func, ast.Name) and node.func.id == "next" and node.args: + if is_exact_builtin("next") and node.args: self._invoke_implicit_protocol(self._expression_value(node.args[0]), "__next__") if isinstance(node.func, ast.Attribute) and node.func.attr == "sort": key_keyword = next((keyword for keyword in node.keywords if keyword.arg == "key"), None) @@ -4491,6 +4540,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: if node.module == "builtins" and alias.name == "vars" else _origin_value(_RANGE_BUILTIN) if node.module == "builtins" and alias.name == "range" + else _origin_value(f"{_PROTOCOL_BUILTIN_PREFIX}{alias.name}>") + if node.module == "builtins" and alias.name in _PROTOCOL_BUILTINS else _origin_value(f"") if node.module == "builtins" and alias.name in _TRACKED_BUILTIN_CONSUMERS else _origin_value(_PARTIAL_FACTORY) @@ -4657,19 +4708,22 @@ def _bind_partialmethod_descriptors(self, owner: _AbstractValue) -> _AbstractVal def _context_entry_value( self, context_value: _AbstractValue, *, asynchronous: bool - ) -> _AbstractValue: + ) -> tuple[_AbstractValue, tuple[_FunctionExecution, ...]]: if _NULLCONTEXT_VALUE in context_value.origins and context_value.items: - return context_value.items[0] + return context_value.items[0], () if _CLOSING_VALUE in context_value.origins and context_value.items: - return context_value.items[0] + return context_value.items[0], () if _EXITSTACK_VALUE in context_value.origins: - return context_value + return context_value, () receiver = self._descriptor_receiver(context_value) - entries: list[_AbstractValue] = [ - self._local_direct_call_value(function, (receiver,)) + executions: list[_FunctionExecution] = [ + self._local_direct_call_execution(function, (receiver,)) for method_name in (("__aenter__",) if asynchronous else ("__enter__",)) for function in self._source_index.methods(context_value.instance_classes, method_name) ] + entries: list[_AbstractValue] = [ + value for execution in executions for value in execution.values + ] for identity in context_value.identity: deferred = self._deferred_generators.get(identity) if deferred is None or not isinstance( @@ -4693,11 +4747,12 @@ def _context_entry_value( ) if yielded is not None: entries.append(self._value_in_scope(yielded, deferred.scoped)) - return _join_values(*entries) + return _join_values(*entries), tuple(executions) def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: asynchronous = isinstance(node, ast.AsyncWith) context_values: list[_AbstractValue] = [] + entry_abrupt: list[_AbruptPath] = [] for item in node.items: self.visit(item.context_expr) context_value = self._expression_value(item.context_expr) @@ -4714,28 +4769,69 @@ def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: ) ): self._consume_deferred_generator(item.context_expr, mode="one_turn") - entry_value = self._context_entry_value( + entry_value, entry_executions = self._context_entry_value( context_value, asynchronous=asynchronous, ) + for execution in entry_executions: + entry_abrupt.extend(execution.raises) + if entry_executions and all( + not execution.returns_to_caller for execution in entry_executions + ): + self._apply_flow_result(_FlowResult(None, tuple(entry_abrupt))) + return if item.optional_vars is not None: self._visit_store_target(item.optional_vars) self._bind_target_value(item.optional_vars, entry_value) self._bind_function_target(item.optional_vars, frozenset()) self._bind_annotation_target(item.optional_vars, _UNKNOWN_VALUE) body_result = self._visit_binding_branch(node.body, self._binding_snapshot()) + fallthrough = body_result.fallthrough + abrupt = [*entry_abrupt, *body_result.abrupt] for context_value in reversed(context_values): receiver = self._descriptor_receiver(context_value) + exit_executions: list[_FunctionExecution] = [] for method_name in ("__aexit__",) if asynchronous else ("__exit__",): for function in self._source_index.methods( context_value.instance_classes, method_name ): - self._local_direct_call_value( - function, - (receiver, _UNKNOWN_VALUE, _UNKNOWN_VALUE, _UNKNOWN_VALUE), + exit_executions.append( + self._local_direct_call_execution( + function, + (receiver, _UNKNOWN_VALUE, _UNKNOWN_VALUE, _UNKNOWN_VALUE), + ) ) self._consume_deferred_generator_value(context_value, mode="full") - self._apply_flow_result(body_result) + if not exit_executions: + continue + + exit_raises = [path for execution in exit_executions for path in execution.raises] + returning = [execution for execution in exit_executions if execution.returns_to_caller] + if not returning: + fallthrough = None + abrupt = exit_raises + continue + + transformed: list[_AbruptPath] = [] + for path in abrupt: + if path.kind != "raise": + transformed.append(path) + continue + truth_values = [ + value.truth + for execution in returning + for value in (execution.values or (_AbstractValue(truth=False),)) + ] + if any(truth is True or truth is None for truth in truth_values): + fallthrough = ( + path.bindings + if fallthrough is None + else self._join_binding_snapshots(fallthrough, path.bindings) + ) + if any(truth is False or truth is None for truth in truth_values): + transformed.append(path) + abrupt = [*transformed, *exit_raises] + self._apply_flow_result(_FlowResult(fallthrough, tuple(abrupt))) def visit_With(self, node: ast.With) -> None: self._visit_with(node) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 113c5c2bb5..53c2255f0f 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -337,6 +337,177 @@ def entered(config): } +def test_runtime_scan_tracks_qualified_and_imported_builtin_protocols( + contract, tmp_path: Path +) -> None: + (tmp_path / "builtin_protocols.py").write_text( + """ +import builtins +from builtins import int as integer + +class Reader: + def __len__(self): + return settings.evaluation.stage1_enabled + + def __bool__(self): + return settings.evaluation.stage2_enabled + + def __int__(self): + return settings.evaluation.stage3_enabled + +builtins.len(Reader()) +builtins.bool(Reader()) +integer(Reader()) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_does_not_treat_shadowed_builtin_protocol_names_as_builtins( + contract, tmp_path: Path +) -> None: + (tmp_path / "shadowed_builtin_protocols.py").write_text( + """ +import builtins +from builtins import int as integer + +class Reader: + def __len__(self): + return settings.evaluation.stage1_enabled + def __int__(self): + return settings.evaluation.stage2_enabled + +class FakeBuiltins: + len = lambda self, value: 0 + +builtins = FakeBuiltins() +integer = lambda value: 0 +builtins.len(Reader()) +integer(Reader()) +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset() + + +def test_runtime_scan_executes_exact_new_without_fabricating_requested_instance( + contract, tmp_path: Path +) -> None: + (tmp_path / "new_protocol.py").write_text( + """ +class UnrelatedCallable: + def __call__(self): + return settings.evaluation.stage2_enabled + +class Reader: + def __new__(cls): + settings.evaluation.stage1_enabled + return UnrelatedCallable() + +Reader() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage1_enabled")} + ) + + +def test_runtime_scan_honors_exact_metaclass_construction(contract, tmp_path: Path) -> None: + (tmp_path / "metaclass_protocol.py").write_text( + """ +class Unrelated: + pass + +class Meta(type): + def __call__(cls): + settings.evaluation.stage1_enabled + return Unrelated() + +class Requested(metaclass=Meta): + def __new__(cls): + settings.evaluation.stage2_enabled + return super().__new__(cls) + +Requested() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + {contract.ConfigField("evaluation", "stage1_enabled")} + ) + + +def test_runtime_scan_threads_context_manager_abrupt_control_flow(contract, tmp_path: Path) -> None: + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + cases = { + "entry_raises": ( + """ +class Manager: + def __enter__(self): + raise RuntimeError + def __exit__(self, *args): + return False +with Manager(): + settings.evaluation.stage1_enabled +""", + frozenset(), + ), + "exit_suppresses": ( + """ +class Manager: + def __enter__(self): + return self + def __exit__(self, *args): + return True +with Manager(): + raise RuntimeError +settings.evaluation.stage2_enabled +""", + frozenset({contract.ConfigField("evaluation", "stage2_enabled")}), + ), + "exit_raises": ( + """ +class Manager: + def __enter__(self): + return self + def __exit__(self, *args): + raise RuntimeError +with Manager(): + pass +settings.evaluation.stage3_enabled +""", + frozenset(), + ), + } + for name, (source, expected) in cases.items(): + case_root = tmp_path / name + case_root.mkdir() + (case_root / "case.py").write_text(source, encoding="utf-8") + assert contract.runtime_reads(case_root, fields) == expected + + def test_runtime_scan_executes_eager_key_callback_only_for_nonempty_input( contract, tmp_path: Path ) -> None: From df6cd98edc7199b7b8bf7c2f21670e148b113b4a Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 21:32:39 +0900 Subject: [PATCH 66/70] fix(contract): execute initializer and class hooks --- scripts/check-config-reference-contract.py | 83 ++++++++++++++++++- .../test_check_config_reference_contract.py | 65 +++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index d7e7af861a..8a4f499002 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -2449,10 +2449,34 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: returned = tuple( value for execution in new_executions for value in execution.values ) + returned_value = _join_values(*returned) if returned else _UNKNOWN_VALUE + initializes_requested_class = any( + returned_class is requested_class + or self._source_index.is_strict_subclass(returned_class, requested_class) + for returned_class in returned_value.instance_classes + for requested_class in constructor_classes + ) + if initializes_requested_class or returned_value == _UNKNOWN_VALUE: + receiver = ( + returned_value + if initializes_requested_class + else _AbstractValue( + identity=frozenset({id(node)}), + classes=constructor_classes, + instance_classes=constructor_classes, + ) + ) + init_executions = [ + self._local_direct_call_execution(function, (receiver, *items)) + for function in self._source_index.methods( + constructor_classes, "__init__" + ) + ] + self._call_executions.setdefault(id(node), []).extend(init_executions) # Exact local ``__new__`` owns construction. In particular, # an unrelated object returned here must not be fabricated # back into an instance of the requested class. - return _join_values(*returned) if returned else _UNKNOWN_VALUE + return returned_value instance = _AbstractValue( items=tuple(items), attributes=tuple(attributes), @@ -2460,6 +2484,11 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: classes=constructor_classes, instance_classes=constructor_classes, ) + init_executions = [ + self._local_direct_call_execution(function, (instance, *items)) + for function in self._source_index.methods(constructor_classes, "__init__") + ] + self._call_executions.setdefault(id(node), []).extend(init_executions) return self._bind_partialmethod_descriptors(instance) if _DICT_BUILTIN in function_value.origins: if node.args: @@ -5384,8 +5413,12 @@ def visit_Module(self, node: ast.Module) -> None: ) def visit_ClassDef(self, node: ast.ClassDef) -> None: - for expression in (*node.decorator_list, *node.bases): + base_values: list[_AbstractValue] = [] + for expression in node.decorator_list: + self.visit(expression) + for expression in node.bases: self.visit(expression) + base_values.append(self._expression_value(expression)) for keyword in node.keywords: self.visit(keyword.value) self._states.append({}) @@ -5416,13 +5449,57 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: self._functions.pop() self._annotations.pop() self._states.pop() - self._states[-1][node.name] = _AbstractValue( + created_class = _AbstractValue( classes=frozenset({node}), attributes=class_attributes, ) + self._states[-1][node.name] = created_class self._annotations[-1][node.name] = _UNKNOWN_VALUE self._functions[-1][node.name] = frozenset() + # Python executes descriptor ``__set_name__`` hooks and inherited + # ``__init_subclass__`` during class creation, before the class name is + # available to later module statements. Keep those reads on the real + # creation boundary instead of treating class bodies as declarations. + creation_executions: list[_FunctionExecution] = [] + for name, value in class_attributes: + for function in self._source_index.methods(value.instance_classes, "__set_name__"): + creation_executions.append( + self._local_direct_call_execution( + function, + ( + self._descriptor_receiver(value), + created_class, + _AbstractValue( + literal=_key_token(ast.Constant(name)), + string_value=name, + string_values=frozenset({name}), + ), + ), + ) + ) + base_classes = {base_class for value in base_values for base_class in value.classes} + for function in self._source_index.methods(base_classes, "__init_subclass__"): + creation_executions.append( + self._local_direct_call_execution(function, (created_class,)) + ) + for execution in creation_executions: + if execution.returns_to_caller: + continue + for path in execution.raises: + self._append_abrupt( + self._flow_abrupts[-1], + _AbruptPath( + "raise", + self._binding_snapshot(), + exception_type=path.exception_type, + exception_exclusions=path.exception_exclusions, + exception_upper_bound=path.exception_upper_bound, + ), + ) + self._path_reachable = False + break + def visit_Lambda(self, node: ast.Lambda) -> None: for default in (*node.args.defaults, *node.args.kw_defaults): if default is not None: diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 53c2255f0f..ba3170f1b8 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -414,6 +414,9 @@ def __new__(cls): settings.evaluation.stage1_enabled return UnrelatedCallable() + def __init__(self): + settings.evaluation.stage2_enabled + Reader() """, encoding="utf-8", @@ -427,6 +430,22 @@ def __new__(cls): ) +def test_runtime_scan_executes_normal_local_initializer(contract, tmp_path: Path) -> None: + (tmp_path / "initializer.py").write_text( + """ +class Reader: + def __init__(self, config): + config.evaluation.uncertainty_threshold + +Reader(settings) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "uncertainty_threshold") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + def test_runtime_scan_honors_exact_metaclass_construction(contract, tmp_path: Path) -> None: (tmp_path / "metaclass_protocol.py").write_text( """ @@ -456,6 +475,52 @@ def __new__(cls): ) +def test_runtime_scan_executes_class_creation_protocols_without_overreach( + contract, tmp_path: Path +) -> None: + (tmp_path / "class_creation.py").write_text( + """ +class Base: + def __init_subclass__(cls): + settings.evaluation.stage1_enabled + +class Descriptor: + def __set_name__(self, owner, name): + settings.evaluation.stage2_enabled + +class Child(Base): + field = Descriptor() + +class PendingBase: + def __init_subclass__(cls): + settings.evaluation.stage3_enabled + +class PendingDescriptor: + def __set_name__(self, owner, name): + settings.evaluation.semantic_model + +PendingDescriptor() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ( + "stage1_enabled", + "stage2_enabled", + "stage3_enabled", + "semantic_model", + ) + ) + + assert contract.runtime_reads(tmp_path, fields) == frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + } + ) + + def test_runtime_scan_threads_context_manager_abrupt_control_flow(contract, tmp_path: Path) -> None: fields = frozenset( contract.ConfigField("evaluation", name) From 47882862dbe88029449095ca0db3c1623176c690 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 21:52:41 +0900 Subject: [PATCH 67/70] fix(contract): execute invoked private config readers --- scripts/check-config-reference-contract.py | 17 ++++++++++++- .../test_check_config_reference_contract.py | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 8a4f499002..e03c3b4d2a 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -2381,8 +2381,23 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if model_copy_sections: return _origin_value(*model_copy_sections) values: list[_AbstractValue] = [] + direct_local_call = ( + isinstance(node.func, ast.Name) + and node.func.id.startswith("_") + or isinstance(node.func, ast.Attribute) + and node.func.attr.startswith("_") + ) for function, bound_receiver in self._call_targets(node.func): - if self._call_has_relevant_provenance(node, function, bound_receiver): + private_config_reader = direct_local_call and any( + isinstance(candidate, ast.Name) + and _looks_like_config_name(candidate.id) + or isinstance(candidate, ast.Attribute) + and candidate.attr in TRACKED_SECTIONS + for candidate in ast.walk(function) + ) + if private_config_reader or self._call_has_relevant_provenance( + node, function, bound_receiver + ): values.append(self._local_call_value(node, function, bound_receiver)) continue if self._function_is_syntactically_nonreturning(function): diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index ba3170f1b8..ac6c3e7768 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -95,6 +95,30 @@ def erase_nested(): ) +def test_runtime_scan_executes_invoked_private_helpers_and_methods( + contract, tmp_path: Path +) -> None: + (tmp_path / "private_calls.py").write_text( + """ +def _read(): + return settings.evaluation.stage1_enabled + +class Reader: + def _read(self): + return settings.evaluation.stage2_enabled + +_read() +Reader()._read() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + def test_runtime_scan_requires_generator_consumption(contract, tmp_path: Path) -> None: (tmp_path / "deferred.py").write_text( """ From 4e9aa19012b6cb0641b78bb94b7c628922dd328d Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 22:18:05 +0900 Subject: [PATCH 68/70] fix(contract): execute public config call paths --- scripts/check-config-reference-contract.py | 11 +-- .../test_check_config_reference_contract.py | 80 +++++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index e03c3b4d2a..253e640b95 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -2381,21 +2381,15 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: if model_copy_sections: return _origin_value(*model_copy_sections) values: list[_AbstractValue] = [] - direct_local_call = ( - isinstance(node.func, ast.Name) - and node.func.id.startswith("_") - or isinstance(node.func, ast.Attribute) - and node.func.attr.startswith("_") - ) for function, bound_receiver in self._call_targets(node.func): - private_config_reader = direct_local_call and any( + syntactic_config_reader = any( isinstance(candidate, ast.Name) and _looks_like_config_name(candidate.id) or isinstance(candidate, ast.Attribute) and candidate.attr in TRACKED_SECTIONS for candidate in ast.walk(function) ) - if private_config_reader or self._call_has_relevant_provenance( + if syntactic_config_reader or self._call_has_relevant_provenance( node, function, bound_receiver ): values.append(self._local_call_value(node, function, bound_receiver)) @@ -3751,6 +3745,7 @@ def visit_For(self, node: ast.For) -> None: mode = "one_turn" if node.body and isinstance(node.body[0], ast.Break) else "full" self._consume_deferred_generator(node.iter, mode=mode) self._consume_deferred_callable_iterator(node.iter, mode=mode) + self._consume_local_iterator(node.iter, mode=mode) self.visit(node.iter) iterable = self._expression_value(node.iter) zero_iterations_possible = self._static_truth(node.iter) is not True diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index ac6c3e7768..2e38f85b38 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -119,6 +119,86 @@ def _read(self): assert contract.runtime_reads(tmp_path, fields) == fields +def test_runtime_scan_executes_public_zero_provenance_callables(contract, tmp_path: Path) -> None: + (tmp_path / "public_calls.py").write_text( + """ +class Reader: + @staticmethod + def read(): + return settings.evaluation.stage1_enabled + + @classmethod + def read_class(cls): + return settings.evaluation.stage2_enabled + + def __call__(self): + return settings.evaluation.stage3_enabled + +Reader.read() +Reader.read_class() +Reader()() +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) + for name in ("stage1_enabled", "stage2_enabled", "stage3_enabled") + ) + + assert contract.runtime_reads(tmp_path, fields) == fields + + +def test_runtime_scan_executes_public_call_before_later_overwrite(contract, tmp_path: Path) -> None: + (tmp_path / "called_then_overwritten.py").write_text( + """ +def read(): + return settings.evaluation.stage1_enabled + +read() +read = external +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + +def test_runtime_scan_rejects_public_reader_overwritten_before_call( + contract, tmp_path: Path +) -> None: + (tmp_path / "overwritten_then_called.py").write_text( + """ +def read(): + return settings.evaluation.stage1_enabled + +read = external +read() +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +def test_runtime_scan_executes_zero_provenance_iterator_protocol(contract, tmp_path: Path) -> None: + (tmp_path / "public_iterator.py").write_text( + """ +class Reader: + def __iter__(self): + yield settings.evaluation.stage1_enabled + +for _ in Reader(): + pass +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) + + def test_runtime_scan_requires_generator_consumption(contract, tmp_path: Path) -> None: (tmp_path / "deferred.py").write_text( """ From 8743bf4609c0c43570a4dac6f511ab32dae40648 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 22:31:10 +0900 Subject: [PATCH 69/70] fix(contract): model annotation execution scopes --- scripts/check-config-reference-contract.py | 23 +++++- .../test_check_config_reference_contract.py | 70 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 253e640b95..21f0ffa48c 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -1098,6 +1098,7 @@ def __init__( self._exception_capture_depth = 0 self._caught_exception_stack: list[tuple[_AbruptPath, ...]] = [] self._function_body_depth = 0 + self._runtime_scope_kinds: list[str] = ["module"] self._class_member_values: dict[int, tuple[_AbstractValue, ...]] = {} self._class_attributes: dict[int, tuple[tuple[str, _AbstractValue], ...]] = {} self._closure_bindings: dict[int, dict[str, _AbstractValue]] = {} @@ -3596,7 +3597,10 @@ def visit_Assign(self, node: ast.Assign) -> None: self._bind_function_target(target, function) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: - if not self._postponed_annotations: + # Eager annotations are evaluated in module and class scopes, but a + # bare local-variable annotation inside a function has no runtime + # evaluation effect (PEP 526). + if not self._postponed_annotations and self._runtime_scope_kinds[-1] != "function": self.visit(node.annotation) self._bind_function_target(node.target, frozenset()) if node.value is not None: @@ -5099,6 +5103,7 @@ def _visit_function_body( self._module = self._source_index.owner(node) or self._module self._expression_cache = {} self._function_body_depth += 1 + self._runtime_scope_kinds.append("function") if generator_identity is not None: self._generator_consumer_modes.append(generator_consumer_mode) self._generator_skip_yields.append( @@ -5131,6 +5136,7 @@ def _visit_function_body( ) self._generator_skip_yields.pop() self._generator_consumer_modes.pop() + self._runtime_scope_kinds.pop() self._function_body_depth -= 1 self._expression_cache = previous_cache self._module = previous_module @@ -5372,6 +5378,19 @@ def _visit_function_definition(self, node: ast.FunctionDef | ast.AsyncFunctionDe for default in (*node.args.defaults, *node.args.kw_defaults): if default is not None: self.visit(default) + if not self._postponed_annotations: + for argument in ( + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ): + if argument.annotation is not None: + self.visit(argument.annotation) + for argument in (node.args.vararg, node.args.kwarg): + if argument is not None and argument.annotation is not None: + self.visit(argument.annotation) + if node.returns is not None: + self.visit(node.returns) self._capture_function_closure(node) value = self._decorated_function_value(node) functions = frozenset(target.function for target in value.callables) @@ -5436,6 +5455,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: self._functions.append(self._declared_functions(node.body)) self._global_names.append(set()) self._nonlocal_names.append(set()) + self._runtime_scope_kinds.append("class") try: for statement in node.body: self.visit(statement) @@ -5454,6 +5474,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: ) self._class_attributes[id(node)] = class_attributes finally: + self._runtime_scope_kinds.pop() self._nonlocal_names.pop() self._global_names.pop() self._functions.pop() diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index 2e38f85b38..b34dfc3f46 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -4809,6 +4809,76 @@ def test_runtime_scan_respects_postponed_annotation_runtime_semantics( ) +def test_runtime_scan_tracks_eager_function_signature_annotations(contract, tmp_path: Path) -> None: + (tmp_path / "eager_signature.py").write_text( + """ +def reader( + positional: settings.evaluation.stage1_enabled, + *args: settings.evaluation.stage2_enabled, + keyword: settings.evaluation.stage3_enabled, + **kwargs: settings.consensus.semantic_enabled, +) -> settings.consensus.uncertainty_threshold: + pass +""", + encoding="utf-8", + ) + fields = frozenset( + { + contract.ConfigField("evaluation", "stage1_enabled"), + contract.ConfigField("evaluation", "stage2_enabled"), + contract.ConfigField("evaluation", "stage3_enabled"), + contract.ConfigField("consensus", "semantic_enabled"), + contract.ConfigField("consensus", "uncertainty_threshold"), + } + ) + reads = contract.runtime_reads(tmp_path, fields) + + assert reads == fields + for field in fields: + assert _audit_as_documented_inert(contract, field, reads).violations + + +def test_runtime_scan_skips_postponed_function_signature_annotations( + contract, tmp_path: Path +) -> None: + (tmp_path / "postponed_signature.py").write_text( + """ +from __future__ import annotations + +def reader( + value: settings.evaluation.stage1_enabled, +) -> settings.evaluation.stage2_enabled: + pass +""", + encoding="utf-8", + ) + fields = frozenset( + contract.ConfigField("evaluation", name) for name in ("stage1_enabled", "stage2_enabled") + ) + reads = contract.runtime_reads(tmp_path, fields) + + assert reads == frozenset() + for field in fields: + assert _audit_as_documented_inert(contract, field, reads).violations == () + + +def test_runtime_scan_skips_function_local_variable_annotations(contract, tmp_path: Path) -> None: + (tmp_path / "local_annotation.py").write_text( + """ +def reader(): + local: settings.evaluation.stage1_enabled + +reader() +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + reads = contract.runtime_reads(tmp_path, frozenset({field})) + + assert reads == frozenset() + assert _audit_as_documented_inert(contract, field, reads).violations == () + + def test_runtime_scan_tracks_alternative_attribute_accessors(contract, tmp_path: Path) -> None: (tmp_path / "attribute_accessors.py").write_text( """ From 904c68f2afe42095399281c2b8d3b8d276b7a422 Mon Sep 17 00:00:00 2001 From: Q00 Date: Sun, 16 Aug 2026 23:01:51 +0900 Subject: [PATCH 70/70] fix(contract): enforce decorator semantics --- scripts/check-config-reference-contract.py | 117 +++++++++++++++++- .../test_check_config_reference_contract.py | 79 +++++++++++- 2 files changed, 190 insertions(+), 6 deletions(-) diff --git a/scripts/check-config-reference-contract.py b/scripts/check-config-reference-contract.py index 21f0ffa48c..d5e17d98cb 100644 --- a/scripts/check-config-reference-contract.py +++ b/scripts/check-config-reference-contract.py @@ -75,6 +75,21 @@ class ContractReport: runtime_reads: frozenset[ConfigField] +class _RuntimeReads(frozenset[ConfigField]): + """Read set carrying fail-closed analyzer uncertainty.""" + + unresolved_semantics: tuple[str, ...] + + def __new__( + cls, + values: Iterable[ConfigField] = (), + unresolved_semantics: Iterable[str] = (), + ) -> _RuntimeReads: + instance = super().__new__(cls, values) + instance.unresolved_semantics = tuple(sorted(set(unresolved_semantics))) + return instance + + TRACKED_SECTIONS = frozenset({"evaluation", "consensus"}) REFERENCE_PATH = Path("docs/config-reference.md") _SECTION_HEADING = re.compile(r"^## `(?P
evaluation|consensus)`\s*$") @@ -308,6 +323,17 @@ class _DeferredPartial: {"", ""} ) _IDENTITY_DECORATOR = "" +_PRESERVING_DECORATOR_NAMES = frozenset( + { + "app.command", + "app.callback", + "command", + "callback", + "dataclass", + "field_validator", + "retry_async", + } +) _WRAPS_FACTORY = "" _UPDATE_WRAPPER = "" _NULLCONTEXT_FACTORY = "" @@ -1111,6 +1137,32 @@ def __init__( self._generator_skip_yields: list[int] = [] self._generator_advanced_yields: list[int] = [] self.reads: set[ConfigField] = set() + self.unresolved_semantics: set[str] = set() + + @staticmethod + def _syntactically_mentions_tracked_config(node: ast.AST) -> bool: + return any( + isinstance(candidate, ast.Name) + and _looks_like_config_name(candidate.id) + or isinstance(candidate, ast.Attribute) + and candidate.attr in TRACKED_SECTIONS + for candidate in ast.walk(node) + ) + + @staticmethod + def _decorator_name(decorator: ast.expr) -> str | None: + target = decorator.func if isinstance(decorator, ast.Call) else decorator + return _callable_name(target) + + def _record_unresolved_decorator(self, node: ast.AST, decorator: ast.expr) -> None: + if not self._syntactically_mentions_tracked_config(node): + return + name = self._decorator_name(decorator) or ast.unparse(decorator) + if name in _PRESERVING_DECORATOR_NAMES: + return + self.unresolved_semantics.add( + f"{self._module.name}:{getattr(node, 'lineno', 0)}: unresolved decorator {name!r}" + ) def _name_value(self, name: str) -> _AbstractValue: for scope in reversed(self._states): @@ -1986,7 +2038,11 @@ def _expression_value(self, node: ast.AST) -> _AbstractValue: return _origin_value(_PARTIALMETHOD_FACTORY) if _FUNCTOOLS_MODULE in owner.origins and node.attr == "reduce": return _origin_value(_REDUCE_CONSUMER) - if _FUNCTOOLS_MODULE in owner.origins and node.attr in {"cache", "lru_cache"}: + if _FUNCTOOLS_MODULE in owner.origins and node.attr in { + "cache", + "lru_cache", + "singledispatch", + }: return _origin_value(_IDENTITY_DECORATOR) if _FUNCTOOLS_MODULE in owner.origins and node.attr == "cached_property": return _origin_value(_CACHED_PROPERTY_DECORATOR) @@ -4594,7 +4650,8 @@ def _bind_import_from(self, node: ast.ImportFrom, *, runtime: bool) -> None: else _origin_value(_REDUCE_CONSUMER) if node.module == "functools" and alias.name == "reduce" else _origin_value(_IDENTITY_DECORATOR) - if node.module == "functools" and alias.name in {"cache", "lru_cache"} + if node.module == "functools" + and alias.name in {"cache", "lru_cache", "singledispatch"} else _origin_value(_WRAPS_FACTORY) if node.module == "functools" and alias.name == "wraps" else _origin_value(_UPDATE_WRAPPER) @@ -5350,10 +5407,22 @@ def _decorated_function_value( value = original for decorator in reversed(node.decorator_list): decorator_value = self._expression_value(decorator) - if decorator_value.origins & (_CONTEXTMANAGER_DECORATORS | {_IDENTITY_DECORATOR}): + if decorator_value.origins & ( + _CONTEXTMANAGER_DECORATORS + | { + _IDENTITY_DECORATOR, + _CLASSMETHOD_DECORATOR, + _STATICMETHOD_DECORATOR, + _PROPERTY_DECORATOR, + _CACHED_PROPERTY_DECORATOR, + } + ): continue targets = self._call_targets(decorator) if not targets: + if (self._decorator_name(decorator) or "") in _PRESERVING_DECORATOR_NAMES: + continue + self._record_unresolved_decorator(node, decorator) return _UNKNOWN_VALUE replacements = [ self._local_direct_call_value( @@ -5363,11 +5432,13 @@ def _decorated_function_value( for function, receiver in targets ] if not replacements: + self._record_unresolved_decorator(node, decorator) return _UNKNOWN_VALUE replacement = _join_values(*replacements) if not replacement.callables and not replacement.instance_classes: if replacement != _UNKNOWN_VALUE: return replacement + self._record_unresolved_decorator(node, decorator) return _UNKNOWN_VALUE value = replacement return value @@ -5484,7 +5555,39 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: classes=frozenset({node}), attributes=class_attributes, ) - self._states[-1][node.name] = created_class + decorated_class = created_class + for decorator in reversed(node.decorator_list): + decorator_value = self._expression_value(decorator) + if _IDENTITY_DECORATOR in decorator_value.origins: + continue + targets = self._call_targets(decorator) + if not targets: + if (self._decorator_name(decorator) or "") in _PRESERVING_DECORATOR_NAMES: + continue + self._record_unresolved_decorator(node, decorator) + decorated_class = _UNKNOWN_VALUE + break + replacements = [ + self._local_direct_call_value( + function, + ((receiver,) if receiver is not None else ()) + (decorated_class,), + ) + for function, receiver in targets + ] + replacement = _join_values(*replacements) + if replacement == _UNKNOWN_VALUE: + self._record_unresolved_decorator(node, decorator) + decorated_class = _UNKNOWN_VALUE + break + decorated_class = replacement + for class_node in decorated_class.classes: + attributes = tuple(value for _name, value in decorated_class.attributes or ()) + if attributes: + self._class_member_values[id(class_node)] = ( + *self._class_member_values.get(id(class_node), ()), + *attributes, + ) + self._states[-1][node.name] = decorated_class self._annotations[-1][node.name] = _UNKNOWN_VALUE self._functions[-1][node.name] = frozenset() @@ -5546,11 +5649,13 @@ def runtime_reads(source_root: Path, fields: frozenset[ConfigField]) -> frozense for path in sorted(source_root.rglob("*.py")) } source_index = _SourceIndex(source_root, trees) + unresolved_semantics: set[str] = set() for path, tree in trees.items(): visitor = _RuntimeReadVisitor(fields, source_index, source_index.module_for_path(path)) visitor.visit(tree) reads.update(visitor.reads) - return frozenset(reads) + unresolved_semantics.update(visitor.unresolved_semantics) + return _RuntimeReads(reads, unresolved_semantics) def _split_markdown_row(line: str) -> tuple[str, ...]: @@ -5688,6 +5793,8 @@ def audit_contract( """Classify every field and return precise bidirectional drift failures.""" violations: list[str] = [] + for detail in getattr(reads, "unresolved_semantics", ()): + violations.append(f"runtime scan unresolved semantics: {detail}") marker_fields = frozenset(markers) allowlisted_fields = frozenset(allowlist) diff --git a/tests/unit/scripts/test_check_config_reference_contract.py b/tests/unit/scripts/test_check_config_reference_contract.py index b34dfc3f46..b1d60dfd6f 100644 --- a/tests/unit/scripts/test_check_config_reference_contract.py +++ b/tests/unit/scripts/test_check_config_reference_contract.py @@ -5509,7 +5509,22 @@ def read_stage(config): ) field = contract.ConfigField("evaluation", "stage2_enabled") - assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + reads = contract.runtime_reads(tmp_path, frozenset({field})) + + assert reads == frozenset() + report = contract.audit_contract( + fields=frozenset({field}), + reads=reads, + rows={ + field: contract.ReferenceRow( + "true", "Currently inert. Effective control: runtime.stage1_enabled." + ) + }, + markers={field: contract.InertMarker(field, "runtime.stage1_enabled")}, + allowlist={}, + documented_defaults={}, + ) + assert any("unresolved decorator 'erase'" in violation for violation in report.violations) def test_runtime_scan_keeps_exact_local_identity_decorator(contract, tmp_path: Path) -> None: @@ -5531,6 +5546,68 @@ def read_stage(config): assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset({field}) +def test_runtime_scan_applies_exact_local_class_decorator(contract, tmp_path: Path) -> None: + (tmp_path / "class_decorator.py").write_text( + """ +def install(cls): + def read(self): + return settings.evaluation.stage1_enabled + cls.read = read + return cls + +@install +class Reader: + pass + +Reader().read() +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + reads = contract.runtime_reads(tmp_path, frozenset({field})) + + assert reads == frozenset({field}) + assert _audit_as_documented_inert(contract, field, reads).violations + + +def test_runtime_scan_honors_exact_class_erasing_decorator(contract, tmp_path: Path) -> None: + (tmp_path / "erased_class.py").write_text( + """ +def erase(cls): + return None + +@erase +class Reader: + def read(self): + return settings.evaluation.stage1_enabled +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + + assert contract.runtime_reads(tmp_path, frozenset({field})) == frozenset() + + +def test_runtime_scan_preserves_functools_singledispatch_default(contract, tmp_path: Path) -> None: + (tmp_path / "singledispatch_reader.py").write_text( + """ +from functools import singledispatch + +@singledispatch +def read(value): + return settings.evaluation.stage1_enabled + +read(object()) +""", + encoding="utf-8", + ) + field = contract.ConfigField("evaluation", "stage1_enabled") + reads = contract.runtime_reads(tmp_path, frozenset({field})) + + assert reads == frozenset({field}) + assert _audit_as_documented_inert(contract, field, reads).violations + + def test_runtime_scan_resolves_inherited_super_property(contract, tmp_path: Path) -> None: (tmp_path / "super_property.py").write_text( """