From 2f522110b4a7e4ba3aae07e587ef12dfd4b8175f Mon Sep 17 00:00:00 2001 From: Shaggi Date: Thu, 30 Jul 2026 00:22:38 +0300 Subject: [PATCH] fix: compose exact resource identities --- docs/effect-contracts.md | 19 ++- .../analyzer/effect_contract_auditor.py | 61 +++++++- .../models/effect_contract.py | 33 +++- .../presets/effects_object_storage_v1.yaml | 24 ++- tests/integration/test_resource_coupling.py | 143 ++++++++++++++++++ tests/unit/test_effect_contract_audit.py | 66 ++++++++ tests/unit/test_effect_contract_models.py | 43 +++++- tests/unit/test_effect_contract_presets.py | 20 ++- 8 files changed, 389 insertions(+), 20 deletions(-) diff --git a/docs/effect-contracts.md b/docs/effect-contracts.md index cba84fe..7f41b8e 100644 --- a/docs/effect-contracts.md +++ b/docs/effect-contracts.md @@ -42,7 +42,7 @@ Source spelling, receiver candidates, suffixes, bare method names, package metad Equivalent YAML/JSON/TOML key and contract ordering produces the same semantic hashes. Formatting changes can change only the raw hash. -## Schema v1, v2, and v3 +## Schema v1 through v4 ```yaml schema_version: 1 @@ -101,6 +101,14 @@ Schema v3 adds optional structured `http_method` metadata for exact `PUT`, `PATCH`, `DELETE`, `HEAD`, and `OPTIONS` are accepted. A method is contract semantics, not runtime observation or a fallback match key. +Schema v4 adds ordered `composite` resource selectors with two through four +ordinary selector components. Every component must resolve to finite evidence; +the bounded Cartesian product may contain at most eight identities. Each result +hash includes the ordered selector domains and component hashes, so `(Bucket, +Key)` cannot collide across buckets or with a reversed selector. Missing, +dynamic, path-based, or over-budget components make the complete resource +identity unavailable rather than partially matching it. + Selectors are deliberately bounded: - `none` @@ -154,10 +162,11 @@ mode-specific append classification, deferred cursors, Redis pipelines, and bare method names are intentionally absent. Each family has an independent identity and semantic hash. Filesystem receiver -origins and exact HTTP verb tables are version `2.0.0`; the other non-SQL -families remain `1.0.0`. HTTP contracts preserve `GET`, `POST`, `PUT`, `PATCH`, -`DELETE`, `HEAD`, or `OPTIONS` as structured contract semantics while finite -URLs remain hashed resource evidence. The v1 changelog and +origins, exact HTTP verb tables, and composite typed-S3 `(Bucket, Key)` identities +are version `2.0.0`; MongoDB and Redis remain `1.0.0`. HTTP contracts preserve +`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, or `OPTIONS` as structured +contract semantics while finite URLs remain hashed resource evidence. Typed S3 +contracts fail closed unless both bucket and key are finite. The v1 changelog and known exclusions are frozen in `benchmarks/results/effect-presets-v1/README.md`. Multiple presets are not silently merged because the current provenance model has one authoritative contract source per analysis. diff --git a/src/fastapi_endpoint_detector/analyzer/effect_contract_auditor.py b/src/fastapi_endpoint_detector/analyzer/effect_contract_auditor.py index 367602c..d5badaa 100644 --- a/src/fastapi_endpoint_detector/analyzer/effect_contract_auditor.py +++ b/src/fastapi_endpoint_detector/analyzer/effect_contract_auditor.py @@ -4,12 +4,15 @@ import hashlib import json +from itertools import product from pathlib import Path from typing import TYPE_CHECKING, Any from fastapi_endpoint_detector.models.effect_contract import ( CallResolutionStatus, + CompositeEffectSelector, EffectContract, + EffectSelector, FiniteValueStatus, LoadedEffectContracts, ResolvedCallSite, @@ -130,11 +133,10 @@ def _call_payload(site: ResolvedCallSite, relative_path: str) -> dict[str, Any]: } -def _resource_identity( - contract: EffectContract, +def _selector_identity( + selector: EffectSelector, site_payload: dict[str, Any], ) -> ResourceIdentityEvidence: - selector = contract.resource if selector.path: return ResourceIdentityEvidence( status=FiniteValueStatus.UNAVAILABLE, @@ -176,6 +178,59 @@ def _resource_identity( ) +def _resource_identity( + contract: EffectContract, + site_payload: dict[str, Any], +) -> ResourceIdentityEvidence: + selector = contract.resource + if not isinstance(selector, CompositeEffectSelector): + return _selector_identity(selector, site_payload) + + component_evidence = [ + _selector_identity(component, site_payload) for component in selector.components + ] + if any(item.status == FiniteValueStatus.UNAVAILABLE for item in component_evidence): + return ResourceIdentityEvidence( + status=FiniteValueStatus.UNAVAILABLE, + reason_code="composite_component_unavailable", + ) + cardinality = 1 + for item in component_evidence: + cardinality *= len(item.value_hashes) + if cardinality > 8: + return ResourceIdentityEvidence( + status=FiniteValueStatus.UNAVAILABLE, + reason_code="composite_resource_limit_exceeded", + ) + + component_domains = [ + component.model_dump(mode="json", exclude_none=True) for component in selector.components + ] + value_hashes = tuple( + sorted( + { + _semantic_hash( + { + "schema_version": 1, + "kind": "composite_resource_identity", + "components": [ + {"selector": domain, "value_hash": value_hash} + for domain, value_hash in zip( + component_domains, combination, strict=True + ) + ], + } + ) + for combination in product(*(item.value_hashes for item in component_evidence)) + } + ) + ) + return ResourceIdentityEvidence( + status=(FiniteValueStatus.EXACT if len(value_hashes) == 1 else FiniteValueStatus.FINITE), + value_hashes=value_hashes, + ) + + def audit_effect_contracts( # noqa: PLR0912, PLR0915 loaded: LoadedEffectContracts, *, diff --git a/src/fastapi_endpoint_detector/models/effect_contract.py b/src/fastapi_endpoint_detector/models/effect_contract.py index 608c5da..50edfb3 100644 --- a/src/fastapi_endpoint_detector/models/effect_contract.py +++ b/src/fastapi_endpoint_detector/models/effect_contract.py @@ -203,6 +203,22 @@ def validate_shape(self) -> EffectSelector: return self +class CompositeEffectSelector(_StrictModel): + """Ordered, domain-separated resource identity components.""" + + kind: Literal["composite"] + components: tuple[EffectSelector, ...] = Field(min_length=2, max_length=4) + + @model_validator(mode="after") + def validate_components(self) -> CompositeEffectSelector: + if any(component.kind == SelectorKind.NONE for component in self.components): + raise ValueError("composite resource components cannot be none selectors") + return self + + +EffectResourceSelector = EffectSelector | CompositeEffectSelector + + class EffectBehavior(_StrictModel): """Declared call timing without implied control-flow proof.""" @@ -226,7 +242,7 @@ class EffectContract(_StrictModel): invocation: InvocationKind operation: EffectOperation channel: EffectChannel - resource: EffectSelector = Field(default_factory=EffectSelector) + resource: EffectResourceSelector = Field(default_factory=EffectSelector) value: EffectSelector | None = None behavior: EffectBehavior = Field(default_factory=EffectBehavior) package: PackageApplicability | None = None @@ -276,7 +292,12 @@ def validate_invocation(self) -> EffectContract: self.channel != EffectChannel.OUTBOUND_HTTP or self.operation != EffectOperation.REQUEST ): raise ValueError("HTTP methods require an outbound_http request contract") - selectors = (self.resource, self.value) + resource_selectors = ( + self.resource.components + if isinstance(self.resource, CompositeEffectSelector) + else (self.resource,) + ) + selectors = (*resource_selectors, self.value) if self.invocation in {InvocationKind.FUNCTION, InvocationKind.CONSTRUCTOR} and any( selector is not None and selector.kind == SelectorKind.RECEIVER for selector in selectors @@ -288,7 +309,7 @@ def validate_invocation(self) -> EffectContract: class EffectContractDocument(_StrictModel): """Versioned root document for a deterministic contract set.""" - schema_version: Literal[1, 2, 3] = 1 + schema_version: Literal[1, 2, 3, 4] = 1 preset: PresetMetadata contracts: tuple[EffectContract, ...] = Field(min_length=1) @@ -296,7 +317,7 @@ class EffectContractDocument(_StrictModel): @classmethod def validate_schema_version_type(cls, value: object) -> object: if type(value) is not int: # bool is intentionally excluded - raise ValueError("schema_version must be the integer 1, 2, or 3") + raise ValueError("schema_version must be the integer 1, 2, 3, or 4") return value @model_validator(mode="after") @@ -311,6 +332,10 @@ def validate_contract_keys(self) -> EffectContractDocument: contract.http_method is not None for contract in self.contracts ): raise ValueError("structured HTTP methods require schema_version 3") + if self.schema_version < 4 and any( + isinstance(contract.resource, CompositeEffectSelector) for contract in self.contracts + ): + raise ValueError("composite resource selectors require schema_version 4") ids: set[str] = set() keys: dict[tuple[str, InvocationKind], EffectContract] = {} for contract in self.contracts: diff --git a/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml index 2908519..73727a1 100644 --- a/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml @@ -1,25 +1,33 @@ -schema_version: 1 +schema_version: 4 preset: id: typed-s3-effects - version: 1.0.0 + version: 2.0.0 provenance: kind: preset source: fastapi-endpoint-detector/effects_object_storage_v1.yaml - revision: "1" + revision: "2" contracts: - id: typed-s3-get-object symbol: mypy_boto3_s3.client.S3Client.get_object invocation: instance_method operation: read channel: object_storage - resource: {kind: keyword, name: Key} + resource: + kind: composite + components: + - {kind: keyword, name: Bucket} + - {kind: keyword, name: Key} package: {distribution: mypy-boto3-s3, version: ">=1.34,<2"} - id: typed-s3-put-object symbol: mypy_boto3_s3.client.S3Client.put_object invocation: instance_method operation: write channel: object_storage - resource: {kind: keyword, name: Key} + resource: + kind: composite + components: + - {kind: keyword, name: Bucket} + - {kind: keyword, name: Key} value: {kind: keyword, name: Body} package: {distribution: mypy-boto3-s3, version: ">=1.34,<2"} - id: typed-s3-delete-object @@ -27,5 +35,9 @@ contracts: invocation: instance_method operation: delete channel: object_storage - resource: {kind: keyword, name: Key} + resource: + kind: composite + components: + - {kind: keyword, name: Bucket} + - {kind: keyword, name: Key} package: {distribution: mypy-boto3-s3, version: ">=1.34,<2"} diff --git a/tests/integration/test_resource_coupling.py b/tests/integration/test_resource_coupling.py index 90abd84..c71c814 100644 --- a/tests/integration/test_resource_coupling.py +++ b/tests/integration/test_resource_coupling.py @@ -113,6 +113,91 @@ def _project( return contracts, coupling, diff +def _composite_project(root: Path, reader_bucket: str) -> tuple[Path, Path, Path]: + (root / "main.py").write_text( + "from fastapi import FastAPI\n\n" + "app = FastAPI()\n\n" + "def write_state(*, Bucket: str, Key: str) -> None: pass\n" + "def read_state(*, Bucket: str, Key: str) -> str: return Key\n\n" + "@app.post('/write')\n" + "def writer() -> None:\n" + " write_state(Bucket='bucket-a', Key='shared-key')\n\n" + "@app.get('/read')\n" + "def reader() -> str:\n" + f" return read_state(Bucket={reader_bucket}, Key='shared-key')\n", + encoding="utf-8", + ) + selector = { + "kind": "composite", + "components": [ + {"kind": "keyword", "name": "Bucket"}, + {"kind": "keyword", "name": "Key"}, + ], + } + contracts = root / "effects.yaml" + contracts.write_text( + yaml.safe_dump( + { + "schema_version": 4, + "preset": { + "id": "composite-test", + "version": "1.0.0", + "provenance": {"kind": "user", "source": "effects.yaml"}, + }, + "contracts": [ + { + "id": "read-state", + "symbol": f"{root.name}.main.read_state", + "invocation": "function", + "operation": "read", + "channel": "custom", + "resource": selector, + }, + { + "id": "write-state", + "symbol": f"{root.name}.main.write_state", + "invocation": "function", + "operation": "write", + "channel": "custom", + "resource": selector, + }, + ], + }, + sort_keys=False, + ), + encoding="utf-8", + ) + coupling = root / "coupling.yaml" + coupling.write_text( + yaml.safe_dump( + { + "schema_version": 1, + "mode": "report_only", + "groups": [ + { + "id": "composite-state", + "resource_space": "composite-test-namespace", + "producer_contract_ids": ["write-state"], + "consumer_contract_ids": ["read-state"], + } + ], + "limits": {"max_endpoint_links_per_resource": 8, "max_edges": 16}, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + diff = root / "change.diff" + diff.write_text( + "diff --git a/main.py b/main.py\n--- a/main.py\n+++ b/main.py\n" + "@@ -5,1 +5,1 @@\n" + "-def write_state(*, Bucket: str, Key: str) -> None: pass\n" + "+def write_state(*, Bucket: str, Key: str) -> None: return None\n", + encoding="utf-8", + ) + return contracts, coupling, diff + + def _candidate_projection(report: AnalysisReport) -> list[dict[str, object]]: return [item.model_dump(mode="json") for item in report.candidate_endpoints] @@ -165,6 +250,64 @@ def test_report_only_graph_is_exact_and_never_changes_candidates(tmp_path: Path) assert "orders-test-namespace" not in serialized +@pytest.mark.parametrize( + ("reader_bucket", "expected_edges"), + [ + ("'bucket-a'", 1), + ("'bucket-b'", 0), + ("dynamic_bucket", 0), + ], +) +def test_composite_resource_identity_requires_every_exact_component( + tmp_path: Path, + reader_bucket: str, + expected_edges: int, +) -> None: + contracts, coupling, diff = _composite_project(tmp_path, reader_bucket) + if reader_bucket == "dynamic_bucket": + main = tmp_path / "main.py" + main.write_text( + main.read_text(encoding="utf-8").replace( + "@app.get('/read')", + "dynamic_bucket = input()\n\n@app.get('/read')", + ), + encoding="utf-8", + ) + + report = ChangeMapper( + app_path=tmp_path, + config=Config( + analysis=AnalysisConfig( + effect_contracts=contracts, + resource_coupling=coupling, + ) + ), + secure_ast=True, + use_cache=False, + ).analyze_diff(diff) + + assert report.resource_coupling_graph is not None + assert len(report.resource_coupling_graph.edges) == expected_edges + audit = report.effect_contract_audit + assert audit is not None + identities = { + occurrence.contract_id: occurrence.resource_identity + for occurrence in audit.occurrences + if occurrence.contract_id is not None + } + if reader_bucket == "dynamic_bucket": + assert identities["read-state"] is not None + assert identities["read-state"].status.value == "unavailable" + assert identities["read-state"].reason_code == "composite_component_unavailable" + else: + assert identities["write-state"] is not None + assert identities["write-state"].status.value == "exact" + assert identities["read-state"] is not None + assert identities["read-state"].status.value == "exact" + if reader_bucket == "'bucket-b'": + assert identities["write-state"].value_hashes != identities["read-state"].value_hashes + + def test_exact_added_writer_callsite_adds_one_low_nonrecursive_reader( tmp_path: Path, ) -> None: diff --git a/tests/unit/test_effect_contract_audit.py b/tests/unit/test_effect_contract_audit.py index 061303e..773dac4 100644 --- a/tests/unit/test_effect_contract_audit.py +++ b/tests/unit/test_effect_contract_audit.py @@ -8,6 +8,7 @@ from fastapi_endpoint_detector.analyzer.effect_contract_auditor import audit_effect_contracts from fastapi_endpoint_detector.models.effect_contract import ( + CallArgumentEvidence, CallResolutionStatus, FiniteValueStatus, InvocationKind, @@ -131,6 +132,71 @@ def _audit( ) +def test_composite_resource_cartesian_overflow_is_unavailable(tmp_path: Path) -> None: + contracts = tmp_path / "composite-effects.yaml" + contracts.write_text( + yaml.safe_dump( + { + "schema_version": 4, + "preset": { + "id": "composite-audit", + "version": "1.0.0", + "provenance": {"kind": "user", "source": "effects.yaml"}, + }, + "contracts": [ + { + "id": "emit", + "symbol": "company.events.emit", + "invocation": "function", + "operation": "publish", + "channel": "message_bus", + "resource": { + "kind": "composite", + "components": [ + {"kind": "keyword", "name": "Bucket"}, + {"kind": "keyword", "name": "Key"}, + ], + }, + } + ], + }, + sort_keys=False, + ), + encoding="utf-8", + ) + hashes = tuple(f"sha256:{character * 64}" for character in "abcdef") + site = _site(tmp_path, column=2).model_copy( + update={ + "arguments": ( + CallArgumentEvidence( + source_index=0, + keyword="Bucket", + status=FiniteValueStatus.FINITE, + value_hashes=hashes[:3], + ), + CallArgumentEvidence( + source_index=1, + keyword="Key", + status=FiniteValueStatus.FINITE, + value_hashes=hashes[3:], + ), + ) + } + ) + + audit = _audit( + tmp_path, + [(_endpoint(tmp_path, "handler"), [site])], + loaded=load_effect_contracts(contracts), + ) + + identity = audit.occurrences[0].resource_identity + assert identity is not None + assert identity.status == FiniteValueStatus.UNAVAILABLE + assert identity.value_hashes == () + assert identity.reason_code == "composite_resource_limit_exceeded" + + def test_exact_matching_is_symbol_and_invocation_only(tmp_path: Path) -> None: endpoint = _endpoint(tmp_path, "handler") rows = [ diff --git a/tests/unit/test_effect_contract_models.py b/tests/unit/test_effect_contract_models.py index 0a9de8b..18b2e8b 100644 --- a/tests/unit/test_effect_contract_models.py +++ b/tests/unit/test_effect_contract_models.py @@ -131,7 +131,7 @@ def test_semantic_change_changes_hash(tmp_path: Path) -> None: @pytest.mark.parametrize( "mutation,match", [ - (lambda data: data.update(schema_version=4), "schema_version"), + (lambda data: data.update(schema_version=5), "schema_version"), (lambda data: data.update(unknown=True), "Extra inputs"), ( lambda data: data["contracts"][0].update(symbol="redis.client.Redis.*"), @@ -155,6 +155,47 @@ def test_rejects_unsafe_or_malformed_contracts(mutation: object, match: str) -> EffectContractDocument.model_validate(data) +def test_composite_resources_require_schema_v4_and_bounded_real_components() -> None: + data = _document() + contract = data["contracts"][0] # type: ignore[index] + contract["resource"] = { + "kind": "composite", + "components": [ + {"kind": "keyword", "name": "Bucket"}, + {"kind": "keyword", "name": "Key"}, + ], + } + + with pytest.raises(ValidationError, match="schema_version 4"): + EffectContractDocument.model_validate(data) + + data["schema_version"] = 4 + document = EffectContractDocument.model_validate(data) + assert ( + document.contracts[0].resource.model_dump( + mode="json", exclude_none=True, exclude_defaults=True + ) + == contract["resource"] + ) + + contract["resource"] = { + "kind": "composite", + "components": [{"kind": "keyword", "name": "Bucket"}], + } + with pytest.raises(ValidationError, match="at least 2"): + EffectContractDocument.model_validate(data) + + contract["resource"] = { + "kind": "composite", + "components": [ + {"kind": "none"}, + {"kind": "keyword", "name": "Key"}, + ], + } + with pytest.raises(ValidationError, match="cannot be none"): + EffectContractDocument.model_validate(data) + + def test_transaction_scope_requires_schema_v2_sql_begin_context() -> None: data = _document() contracts = data["contracts"] diff --git a/tests/unit/test_effect_contract_presets.py b/tests/unit/test_effect_contract_presets.py index 646fbb8..43cb55c 100644 --- a/tests/unit/test_effect_contract_presets.py +++ b/tests/unit/test_effect_contract_presets.py @@ -22,7 +22,7 @@ "filesystem-v1": "sha256:5acc35da9d989ccafda0960090efefbbaa52ca5b70894882c24c4bf1355c2b96", "http-clients-v1": "sha256:ab3d88b368db24f4c6c0879c8104105b09f23997997e63dd232856886bca6e2e", "mongodb-v1": "sha256:7e0f41e452ac61b7340f02215963e8aa765333988b67d441b7aece9dfa53191c", - "object-storage-v1": "sha256:53a918b63f7813f54a23b502c68dfea40246d2a8a465275fb221bce018420996", + "object-storage-v1": "sha256:99707cccf212f530bd2437a3cb154d5ebea775857cc7d65c777771f9771cc6c4", "redis-v1": "sha256:ce681490563300ce01dec68cd42af26c5fe8e06c7d5d45ae652dfce73c531ca2", "sqlalchemy-v1": "sha256:132982ba61f04626df531dc80c71ce5d21c12ec583a932d21c220486785c8d04", } @@ -111,11 +111,13 @@ def test_bundled_effect_presets_are_strict_versioned_snapshots(name: str) -> Non expected_version = { "filesystem-v1": "2.0.0", "http-clients-v1": "2.0.0", + "object-storage-v1": "2.0.0", "sqlalchemy-v1": "3.0.0", }.get(name, "1.0.0") expected_revision = { "filesystem-v1": "2", "http-clients-v1": "2", + "object-storage-v1": "2", "sqlalchemy-v1": "3", }.get(name, "1") assert loaded.document.preset.version == expected_version @@ -145,6 +147,22 @@ def test_presets_never_contain_bare_or_generic_method_symbols() -> None: ) +def test_object_storage_preset_uses_bucket_key_composite_identity() -> None: + loaded = load_effect_preset("object-storage-v1") + + assert loaded.document.schema_version == 4 + for contract in loaded.document.contracts: + assert contract.resource.model_dump( + mode="json", exclude_none=True, exclude_defaults=True + ) == { + "kind": "composite", + "components": [ + {"kind": "keyword", "name": "Bucket"}, + {"kind": "keyword", "name": "Key"}, + ], + } + + def test_http_client_preset_declares_exact_methods_for_each_supported_client() -> None: loaded = load_effect_preset("http-clients-v1") methods_by_class: dict[str, set[str]] = {}