From 16a69070b4f2da6843d34f891df3cc9f287cd26b Mon Sep 17 00:00:00 2001 From: Shaggi Date: Wed, 29 Jul 2026 23:02:01 +0300 Subject: [PATCH 1/4] feat: expand exact effect presets --- CHANGELOG.md | 6 + docs/effect-contracts.md | 36 ++-- .../models/effect_contract.py | 1 + .../presets/effects_filesystem_v1.yaml | 173 ++++++++++++++-- .../presets/effects_http_clients_v1.yaml | 121 +++++++++++- .../presets/effects_message_bus_v1.yaml | 48 +++++ .../presets/effects_mongodb_v1.yaml | 117 ++++++++++- .../presets/effects_object_storage_v1.yaml | 60 ++++-- .../presets/effects_redis_v1.yaml | 67 ++++++- tests/unit/test_effect_contract_audit.py | 140 +++++++------ tests/unit/test_effect_contract_presets.py | 186 +++++++++++++++--- 11 files changed, 796 insertions(+), 159 deletions(-) create mode 100644 src/fastapi_endpoint_detector/presets/effects_message_bus_v1.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c1d3d..563ed75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - coverage.py dependency ### Added +- **Versioned exact effect presets**: expanded Redis, PyMongo/Motor, filesystem, + requests/httpx/aiohttp, and typed S3 contracts; added a message-bus preset for + exact aiokafka, kafka-python, confluent-kafka, pika, and kombu symbols. + Dynamic method/mode APIs remain deliberately unsupported, and S3 object + operations abstain from Key-only resource identity until composite selectors + are available. - **Mypy integration**: Added mypy's build API for type-aware dependency analysis - New `_get_module_dependencies_via_mypy()` method for full dependency graph extraction - New `_module_to_file_path()` helper for module resolution diff --git a/docs/effect-contracts.md b/docs/effect-contracts.md index 7f41b8e..f9c2128 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 through v4 +## Schema v1, v2, and v3 ```yaml schema_version: 1 @@ -101,14 +101,6 @@ 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` @@ -125,12 +117,13 @@ differ from the analyzed snapshot. ## Package-owned presets -Six conservative, independently versioned exact-symbol presets are bundled: +Seven conservative, independently versioned exact-symbol presets are bundled: - `redis-v1` - `mongodb-v1` - `filesystem-v1` - `http-clients-v1` +- `message-bus-v1` - `object-storage-v1` - `sqlalchemy-v1` @@ -151,22 +144,25 @@ analysis: same exact `(canonical symbol, invocation)` matcher and evidence-only behavior as user documents. Version ranges are audited support metadata, not runtime package checks. Direct positional/keyword finite strings produce hashed resource -identities. `filesystem-v1` 2.0 additionally traces an exact `pathlib.Path(...)` +identities. `filesystem-v1` additionally traces an exact `pathlib.Path(...)` or `builtins.open(...)` constructor through one unconditional local assignment or active `with` binding into exact `_io`/`Path` instance methods. Reassignment, escaped handles, control flow, aliases, captured handles, composition, dynamic arguments, and unsupported factories fail closed. Dynamic boto3 clients without `mypy-boto3-s3`, generic HTTP `request`/`send`, -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, 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 +mode-specific `open()` classification, deferred cursor consumption, Redis +pipelines, and bare method names are intentionally absent. S3 object operations +are exact effects but do not claim a Key-only resource identity: `(Bucket, Key)` +requires a future composite selector. Message-publisher contracts likewise omit +an identity when routing requires multiple values that the selector schema cannot +represent. + +Each family has an independent identity and semantic hash. Filesystem and HTTP +contracts are version `3.0.0`; Redis, MongoDB/Motor, and typed S3 contracts are +version `2.0.0`; message bus starts at `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 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/models/effect_contract.py b/src/fastapi_endpoint_detector/models/effect_contract.py index 50edfb3..e50d1c4 100644 --- a/src/fastapi_endpoint_detector/models/effect_contract.py +++ b/src/fastapi_endpoint_detector/models/effect_contract.py @@ -28,6 +28,7 @@ BUNDLED_EFFECT_PRESETS = { "filesystem-v1": Path(__file__).parent.parent / "presets" / "effects_filesystem_v1.yaml", "http-clients-v1": Path(__file__).parent.parent / "presets" / "effects_http_clients_v1.yaml", + "message-bus-v1": Path(__file__).parent.parent / "presets" / "effects_message_bus_v1.yaml", "mongodb-v1": Path(__file__).parent.parent / "presets" / "effects_mongodb_v1.yaml", "object-storage-v1": ( Path(__file__).parent.parent / "presets" / "effects_object_storage_v1.yaml" diff --git a/src/fastapi_endpoint_detector/presets/effects_filesystem_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_filesystem_v1.yaml index 20b314a..95a19b7 100644 --- a/src/fastapi_endpoint_detector/presets/effects_filesystem_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_filesystem_v1.yaml @@ -1,11 +1,11 @@ schema_version: 1 preset: id: stdlib-filesystem-effects - version: 2.0.0 + version: 3.0.0 provenance: kind: preset source: fastapi-endpoint-detector/effects_filesystem_v1.yaml - revision: "2" + revision: "3" contracts: - id: pathlib-read-text symbol: pathlib.Path.read_text @@ -13,21 +13,82 @@ contracts: operation: read channel: filesystem resource: {kind: receiver} - package: {python: ">=3.10,<3.14"} + package: &python {python: ">=3.10,<3.14"} - id: pathlib-read-bytes symbol: pathlib.Path.read_bytes invocation: instance_method operation: read channel: filesystem resource: {kind: receiver} - package: {python: ">=3.10,<3.14"} + package: *python + - id: pathlib-write-text + symbol: pathlib.Path.write_text + invocation: instance_method + operation: write + channel: filesystem + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *python + - id: pathlib-write-bytes + symbol: pathlib.Path.write_bytes + invocation: instance_method + operation: write + channel: filesystem + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *python + - id: pathlib-touch + symbol: pathlib.Path.touch + invocation: instance_method + operation: write + channel: filesystem + resource: {kind: receiver} + package: *python + - id: pathlib-mkdir + symbol: pathlib.Path.mkdir + invocation: instance_method + operation: write + channel: filesystem + resource: {kind: receiver} + package: *python + - id: pathlib-unlink + symbol: pathlib.Path.unlink + invocation: instance_method + operation: delete + channel: filesystem + resource: {kind: receiver} + package: *python + - id: pathlib-rmdir + symbol: pathlib.Path.rmdir + invocation: instance_method + operation: delete + channel: filesystem + resource: {kind: receiver} + package: *python + - id: pathlib-rename + symbol: pathlib.Path.rename + invocation: instance_method + operation: update + channel: filesystem + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *python + - id: pathlib-replace + symbol: pathlib.Path.replace + invocation: instance_method + operation: update + channel: filesystem + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *python + - id: io-text-read symbol: _io._TextIOBase.read invocation: instance_method operation: read channel: filesystem resource: {kind: receiver} - package: {python: ">=3.10,<3.14"} + package: *python - id: io-text-write symbol: _io._TextIOBase.write invocation: instance_method @@ -35,14 +96,14 @@ contracts: channel: filesystem resource: {kind: receiver} value: {kind: argument, index: 0} - package: {python: ">=3.10,<3.14"} + package: *python - id: io-buffered-read symbol: _io._BufferedIOBase.read invocation: instance_method operation: read channel: filesystem resource: {kind: receiver} - package: {python: ">=3.10,<3.14"} + package: *python - id: io-buffered-write symbol: _io.BufferedWriter.write invocation: instance_method @@ -50,20 +111,96 @@ contracts: channel: filesystem resource: {kind: receiver} value: {kind: argument, index: 0} - package: {python: ">=3.10,<3.14"} - - id: pathlib-write-text - symbol: pathlib.Path.write_text - invocation: instance_method + package: *python + + - id: os-remove + symbol: os.remove + invocation: function + operation: delete + channel: filesystem + resource: {kind: argument, index: 0} + package: *python + - id: os-unlink + symbol: os.unlink + invocation: function + operation: delete + channel: filesystem + resource: {kind: argument, index: 0} + package: *python + - id: os-rmdir + symbol: os.rmdir + invocation: function + operation: delete + channel: filesystem + resource: {kind: argument, index: 0} + package: *python + - id: os-mkdir + symbol: os.mkdir + invocation: function operation: write channel: filesystem - resource: {kind: receiver} + resource: {kind: argument, index: 0} + package: *python + - id: os-makedirs + symbol: os.makedirs + invocation: function + operation: write + channel: filesystem + resource: {kind: argument, index: 0} + package: *python + - id: os-rename + symbol: os.rename + invocation: function + operation: update + channel: filesystem + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *python + - id: os-replace + symbol: os.replace + invocation: function + operation: update + channel: filesystem + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *python + + - id: shutil-copyfile + symbol: shutil.copyfile + invocation: function + operation: write + channel: filesystem + resource: {kind: argument, index: 1} value: {kind: argument, index: 0} - package: {python: ">=3.10,<3.14"} - - id: pathlib-write-bytes - symbol: pathlib.Path.write_bytes - invocation: instance_method + package: *python + - id: shutil-copy + symbol: shutil.copy + invocation: function operation: write channel: filesystem - resource: {kind: receiver} + resource: {kind: argument, index: 1} value: {kind: argument, index: 0} - package: {python: ">=3.10,<3.14"} + package: *python + - id: shutil-copy2 + symbol: shutil.copy2 + invocation: function + operation: write + channel: filesystem + resource: {kind: argument, index: 1} + value: {kind: argument, index: 0} + package: *python + - id: shutil-move + symbol: shutil.move + invocation: function + operation: update + channel: filesystem + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *python + - id: shutil-rmtree + symbol: shutil.rmtree + invocation: function + operation: delete + channel: filesystem + resource: {kind: argument, index: 0} + package: *python diff --git a/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml index 7061b4c..9556eb5 100644 --- a/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml @@ -1,11 +1,11 @@ schema_version: 3 preset: id: python-http-client-effects - version: 2.0.0 + version: 3.0.0 provenance: kind: preset source: fastapi-endpoint-detector/effects_http_clients_v1.yaml - revision: "2" + revision: "3" contracts: - id: requests-session-get symbol: requests.sessions.Session.get @@ -248,3 +248,120 @@ contracts: http_method: OPTIONS behavior: *aiohttp_async package: *aiohttp + + # Public convenience functions are separate exact symbols from Session methods. + # Generic request(method, url) APIs are deliberately absent because v3 has no + # selector for a bounded method argument. + - id: requests-api-get + symbol: requests.api.get + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: GET + package: *requests + - id: requests-api-post + symbol: requests.api.post + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: POST + package: *requests + - id: requests-api-put + symbol: requests.api.put + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: PUT + package: *requests + - id: requests-api-patch + symbol: requests.api.patch + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: PATCH + package: *requests + - id: requests-api-delete + symbol: requests.api.delete + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: DELETE + package: *requests + - id: requests-api-head + symbol: requests.api.head + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: HEAD + package: *requests + - id: requests-api-options + symbol: requests.api.options + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: OPTIONS + package: *requests + + - id: httpx-get + symbol: httpx.get + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: GET + package: *httpx + - id: httpx-post + symbol: httpx.post + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: POST + package: *httpx + - id: httpx-put + symbol: httpx.put + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: PUT + package: *httpx + - id: httpx-patch + symbol: httpx.patch + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: PATCH + package: *httpx + - id: httpx-delete + symbol: httpx.delete + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: DELETE + package: *httpx + - id: httpx-head + symbol: httpx.head + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: HEAD + package: *httpx + - id: httpx-options + symbol: httpx.options + invocation: function + operation: request + channel: outbound_http + resource: {kind: argument, index: 0} + http_method: OPTIONS + package: *httpx diff --git a/src/fastapi_endpoint_detector/presets/effects_message_bus_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_message_bus_v1.yaml new file mode 100644 index 0000000..c6f2fdf --- /dev/null +++ b/src/fastapi_endpoint_detector/presets/effects_message_bus_v1.yaml @@ -0,0 +1,48 @@ +schema_version: 1 +preset: + id: python-message-bus-effects + version: 1.0.0 + provenance: + kind: preset + source: fastapi-endpoint-detector/effects_message_bus_v1.yaml + revision: "1" +contracts: + - id: aiokafka-send-and-wait + symbol: aiokafka.producer.producer.AIOKafkaProducer.send_and_wait + invocation: instance_method + operation: publish + channel: message_bus + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + behavior: {async_mode: async, timing: await} + package: {distribution: aiokafka, version: ">=0.10,<1"} + - id: kafka-python-send + symbol: kafka.producer.kafka.KafkaProducer.send + invocation: instance_method + operation: publish + channel: message_bus + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: {distribution: kafka-python, version: ">=2,<3"} + - id: confluent-kafka-produce + symbol: confluent_kafka.Producer.produce + invocation: instance_method + operation: publish + channel: message_bus + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: {distribution: confluent-kafka, version: ">=2.3,<3"} + - id: pika-basic-publish + symbol: pika.channel.Channel.basic_publish + invocation: instance_method + operation: publish + channel: message_bus + value: {kind: argument, index: 2} + package: {distribution: pika, version: ">=1.3,<2"} + - id: kombu-producer-publish + symbol: kombu.messaging.Producer.publish + invocation: instance_method + operation: publish + channel: message_bus + value: {kind: argument, index: 0} + package: {distribution: kombu, version: ">=5.3,<6"} diff --git a/src/fastapi_endpoint_detector/presets/effects_mongodb_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_mongodb_v1.yaml index 70057dc..a0d0a61 100644 --- a/src/fastapi_endpoint_detector/presets/effects_mongodb_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_mongodb_v1.yaml @@ -1,11 +1,11 @@ schema_version: 1 preset: id: pymongo-effects - version: 1.0.0 + version: 2.0.0 provenance: kind: preset source: fastapi-endpoint-detector/effects_mongodb_v1.yaml - revision: "1" + revision: "2" contracts: - id: pymongo-find-one symbol: pymongo.synchronous.collection.Collection.find_one @@ -14,7 +14,7 @@ contracts: channel: mongodb resource: {kind: receiver} value: {kind: argument, index: 0} - package: {distribution: pymongo, version: ">=4.9,<5"} + package: &pymongo {distribution: pymongo, version: ">=4.9,<5"} - id: pymongo-insert-one symbol: pymongo.synchronous.collection.Collection.insert_one invocation: instance_method @@ -22,7 +22,23 @@ contracts: channel: mongodb resource: {kind: receiver} value: {kind: argument, index: 0} - package: {distribution: pymongo, version: ">=4.9,<5"} + package: *pymongo + - id: pymongo-insert-many + symbol: pymongo.synchronous.collection.Collection.insert_many + invocation: instance_method + operation: write + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *pymongo + - id: pymongo-replace-one + symbol: pymongo.synchronous.collection.Collection.replace_one + invocation: instance_method + operation: update + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 1} + package: *pymongo - id: pymongo-update-one symbol: pymongo.synchronous.collection.Collection.update_one invocation: instance_method @@ -30,7 +46,15 @@ contracts: channel: mongodb resource: {kind: receiver} value: {kind: argument, index: 1} - package: {distribution: pymongo, version: ">=4.9,<5"} + package: *pymongo + - id: pymongo-update-many + symbol: pymongo.synchronous.collection.Collection.update_many + invocation: instance_method + operation: update + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 1} + package: *pymongo - id: pymongo-delete-one symbol: pymongo.synchronous.collection.Collection.delete_one invocation: instance_method @@ -38,4 +62,85 @@ contracts: channel: mongodb resource: {kind: receiver} value: {kind: argument, index: 0} - package: {distribution: pymongo, version: ">=4.9,<5"} + package: *pymongo + - id: pymongo-delete-many + symbol: pymongo.synchronous.collection.Collection.delete_many + invocation: instance_method + operation: delete + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + package: *pymongo + + - id: motor-find-one + symbol: motor.core.AgnosticCollection.find_one + invocation: instance_method + operation: read + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + behavior: &motor_await {async_mode: async, timing: await} + package: &motor {distribution: motor, version: ">=3.6,<4"} + - id: motor-insert-one + symbol: motor.core.AgnosticCollection.insert_one + invocation: instance_method + operation: write + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + behavior: *motor_await + package: *motor + - id: motor-insert-many + symbol: motor.core.AgnosticCollection.insert_many + invocation: instance_method + operation: write + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + behavior: *motor_await + package: *motor + - id: motor-replace-one + symbol: motor.core.AgnosticCollection.replace_one + invocation: instance_method + operation: update + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 1} + behavior: *motor_await + package: *motor + - id: motor-update-one + symbol: motor.core.AgnosticCollection.update_one + invocation: instance_method + operation: update + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 1} + behavior: *motor_await + package: *motor + - id: motor-update-many + symbol: motor.core.AgnosticCollection.update_many + invocation: instance_method + operation: update + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 1} + behavior: *motor_await + package: *motor + - id: motor-delete-one + symbol: motor.core.AgnosticCollection.delete_one + invocation: instance_method + operation: delete + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + behavior: *motor_await + package: *motor + - id: motor-delete-many + symbol: motor.core.AgnosticCollection.delete_many + invocation: instance_method + operation: delete + channel: mongodb + resource: {kind: receiver} + value: {kind: argument, index: 0} + behavior: *motor_await + package: *motor 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 73727a1..f7f1236 100644 --- a/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml @@ -1,4 +1,4 @@ -schema_version: 4 +schema_version: 1 preset: id: typed-s3-effects version: 2.0.0 @@ -7,37 +7,59 @@ preset: source: fastapi-endpoint-detector/effects_object_storage_v1.yaml revision: "2" contracts: + # Object identity requires the pair (Bucket, Key), which schema v1 cannot + # encode. These exact operation contracts therefore abstain from resource + # identity instead of emitting a false Key-only coupling. - id: typed-s3-get-object symbol: mypy_boto3_s3.client.S3Client.get_object invocation: instance_method operation: read channel: object_storage - resource: - kind: composite - components: - - {kind: keyword, name: Bucket} - - {kind: keyword, name: Key} - package: {distribution: mypy-boto3-s3, version: ">=1.34,<2"} + package: &s3 {distribution: mypy-boto3-s3, version: ">=1.34,<2"} + - id: typed-s3-head-object + symbol: mypy_boto3_s3.client.S3Client.head_object + invocation: instance_method + operation: read + channel: object_storage + package: *s3 - id: typed-s3-put-object symbol: mypy_boto3_s3.client.S3Client.put_object invocation: instance_method operation: write channel: object_storage - 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"} + package: *s3 + - id: typed-s3-copy-object + symbol: mypy_boto3_s3.client.S3Client.copy_object + invocation: instance_method + operation: write + channel: object_storage + value: {kind: keyword, name: CopySource} + package: *s3 - id: typed-s3-delete-object symbol: mypy_boto3_s3.client.S3Client.delete_object invocation: instance_method operation: delete channel: object_storage - resource: - kind: composite - components: - - {kind: keyword, name: Bucket} - - {kind: keyword, name: Key} - package: {distribution: mypy-boto3-s3, version: ">=1.34,<2"} + package: *s3 + - id: typed-s3-list-objects-v2 + symbol: mypy_boto3_s3.client.S3Client.list_objects_v2 + invocation: instance_method + operation: read + channel: object_storage + resource: {kind: keyword, name: Bucket} + package: *s3 + - id: typed-s3-create-bucket + symbol: mypy_boto3_s3.client.S3Client.create_bucket + invocation: instance_method + operation: write + channel: object_storage + resource: {kind: keyword, name: Bucket} + package: *s3 + - id: typed-s3-delete-bucket + symbol: mypy_boto3_s3.client.S3Client.delete_bucket + invocation: instance_method + operation: delete + channel: object_storage + resource: {kind: keyword, name: Bucket} + package: *s3 diff --git a/src/fastapi_endpoint_detector/presets/effects_redis_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_redis_v1.yaml index 5556953..dbcf186 100644 --- a/src/fastapi_endpoint_detector/presets/effects_redis_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_redis_v1.yaml @@ -1,11 +1,11 @@ schema_version: 1 preset: id: redis-py-effects - version: 1.0.0 + version: 2.0.0 provenance: kind: preset source: fastapi-endpoint-detector/effects_redis_v1.yaml - revision: "1" + revision: "2" contracts: - id: redis-get symbol: redis.commands.core.BasicKeyCommands.get @@ -13,7 +13,7 @@ contracts: operation: read channel: redis resource: {kind: argument, index: 0} - package: {distribution: redis, version: ">=5,<7"} + package: &redis {distribution: redis, version: ">=5,<7"} - id: redis-set symbol: redis.commands.core.BasicKeyCommands.set invocation: instance_method @@ -21,14 +21,69 @@ contracts: channel: redis resource: {kind: argument, index: 0} value: {kind: argument, index: 1} - package: {distribution: redis, version: ">=5,<7"} + package: *redis - id: redis-delete symbol: redis.commands.core.BasicKeyCommands.delete invocation: instance_method operation: delete channel: redis resource: {kind: argument, index: 0} - package: {distribution: redis, version: ">=5,<7"} + package: *redis + - id: redis-expire + symbol: redis.commands.core.BasicKeyCommands.expire + invocation: instance_method + operation: update + channel: redis + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *redis + - id: redis-incrby + symbol: redis.commands.core.BasicKeyCommands.incrby + invocation: instance_method + operation: update + channel: redis + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *redis + - id: redis-hget + symbol: redis.commands.core.HashCommands.hget + invocation: instance_method + operation: read + channel: redis + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *redis + - id: redis-hset + symbol: redis.commands.core.HashCommands.hset + invocation: instance_method + operation: update + channel: redis + resource: {kind: argument, index: 0} + package: *redis + - id: redis-hdel + symbol: redis.commands.core.HashCommands.hdel + invocation: instance_method + operation: update + channel: redis + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *redis + - id: redis-lpush + symbol: redis.commands.core.ListCommands.lpush + invocation: instance_method + operation: append + channel: redis + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *redis + - id: redis-rpush + symbol: redis.commands.core.ListCommands.rpush + invocation: instance_method + operation: append + channel: redis + resource: {kind: argument, index: 0} + value: {kind: argument, index: 1} + package: *redis - id: redis-publish symbol: redis.commands.core.PubSubCommands.publish invocation: instance_method @@ -36,4 +91,4 @@ contracts: channel: message_bus resource: {kind: argument, index: 0} value: {kind: argument, index: 1} - package: {distribution: redis, version: ">=5,<7"} + package: *redis diff --git a/tests/unit/test_effect_contract_audit.py b/tests/unit/test_effect_contract_audit.py index 773dac4..a43bde5 100644 --- a/tests/unit/test_effect_contract_audit.py +++ b/tests/unit/test_effect_contract_audit.py @@ -8,16 +8,18 @@ from fastapi_endpoint_detector.analyzer.effect_contract_auditor import audit_effect_contracts from fastapi_endpoint_detector.models.effect_contract import ( - CallArgumentEvidence, CallResolutionStatus, FiniteValueStatus, InvocationKind, + LoadedEffectContracts, ResolvedCallSite, ResourceIdentityEvidence, load_effect_contracts, + load_effect_preset, ) from fastapi_endpoint_detector.models.effect_contract_audit import ( AuditCallStatus, + EffectContractAudit, EffectContractAuditError, ) from fastapi_endpoint_detector.models.endpoint import ( @@ -34,7 +36,7 @@ from pathlib import Path -def _loaded(path: Path): +def _loaded(path: Path) -> LoadedEffectContracts: document = { "schema_version": 1, "preset": { @@ -112,13 +114,13 @@ def _site( def _audit( root: Path, - rows, + rows: list[tuple[Endpoint, list[ResolvedCallSite]]], *, - loaded=None, + loaded: LoadedEffectContracts | None = None, track_transitive: bool = True, max_depth: int = 10, cache_enabled: bool = True, -): +) -> EffectContractAudit: endpoints = [endpoint for endpoint, _sites in rows] return audit_effect_contracts( loaded or _loaded(root / "effects.yaml"), @@ -132,69 +134,87 @@ 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, +@pytest.mark.parametrize( + ("preset", "symbol", "invocation", "contract_id", "negative_symbol"), + [ + ( + "redis-v1", + "redis.commands.core.HashCommands.hset", + InvocationKind.INSTANCE_METHOD, + "redis-hset", + "project.Cache.hset", ), - encoding="utf-8", + ( + "mongodb-v1", + "motor.core.AgnosticCollection.update_one", + InvocationKind.INSTANCE_METHOD, + "motor-update-one", + "project.Collection.update_one", + ), + ( + "filesystem-v1", + "os.remove", + InvocationKind.FUNCTION, + "os-remove", + "project.files.remove", + ), + ( + "http-clients-v1", + "requests.api.post", + InvocationKind.FUNCTION, + "requests-api-post", + "project.http.post", + ), + ( + "object-storage-v1", + "mypy_boto3_s3.client.S3Client.put_object", + InvocationKind.INSTANCE_METHOD, + "typed-s3-put-object", + "project.S3.put_object", + ), + ( + "message-bus-v1", + "aiokafka.producer.producer.AIOKafkaProducer.send_and_wait", + InvocationKind.INSTANCE_METHOD, + "aiokafka-send-and-wait", + "project.Producer.send_and_wait", + ), + ], +) +def test_bundled_presets_match_only_exact_qualified_symbols( + tmp_path: Path, + preset: str, + symbol: str, + invocation: InvocationKind, + contract_id: str, + negative_symbol: str, +) -> None: + endpoint = _endpoint(tmp_path, "handler") + exact = _site( + tmp_path, + column=2, + symbol=symbol, + invocation=invocation, + spelling=symbol.rsplit(".", maxsplit=1)[-1], ) - 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:], - ), - ) - } + unrelated = _site( + tmp_path, + column=30, + symbol=negative_symbol, + invocation=invocation, + spelling=negative_symbol.rsplit(".", maxsplit=1)[-1], ) audit = _audit( tmp_path, - [(_endpoint(tmp_path, "handler"), [site])], - loaded=load_effect_contracts(contracts), + [(endpoint, [unrelated, exact])], + loaded=load_effect_preset(preset), ) - 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" + assert audit.summary.matched_calls == 1 + assert audit.summary.unmatched_calls == 1 + matched = [item for item in audit.occurrences if item.contract_id is not None] + assert [item.contract_id for item in matched] == [contract_id] def test_exact_matching_is_symbol_and_invocation_only(tmp_path: Path) -> None: diff --git a/tests/unit/test_effect_contract_presets.py b/tests/unit/test_effect_contract_presets.py index 43cb55c..bffd7f6 100644 --- a/tests/unit/test_effect_contract_presets.py +++ b/tests/unit/test_effect_contract_presets.py @@ -9,8 +9,12 @@ from fastapi_endpoint_detector.config import AnalysisConfig, Config from fastapi_endpoint_detector.models.effect_contract import ( BUNDLED_EFFECT_PRESETS, + AsyncMode, EffectContractError, + EffectTiming, + InvocationKind, ProvenanceKind, + SelectorKind, load_effect_preset, ) @@ -19,11 +23,12 @@ _EXPECTED_PRESET_HASHES = { - "filesystem-v1": "sha256:5acc35da9d989ccafda0960090efefbbaa52ca5b70894882c24c4bf1355c2b96", - "http-clients-v1": "sha256:ab3d88b368db24f4c6c0879c8104105b09f23997997e63dd232856886bca6e2e", - "mongodb-v1": "sha256:7e0f41e452ac61b7340f02215963e8aa765333988b67d441b7aece9dfa53191c", - "object-storage-v1": "sha256:99707cccf212f530bd2437a3cb154d5ebea775857cc7d65c777771f9771cc6c4", - "redis-v1": "sha256:ce681490563300ce01dec68cd42af26c5fe8e06c7d5d45ae652dfce73c531ca2", + "filesystem-v1": "sha256:8e09b0a197a523b701bd18ce042b72bf8e8d5cc5c0741d18e7c1c61937712c40", + "http-clients-v1": "sha256:2a3e5f3e85d31a6ca4cae5f917d31081ab126ee82fd677d39dccd1040deb4f99", + "message-bus-v1": "sha256:e5b5dca859e0513f331392b4573e85336376277d64a835c4e9a85ea7459b8c2c", + "mongodb-v1": "sha256:1541057fa430ee8ced171b379aa9dab1f4007156fd9b8632784c0ccdfd2f2032", + "object-storage-v1": "sha256:e105008879ac0fdccec5dd03b0eb9a031749539713b617de089d8d82a796683c", + "redis-v1": "sha256:20161274ce0b8d2f2e18933c159aac66a272078dd3944c3980d857e65d85fa86", "sqlalchemy-v1": "sha256:132982ba61f04626df531dc80c71ce5d21c12ec583a932d21c220486785c8d04", } @@ -33,10 +38,28 @@ "io-buffered-write", "io-text-read", "io-text-write", + "os-makedirs", + "os-mkdir", + "os-remove", + "os-rename", + "os-replace", + "os-rmdir", + "os-unlink", + "pathlib-mkdir", "pathlib-read-bytes", "pathlib-read-text", + "pathlib-rename", + "pathlib-replace", + "pathlib-rmdir", + "pathlib-touch", + "pathlib-unlink", "pathlib-write-bytes", "pathlib-write-text", + "shutil-copy", + "shutil-copy2", + "shutil-copyfile", + "shutil-move", + "shutil-rmtree", }, "http-clients-v1": { "aiohttp-session-delete", @@ -60,6 +83,20 @@ "httpx-client-patch", "httpx-client-post", "httpx-client-put", + "httpx-delete", + "httpx-get", + "httpx-head", + "httpx-options", + "httpx-patch", + "httpx-post", + "httpx-put", + "requests-api-delete", + "requests-api-get", + "requests-api-head", + "requests-api-options", + "requests-api-patch", + "requests-api-post", + "requests-api-put", "requests-session-delete", "requests-session-get", "requests-session-head", @@ -68,18 +105,54 @@ "requests-session-post", "requests-session-put", }, + "message-bus-v1": { + "aiokafka-send-and-wait", + "confluent-kafka-produce", + "kafka-python-send", + "kombu-producer-publish", + "pika-basic-publish", + }, "mongodb-v1": { + "motor-delete-many", + "motor-delete-one", + "motor-find-one", + "motor-insert-many", + "motor-insert-one", + "motor-replace-one", + "motor-update-many", + "motor-update-one", + "pymongo-delete-many", "pymongo-delete-one", "pymongo-find-one", + "pymongo-insert-many", "pymongo-insert-one", + "pymongo-replace-one", + "pymongo-update-many", "pymongo-update-one", }, "object-storage-v1": { + "typed-s3-copy-object", + "typed-s3-create-bucket", + "typed-s3-delete-bucket", "typed-s3-delete-object", "typed-s3-get-object", + "typed-s3-head-object", + "typed-s3-list-objects-v2", "typed-s3-put-object", }, - "redis-v1": {"redis-delete", "redis-get", "redis-publish", "redis-set"}, + "redis-v1": { + "redis-delete", + "redis-expire", + "redis-get", + "redis-hdel", + "redis-hget", + "redis-hset", + "redis-incrby", + "redis-lpush", + "redis-publish", + "redis-rpush", + "redis-set", + }, "sqlalchemy-v1": { "sqlalchemy-async-session-add", "sqlalchemy-async-session-add-all", @@ -109,15 +182,19 @@ def test_bundled_effect_presets_are_strict_versioned_snapshots(name: str) -> Non assert loaded.source_path == BUNDLED_EFFECT_PRESETS[name].resolve() expected_version = { - "filesystem-v1": "2.0.0", - "http-clients-v1": "2.0.0", + "filesystem-v1": "3.0.0", + "http-clients-v1": "3.0.0", + "mongodb-v1": "2.0.0", "object-storage-v1": "2.0.0", + "redis-v1": "2.0.0", "sqlalchemy-v1": "3.0.0", }.get(name, "1.0.0") expected_revision = { - "filesystem-v1": "2", - "http-clients-v1": "2", + "filesystem-v1": "3", + "http-clients-v1": "3", + "mongodb-v1": "2", "object-storage-v1": "2", + "redis-v1": "2", "sqlalchemy-v1": "3", }.get(name, "1") assert loaded.document.preset.version == expected_version @@ -137,9 +214,11 @@ def test_presets_never_contain_bare_or_generic_method_symbols() -> None: loaded = load_effect_preset(name) for contract in loaded.document.contracts: parts = contract.symbol.split(".") - assert len(parts) >= 3 + assert len(parts) >= 2 assert contract.symbol not in forbidden - if parts[-1] in forbidden: + if contract.invocation != InvocationKind.FUNCTION: + assert len(parts) >= 3 + if parts[-1] in forbidden and contract.invocation != InvocationKind.FUNCTION: assert len(parts) >= 4 or parts[0] == "_io" assert contract.package is not None assert contract.package.python is not None or ( @@ -147,22 +226,6 @@ 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]] = {} @@ -174,12 +237,79 @@ def test_http_client_preset_declares_exact_methods_for_each_supported_client() - expected = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} assert methods_by_class == { "aiohttp.client.ClientSession": expected, + "httpx": expected, "httpx._client.AsyncClient": expected, "httpx._client.Client": expected, + "requests.api": expected, "requests.sessions.Session": expected, } +def test_dynamic_or_generic_surfaces_are_not_preset_contracts() -> None: + symbols = { + contract.symbol + for name in BUNDLED_EFFECT_PRESETS + for contract in load_effect_preset(name).document.contracts + } + + assert symbols.isdisjoint( + { + "builtins.open", # mode-dependent until a predicate schema exists + "requests.api.request", # method argument is not modeled by schema v3 + "httpx.request", + "aiohttp.client.request", + "boto3.client", + "redis.Redis.get", + "motor.motor_asyncio.AsyncIOMotorCollection.find", + "pymongo.collection.Collection.update_one", + "pymongo.synchronous.collection.Collection.find", + "redis.commands.core.BasicKeyCommands.incr", + } + ) + + +def test_object_storage_abstains_from_false_key_only_object_identity() -> None: + loaded = load_effect_preset("object-storage-v1") + object_contracts = { + contract.id: contract + for contract in loaded.document.contracts + if contract.id + in { + "typed-s3-copy-object", + "typed-s3-delete-object", + "typed-s3-get-object", + "typed-s3-head-object", + "typed-s3-put-object", + } + } + + assert object_contracts + assert all( + contract.resource.kind == SelectorKind.NONE for contract in object_contracts.values() + ) + assert all(contract.resource.name is None for contract in object_contracts.values()) + + +def test_async_presets_declare_only_supported_effect_timing() -> None: + mongodb = load_effect_preset("mongodb-v1") + motor = { + contract.id: contract + for contract in mongodb.document.contracts + if contract.id.startswith("motor-") + } + assert all(contract.behavior.async_mode == AsyncMode.ASYNC for contract in motor.values()) + assert all(contract.behavior.timing == EffectTiming.AWAIT for contract in motor.values()) + + messages = load_effect_preset("message-bus-v1") + aiokafka = next( + contract + for contract in messages.document.contracts + if contract.id == "aiokafka-send-and-wait" + ) + assert aiokafka.behavior.async_mode == AsyncMode.ASYNC + assert aiokafka.behavior.timing == EffectTiming.AWAIT + + def test_sqlalchemy_preset_declares_exact_transaction_and_savepoint_scopes() -> None: loaded = load_effect_preset("sqlalchemy-v1") scopes = { From 94237e803b71ec00ce13f7e63b5f64af396cf75b Mon Sep 17 00:00:00 2001 From: shaggitza Date: Thu, 30 Jul 2026 01:09:19 +0300 Subject: [PATCH 2/4] fix: make exact effect presets conservative --- CHANGELOG.md | 14 +-- docs/effect-contracts.md | 38 +++++--- docs/resource-coupling.md | 10 +- src/fastapi_endpoint_detector/config.py | 2 +- .../models/effect_contract.py | 1 - .../presets/effects_http_clients_v1.yaml | 39 ++------ .../presets/effects_message_bus_v1.yaml | 38 +------- .../presets/effects_redis_v1.yaml | 94 ------------------- .../test_effect_contract_audit_cli.py | 8 +- tests/unit/test_effect_contract_audit.py | 13 +-- tests/unit/test_effect_contract_presets.py | 78 ++++++++------- tests/unit/test_mypy_resolved_call_sites.py | 77 +++++++++++++++ 12 files changed, 179 insertions(+), 233 deletions(-) delete mode 100644 src/fastapi_endpoint_detector/presets/effects_redis_v1.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 563ed75..d5d354b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,12 +25,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - coverage.py dependency ### Added -- **Versioned exact effect presets**: expanded Redis, PyMongo/Motor, filesystem, - requests/httpx/aiohttp, and typed S3 contracts; added a message-bus preset for - exact aiokafka, kafka-python, confluent-kafka, pika, and kombu symbols. - Dynamic method/mode APIs remain deliberately unsupported, and S3 object - operations abstain from Key-only resource identity until composite selectors - are available. +- **Versioned exact effect presets**: expanded resolver-attested PyMongo/Motor, + filesystem, typed requests/httpx/aiohttp, and typed S3 contracts. Added a + message-bus preset containing only typed confluent-kafka `Producer.produce`, + with staged queue timing. Untyped message clients and Redis's timing-ambiguous + shared sync/async owners are omitted; receiver HTTP clients and S3 object + operations abstain from incomplete URL or Key-only resource identities. + This preset tranche does not include real-world evaluation, and Issue #97 + remains open for unsupported families and evaluation. - **Mypy integration**: Added mypy's build API for type-aware dependency analysis - New `_get_module_dependencies_via_mypy()` method for full dependency graph extraction - New `_module_to_file_path()` helper for module resolution diff --git a/docs/effect-contracts.md b/docs/effect-contracts.md index f9c2128..32279c3 100644 --- a/docs/effect-contracts.md +++ b/docs/effect-contracts.md @@ -117,9 +117,8 @@ differ from the analyzed snapshot. ## Package-owned presets -Seven conservative, independently versioned exact-symbol presets are bundled: +Six conservative, independently versioned exact-symbol presets are bundled: -- `redis-v1` - `mongodb-v1` - `filesystem-v1` - `http-clients-v1` @@ -130,14 +129,14 @@ Seven conservative, independently versioned exact-symbol presets are bundled: Validate one without copying package data: ```bash -fastapi-endpoint-detector validate-effect-contracts --preset redis-v1 --format json +fastapi-endpoint-detector validate-effect-contracts --preset filesystem-v1 --format json ``` Select exactly one preset in configuration: ```yaml analysis: - effect_preset: redis-v1 + effect_preset: filesystem-v1 ``` `effect_preset` and `effect_contracts` are mutually exclusive. Presets preserve the @@ -151,19 +150,28 @@ escaped handles, control flow, aliases, captured handles, composition, dynamic arguments, and unsupported factories fail closed. Dynamic boto3 clients without `mypy-boto3-s3`, generic HTTP `request`/`send`, -mode-specific `open()` classification, deferred cursor consumption, Redis -pipelines, and bare method names are intentionally absent. S3 object operations -are exact effects but do not claim a Key-only resource identity: `(Bucket, Key)` -requires a future composite selector. Message-publisher contracts likewise omit -an identity when routing requires multiple values that the selector schema cannot -represent. +mode-specific `open()` classification, deferred cursor consumption, Redis, and +bare method names are intentionally absent. Redis sync and async clients share +mypy declaration owners, but their immediate-versus-await timing cannot be +expressed by the current exact contract schema. S3 object operations are exact +effects but do not claim a Key-only resource identity: `(Bucket, Key)` requires a +future composite selector. Untyped aiokafka, kafka-python, pika, and kombu rows +are also omitted. The message-bus preset contains only typed confluent-kafka +`Producer.produce`, conservatively declared as a staged queue operation. Each family has an independent identity and semantic hash. Filesystem and HTTP -contracts are version `3.0.0`; Redis, MongoDB/Motor, and typed S3 contracts are -version `2.0.0`; message bus starts at `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 -known exclusions are frozen in `benchmarks/results/effect-presets-v1/README.md`. +contracts are version `3.0.0`; MongoDB/Motor and typed S3 contracts are version +`2.0.0`; message bus starts at `1.0.0`. Requests support starts at its resolver- +typed `2.34` release. HTTP receiver-client contracts abstain from URL resource +identity because constructor `base_url` can make the call argument incomplete; +top-level requests/httpx convenience calls retain finite URL evidence. aiohttp +request timing is conservatively `await`. HTTP contracts preserve `GET`, `POST`, +`PUT`, `PATCH`, `DELETE`, `HEAD`, or `OPTIONS` as structured semantics. The v1 +changelog and known exclusions are frozen in +`benchmarks/results/effect-presets-v1/README.md`. That historical artifact is not +real-world evaluation and is not modified by this tranche. Issue #97 remains open +for unsupported package families, composite/base-URL identities, applicability +enforcement, version-matrix expansion, and controlled or real-world evaluation. Multiple presets are not silently merged because the current provenance model has one authoritative contract source per analysis. diff --git a/docs/resource-coupling.md b/docs/resource-coupling.md index f75fb5d..bdf34ce 100644 --- a/docs/resource-coupling.md +++ b/docs/resource-coupling.md @@ -11,7 +11,7 @@ Configure an effect source and a separate namespace-qualified coupling document: ```yaml analysis: - effect_preset: redis-v1 + effect_preset: filesystem-v1 resource_coupling: .resource-coupling.yaml ``` @@ -19,10 +19,10 @@ analysis: schema_version: 1 mode: report_only groups: - - id: orders-cache - resource_space: production-orders-redis-db0 - producer_contract_ids: [redis-delete, redis-set] - consumer_contract_ids: [redis-get] + - id: generated-files + resource_space: application-data-directory + producer_contract_ids: [pathlib-write-text] + consumer_contract_ids: [pathlib-read-text] limits: max_endpoint_links_per_resource: 32 max_edges: 1000 diff --git a/src/fastapi_endpoint_detector/config.py b/src/fastapi_endpoint_detector/config.py index e800014..9bafa2a 100644 --- a/src/fastapi_endpoint_detector/config.py +++ b/src/fastapi_endpoint_detector/config.py @@ -82,9 +82,9 @@ class AnalysisConfig(BaseModel): Literal[ "filesystem-v1", "http-clients-v1", + "message-bus-v1", "mongodb-v1", "object-storage-v1", - "redis-v1", "sqlalchemy-v1", ] | None diff --git a/src/fastapi_endpoint_detector/models/effect_contract.py b/src/fastapi_endpoint_detector/models/effect_contract.py index e50d1c4..4592f09 100644 --- a/src/fastapi_endpoint_detector/models/effect_contract.py +++ b/src/fastapi_endpoint_detector/models/effect_contract.py @@ -33,7 +33,6 @@ "object-storage-v1": ( Path(__file__).parent.parent / "presets" / "effects_object_storage_v1.yaml" ), - "redis-v1": Path(__file__).parent.parent / "presets" / "effects_redis_v1.yaml", "sqlalchemy-v1": Path(__file__).parent.parent / "presets" / "effects_sqlalchemy_v1.yaml", } diff --git a/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml index 9556eb5..c44a1ac 100644 --- a/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_http_clients_v1.yaml @@ -14,7 +14,7 @@ contracts: channel: outbound_http resource: {kind: argument, index: 0} http_method: GET - package: &requests {distribution: requests, version: ">=2.31,<3"} + package: &requests {distribution: requests, version: ">=2.34,<3"} - id: requests-session-post symbol: requests.sessions.Session.post invocation: instance_method @@ -69,7 +69,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: GET package: &httpx {distribution: httpx, version: ">=0.27,<1"} - id: httpx-client-post @@ -77,7 +76,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: POST package: *httpx - id: httpx-client-put @@ -85,7 +83,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PUT package: *httpx - id: httpx-client-patch @@ -93,7 +90,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PATCH package: *httpx - id: httpx-client-delete @@ -101,7 +97,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: DELETE package: *httpx - id: httpx-client-head @@ -109,7 +104,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: HEAD package: *httpx - id: httpx-client-options @@ -117,7 +111,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: OPTIONS package: *httpx @@ -126,7 +119,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: GET behavior: &httpx_async {async_mode: async, timing: await} package: *httpx @@ -135,7 +127,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: POST behavior: *httpx_async package: *httpx @@ -144,7 +135,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PUT behavior: *httpx_async package: *httpx @@ -153,7 +143,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PATCH behavior: *httpx_async package: *httpx @@ -162,7 +151,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: DELETE behavior: *httpx_async package: *httpx @@ -171,7 +159,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: HEAD behavior: *httpx_async package: *httpx @@ -180,7 +167,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: OPTIONS behavior: *httpx_async package: *httpx @@ -190,16 +176,14 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: GET - behavior: &aiohttp_async {async_mode: async, timing: context_enter} + behavior: &aiohttp_async {async_mode: async, timing: await} package: &aiohttp {distribution: aiohttp, version: ">=3.9,<4"} - id: aiohttp-session-post symbol: aiohttp.client.ClientSession.post invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: POST behavior: *aiohttp_async package: *aiohttp @@ -208,7 +192,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PUT behavior: *aiohttp_async package: *aiohttp @@ -217,7 +200,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: PATCH behavior: *aiohttp_async package: *aiohttp @@ -226,7 +208,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: DELETE behavior: *aiohttp_async package: *aiohttp @@ -235,7 +216,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: HEAD behavior: *aiohttp_async package: *aiohttp @@ -244,7 +224,6 @@ contracts: invocation: instance_method operation: request channel: outbound_http - resource: {kind: argument, index: 0} http_method: OPTIONS behavior: *aiohttp_async package: *aiohttp @@ -310,7 +289,7 @@ contracts: package: *requests - id: httpx-get - symbol: httpx.get + symbol: httpx._api.get invocation: function operation: request channel: outbound_http @@ -318,7 +297,7 @@ contracts: http_method: GET package: *httpx - id: httpx-post - symbol: httpx.post + symbol: httpx._api.post invocation: function operation: request channel: outbound_http @@ -326,7 +305,7 @@ contracts: http_method: POST package: *httpx - id: httpx-put - symbol: httpx.put + symbol: httpx._api.put invocation: function operation: request channel: outbound_http @@ -334,7 +313,7 @@ contracts: http_method: PUT package: *httpx - id: httpx-patch - symbol: httpx.patch + symbol: httpx._api.patch invocation: function operation: request channel: outbound_http @@ -342,7 +321,7 @@ contracts: http_method: PATCH package: *httpx - id: httpx-delete - symbol: httpx.delete + symbol: httpx._api.delete invocation: function operation: request channel: outbound_http @@ -350,7 +329,7 @@ contracts: http_method: DELETE package: *httpx - id: httpx-head - symbol: httpx.head + symbol: httpx._api.head invocation: function operation: request channel: outbound_http @@ -358,7 +337,7 @@ contracts: http_method: HEAD package: *httpx - id: httpx-options - symbol: httpx.options + symbol: httpx._api.options invocation: function operation: request channel: outbound_http diff --git a/src/fastapi_endpoint_detector/presets/effects_message_bus_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_message_bus_v1.yaml index c6f2fdf..6a4ee1e 100644 --- a/src/fastapi_endpoint_detector/presets/effects_message_bus_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_message_bus_v1.yaml @@ -7,42 +7,14 @@ preset: source: fastapi-endpoint-detector/effects_message_bus_v1.yaml revision: "1" contracts: - - id: aiokafka-send-and-wait - symbol: aiokafka.producer.producer.AIOKafkaProducer.send_and_wait - invocation: instance_method - operation: publish - channel: message_bus - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - behavior: {async_mode: async, timing: await} - package: {distribution: aiokafka, version: ">=0.10,<1"} - - id: kafka-python-send - symbol: kafka.producer.kafka.KafkaProducer.send - invocation: instance_method - operation: publish - channel: message_bus - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - package: {distribution: kafka-python, version: ">=2,<3"} + # produce() synchronously queues a record for later delivery. Staged timing + # records that boundary without claiming immediate broker publication. - id: confluent-kafka-produce - symbol: confluent_kafka.Producer.produce + symbol: confluent_kafka.cimpl.Producer.produce invocation: instance_method operation: publish channel: message_bus resource: {kind: argument, index: 0} value: {kind: argument, index: 1} - package: {distribution: confluent-kafka, version: ">=2.3,<3"} - - id: pika-basic-publish - symbol: pika.channel.Channel.basic_publish - invocation: instance_method - operation: publish - channel: message_bus - value: {kind: argument, index: 2} - package: {distribution: pika, version: ">=1.3,<2"} - - id: kombu-producer-publish - symbol: kombu.messaging.Producer.publish - invocation: instance_method - operation: publish - channel: message_bus - value: {kind: argument, index: 0} - package: {distribution: kombu, version: ">=5.3,<6"} + behavior: {async_mode: sync, timing: staged} + package: {distribution: confluent-kafka, version: ">=2.13,<3"} diff --git a/src/fastapi_endpoint_detector/presets/effects_redis_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_redis_v1.yaml deleted file mode 100644 index dbcf186..0000000 --- a/src/fastapi_endpoint_detector/presets/effects_redis_v1.yaml +++ /dev/null @@ -1,94 +0,0 @@ -schema_version: 1 -preset: - id: redis-py-effects - version: 2.0.0 - provenance: - kind: preset - source: fastapi-endpoint-detector/effects_redis_v1.yaml - revision: "2" -contracts: - - id: redis-get - symbol: redis.commands.core.BasicKeyCommands.get - invocation: instance_method - operation: read - channel: redis - resource: {kind: argument, index: 0} - package: &redis {distribution: redis, version: ">=5,<7"} - - id: redis-set - symbol: redis.commands.core.BasicKeyCommands.set - invocation: instance_method - operation: write - channel: redis - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - package: *redis - - id: redis-delete - symbol: redis.commands.core.BasicKeyCommands.delete - invocation: instance_method - operation: delete - channel: redis - resource: {kind: argument, index: 0} - package: *redis - - id: redis-expire - symbol: redis.commands.core.BasicKeyCommands.expire - invocation: instance_method - operation: update - channel: redis - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - package: *redis - - id: redis-incrby - symbol: redis.commands.core.BasicKeyCommands.incrby - invocation: instance_method - operation: update - channel: redis - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - package: *redis - - id: redis-hget - symbol: redis.commands.core.HashCommands.hget - invocation: instance_method - operation: read - channel: redis - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - package: *redis - - id: redis-hset - symbol: redis.commands.core.HashCommands.hset - invocation: instance_method - operation: update - channel: redis - resource: {kind: argument, index: 0} - package: *redis - - id: redis-hdel - symbol: redis.commands.core.HashCommands.hdel - invocation: instance_method - operation: update - channel: redis - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - package: *redis - - id: redis-lpush - symbol: redis.commands.core.ListCommands.lpush - invocation: instance_method - operation: append - channel: redis - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - package: *redis - - id: redis-rpush - symbol: redis.commands.core.ListCommands.rpush - invocation: instance_method - operation: append - channel: redis - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - package: *redis - - id: redis-publish - symbol: redis.commands.core.PubSubCommands.publish - invocation: instance_method - operation: publish - channel: message_bus - resource: {kind: argument, index: 0} - value: {kind: argument, index: 1} - package: *redis diff --git a/tests/integration/test_effect_contract_audit_cli.py b/tests/integration/test_effect_contract_audit_cli.py index 0809b1d..7c0e53f 100644 --- a/tests/integration/test_effect_contract_audit_cli.py +++ b/tests/integration/test_effect_contract_audit_cli.py @@ -61,21 +61,21 @@ def test_validate_effect_preset_and_reject_dual_cli_sources(tmp_path: Path) -> N valid = runner.invoke( cli, - ["validate-effect-contracts", "--preset", "redis-v1", "--format", "json"], + ["validate-effect-contracts", "--preset", "filesystem-v1", "--format", "json"], ) conflict = runner.invoke( cli, [ "validate-effect-contracts", "--preset", - "redis-v1", + "filesystem-v1", "--contracts", str(contracts), ], ) assert valid.exit_code == 0, valid.output - assert json.loads(valid.output)["preset"]["id"] == "redis-py-effects" + assert json.loads(valid.output)["preset"]["id"] == "stdlib-filesystem-effects" assert conflict.exit_code != 0 assert "exactly one" in conflict.output @@ -228,7 +228,7 @@ def test_audit_loads_configured_effect_preset(tmp_path: Path) -> None: assert result.exit_code == 0, result.output data = json.loads(result.output) - assert data["summary"]["contracts"] == 8 + assert data["summary"]["contracts"] == 26 assert data["provenance"]["preset_hash"].startswith("sha256:") diff --git a/tests/unit/test_effect_contract_audit.py b/tests/unit/test_effect_contract_audit.py index a43bde5..b76e04b 100644 --- a/tests/unit/test_effect_contract_audit.py +++ b/tests/unit/test_effect_contract_audit.py @@ -137,13 +137,6 @@ def _audit( @pytest.mark.parametrize( ("preset", "symbol", "invocation", "contract_id", "negative_symbol"), [ - ( - "redis-v1", - "redis.commands.core.HashCommands.hset", - InvocationKind.INSTANCE_METHOD, - "redis-hset", - "project.Cache.hset", - ), ( "mongodb-v1", "motor.core.AgnosticCollection.update_one", @@ -174,10 +167,10 @@ def _audit( ), ( "message-bus-v1", - "aiokafka.producer.producer.AIOKafkaProducer.send_and_wait", + "confluent_kafka.cimpl.Producer.produce", InvocationKind.INSTANCE_METHOD, - "aiokafka-send-and-wait", - "project.Producer.send_and_wait", + "confluent-kafka-produce", + "confluent_kafka.Producer.produce", ), ], ) diff --git a/tests/unit/test_effect_contract_presets.py b/tests/unit/test_effect_contract_presets.py index bffd7f6..c5929fc 100644 --- a/tests/unit/test_effect_contract_presets.py +++ b/tests/unit/test_effect_contract_presets.py @@ -24,11 +24,10 @@ _EXPECTED_PRESET_HASHES = { "filesystem-v1": "sha256:8e09b0a197a523b701bd18ce042b72bf8e8d5cc5c0741d18e7c1c61937712c40", - "http-clients-v1": "sha256:2a3e5f3e85d31a6ca4cae5f917d31081ab126ee82fd677d39dccd1040deb4f99", - "message-bus-v1": "sha256:e5b5dca859e0513f331392b4573e85336376277d64a835c4e9a85ea7459b8c2c", + "http-clients-v1": "sha256:c1f6bcb56e06525f20e2eac5a68c9bc5812c281c054209fb6feb3c7df63cc890", + "message-bus-v1": "sha256:05c2d745da13fc39984d20b6cd260221eeac40ba7049a492cef6979488ac81a1", "mongodb-v1": "sha256:1541057fa430ee8ced171b379aa9dab1f4007156fd9b8632784c0ccdfd2f2032", "object-storage-v1": "sha256:e105008879ac0fdccec5dd03b0eb9a031749539713b617de089d8d82a796683c", - "redis-v1": "sha256:20161274ce0b8d2f2e18933c159aac66a272078dd3944c3980d857e65d85fa86", "sqlalchemy-v1": "sha256:132982ba61f04626df531dc80c71ce5d21c12ec583a932d21c220486785c8d04", } @@ -105,13 +104,7 @@ "requests-session-post", "requests-session-put", }, - "message-bus-v1": { - "aiokafka-send-and-wait", - "confluent-kafka-produce", - "kafka-python-send", - "kombu-producer-publish", - "pika-basic-publish", - }, + "message-bus-v1": {"confluent-kafka-produce"}, "mongodb-v1": { "motor-delete-many", "motor-delete-one", @@ -140,19 +133,6 @@ "typed-s3-list-objects-v2", "typed-s3-put-object", }, - "redis-v1": { - "redis-delete", - "redis-expire", - "redis-get", - "redis-hdel", - "redis-hget", - "redis-hset", - "redis-incrby", - "redis-lpush", - "redis-publish", - "redis-rpush", - "redis-set", - }, "sqlalchemy-v1": { "sqlalchemy-async-session-add", "sqlalchemy-async-session-add-all", @@ -186,7 +166,6 @@ def test_bundled_effect_presets_are_strict_versioned_snapshots(name: str) -> Non "http-clients-v1": "3.0.0", "mongodb-v1": "2.0.0", "object-storage-v1": "2.0.0", - "redis-v1": "2.0.0", "sqlalchemy-v1": "3.0.0", }.get(name, "1.0.0") expected_revision = { @@ -194,7 +173,6 @@ def test_bundled_effect_presets_are_strict_versioned_snapshots(name: str) -> Non "http-clients-v1": "3", "mongodb-v1": "2", "object-storage-v1": "2", - "redis-v1": "2", "sqlalchemy-v1": "3", }.get(name, "1") assert loaded.document.preset.version == expected_version @@ -237,7 +215,7 @@ def test_http_client_preset_declares_exact_methods_for_each_supported_client() - expected = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} assert methods_by_class == { "aiohttp.client.ClientSession": expected, - "httpx": expected, + "httpx._api": expected, "httpx._client.AsyncClient": expected, "httpx._client.Client": expected, "requests.api": expected, @@ -257,17 +235,40 @@ def test_dynamic_or_generic_surfaces_are_not_preset_contracts() -> None: "builtins.open", # mode-dependent until a predicate schema exists "requests.api.request", # method argument is not modeled by schema v3 "httpx.request", + "httpx.get", # public spelling resolves to the declaration owner below the facade "aiohttp.client.request", "boto3.client", + "redis.commands.core.BasicKeyCommands.get", # shared sync/async owner "redis.Redis.get", "motor.motor_asyncio.AsyncIOMotorCollection.find", "pymongo.collection.Collection.update_one", "pymongo.synchronous.collection.Collection.find", "redis.commands.core.BasicKeyCommands.incr", + "aiokafka.producer.producer.AIOKafkaProducer.send_and_wait", + "kafka.producer.kafka.KafkaProducer.send", + "pika.channel.Channel.basic_publish", + "kombu.messaging.Producer.publish", } ) +def test_receiver_http_clients_abstain_from_incomplete_url_identity() -> None: + loaded = load_effect_preset("http-clients-v1") + contracts = {contract.id: contract for contract in loaded.document.contracts} + + receiver_ids = { + contract_id + for contract_id in contracts + if contract_id.startswith(("httpx-client-", "httpx-async-client-", "aiohttp-session-")) + } + assert receiver_ids + assert all(contracts[item].resource.kind == SelectorKind.NONE for item in receiver_ids) + assert contracts["httpx-get"].resource.kind == SelectorKind.ARGUMENT + assert contracts["requests-api-get"].resource.kind == SelectorKind.ARGUMENT + assert contracts["requests-api-get"].package is not None + assert contracts["requests-api-get"].package.version == ">=2.34,<3" + + def test_object_storage_abstains_from_false_key_only_object_identity() -> None: loaded = load_effect_preset("object-storage-v1") object_contracts = { @@ -300,14 +301,23 @@ def test_async_presets_declare_only_supported_effect_timing() -> None: assert all(contract.behavior.async_mode == AsyncMode.ASYNC for contract in motor.values()) assert all(contract.behavior.timing == EffectTiming.AWAIT for contract in motor.values()) + http = load_effect_preset("http-clients-v1") + aiohttp = [ + contract for contract in http.document.contracts if contract.id.startswith("aiohttp-") + ] + assert aiohttp + assert all(contract.behavior.async_mode == AsyncMode.ASYNC for contract in aiohttp) + assert all(contract.behavior.timing == EffectTiming.AWAIT for contract in aiohttp) + messages = load_effect_preset("message-bus-v1") - aiokafka = next( - contract - for contract in messages.document.contracts - if contract.id == "aiokafka-send-and-wait" - ) - assert aiokafka.behavior.async_mode == AsyncMode.ASYNC - assert aiokafka.behavior.timing == EffectTiming.AWAIT + assert len(messages.document.contracts) == 1 + confluent = messages.document.contracts[0] + assert confluent.id == "confluent-kafka-produce" + assert confluent.symbol == "confluent_kafka.cimpl.Producer.produce" + assert confluent.behavior.async_mode == AsyncMode.SYNC + assert confluent.behavior.timing == EffectTiming.STAGED + assert confluent.package is not None + assert confluent.package.version == ">=2.13,<3" def test_sqlalchemy_preset_declares_exact_transaction_and_savepoint_scopes() -> None: @@ -354,7 +364,7 @@ def test_effect_preset_and_user_document_are_mutually_exclusive(tmp_path: Path) with pytest.raises(ValueError, match="mutually exclusive"): AnalysisConfig( effect_contracts=tmp_path / "effects.yaml", - effect_preset="redis-v1", + effect_preset="filesystem-v1", ) diff --git a/tests/unit/test_mypy_resolved_call_sites.py b/tests/unit/test_mypy_resolved_call_sites.py index 030fbbf..b34bf6a 100644 --- a/tests/unit/test_mypy_resolved_call_sites.py +++ b/tests/unit/test_mypy_resolved_call_sites.py @@ -2,8 +2,11 @@ import hashlib import json +from importlib.metadata import PackageNotFoundError, version from pathlib import Path +import pytest + from fastapi_endpoint_detector.analyzer.mypy_analyzer import ( EndpointDependencies, MypyAnalyzer, @@ -38,6 +41,80 @@ def _site_by_spelling(sites: list[ResolvedCallSite], spelling: str) -> list[Reso return [site for site in sites if site.source_spelling == spelling] +def _require_distribution(distribution: str, minimum: tuple[int, int]) -> None: + try: + installed = version(distribution) + except PackageNotFoundError: + pytest.skip(f"resolver fixture requires {distribution}") + numeric = tuple(int(part) for part in installed.split(".")[:2]) + if numeric < minimum: + pytest.skip(f"resolver fixture requires {distribution}>={minimum[0]}.{minimum[1]}") + + +def test_httpx_public_imports_resolve_to_declaration_owner(tmp_path: Path) -> None: + _require_distribution("httpx", (0, 27)) + main = tmp_path / "main.py" + main.write_text( + "import httpx\n" + "from httpx import get as public_get\n\n" + "def handler() -> None:\n" + " httpx.get('https://example.test/qualified')\n" + " public_get('https://example.test/imported')\n", + encoding="utf-8", + ) + + sites = MypyAnalyzer(tmp_path).analyze_endpoint(_endpoint(main, line=4)).resolved_call_sites + calls = [*_site_by_spelling(sites, "httpx.get"), *_site_by_spelling(sites, "public_get")] + + assert len(calls) == 2 + assert all(site.status == CallResolutionStatus.EXACT for site in calls) + assert all(site.invocation == InvocationKind.FUNCTION for site in calls) + assert {site.canonical_symbol for site in calls} == {"httpx._api.get"} + assert all(site.canonical_symbol != "httpx.get" for site in calls) + + +def test_requests_public_imports_resolve_to_typed_declaration_owner(tmp_path: Path) -> None: + _require_distribution("requests", (2, 34)) + main = tmp_path / "main.py" + main.write_text( + "import requests\n" + "from requests import post as public_post\n\n" + "def handler() -> None:\n" + " requests.post('https://example.test/qualified')\n" + " public_post('https://example.test/imported')\n", + encoding="utf-8", + ) + + sites = MypyAnalyzer(tmp_path).analyze_endpoint(_endpoint(main, line=4)).resolved_call_sites + calls = [*_site_by_spelling(sites, "requests.post"), *_site_by_spelling(sites, "public_post")] + + assert len(calls) == 2 + assert all(site.status == CallResolutionStatus.EXACT for site in calls) + assert all(site.invocation == InvocationKind.FUNCTION for site in calls) + assert {site.canonical_symbol for site in calls} == {"requests.api.post"} + assert all(site.canonical_symbol != "requests.post" for site in calls) + + +def test_confluent_public_producer_resolves_to_cimpl_owner(tmp_path: Path) -> None: + _require_distribution("confluent-kafka", (2, 13)) + main = tmp_path / "main.py" + main.write_text( + "from confluent_kafka import Producer\n\n" + "def handler(producer: Producer) -> None:\n" + " producer.produce('orders', b'value')\n", + encoding="utf-8", + ) + + sites = MypyAnalyzer(tmp_path).analyze_endpoint(_endpoint(main, line=3)).resolved_call_sites + produce = _site_by_spelling(sites, "producer.produce") + + assert len(produce) == 1 + assert produce[0].status == CallResolutionStatus.EXACT + assert produce[0].invocation == InvocationKind.INSTANCE_METHOD + assert produce[0].canonical_symbol == "confluent_kafka.cimpl.Producer.produce" + assert produce[0].canonical_symbol != "confluent_kafka.Producer.produce" + + def test_captures_exact_functions_constructors_and_method_kinds(tmp_path: Path) -> None: (tmp_path / "helpers.py").write_text( "def emit() -> int:\n return 1\n", From cb48e3a8180572f717873fc0050dcfba9704ec0f Mon Sep 17 00:00:00 2001 From: shaggitza Date: Thu, 30 Jul 2026 01:42:35 +0300 Subject: [PATCH 3/4] test: preserve composite resources across preset expansion --- docs/effect-contracts.md | 17 +++-- .../presets/effects_object_storage_v1.yaml | 23 +++++-- tests/unit/test_effect_contract_audit.py | 66 +++++++++++++++++++ tests/unit/test_effect_contract_presets.py | 47 +++++++------ 4 files changed, 123 insertions(+), 30 deletions(-) diff --git a/docs/effect-contracts.md b/docs/effect-contracts.md index 32279c3..96ff975 100644 --- a/docs/effect-contracts.md +++ b/docs/effect-contracts.md @@ -153,11 +153,13 @@ Dynamic boto3 clients without `mypy-boto3-s3`, generic HTTP `request`/`send`, mode-specific `open()` classification, deferred cursor consumption, Redis, and bare method names are intentionally absent. Redis sync and async clients share mypy declaration owners, but their immediate-versus-await timing cannot be -expressed by the current exact contract schema. S3 object operations are exact -effects but do not claim a Key-only resource identity: `(Bucket, Key)` requires a -future composite selector. Untyped aiokafka, kafka-python, pika, and kombu rows -are also omitted. The message-bus preset contains only typed confluent-kafka -`Producer.produce`, conservatively declared as a staged queue operation. +expressed by the current exact contract schema. Direct typed-S3 get, put, and +delete operations use the ordered schema-v4 `(Bucket, Key)` composite identity; +copy-source and other compound identities continue to abstain when every +component cannot be represented exactly. Untyped aiokafka, kafka-python, pika, +and kombu rows are also omitted. The message-bus preset contains only typed +confluent-kafka `Producer.produce`, conservatively declared as a staged queue +operation. Each family has an independent identity and semantic hash. Filesystem and HTTP contracts are version `3.0.0`; MongoDB/Motor and typed S3 contracts are version @@ -170,8 +172,9 @@ request timing is conservatively `await`. HTTP contracts preserve `GET`, `POST`, changelog and known exclusions are frozen in `benchmarks/results/effect-presets-v1/README.md`. That historical artifact is not real-world evaluation and is not modified by this tranche. Issue #97 remains open -for unsupported package families, composite/base-URL identities, applicability -enforcement, version-matrix expansion, and controlled or real-world evaluation. +for unsupported package families, additional compound/base-URL identities, +applicability enforcement, version-matrix expansion, and controlled or real-world +evaluation. 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/presets/effects_object_storage_v1.yaml b/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml index f7f1236..b3377a1 100644 --- a/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml +++ b/src/fastapi_endpoint_detector/presets/effects_object_storage_v1.yaml @@ -1,4 +1,4 @@ -schema_version: 1 +schema_version: 4 preset: id: typed-s3-effects version: 2.0.0 @@ -7,14 +7,19 @@ preset: source: fastapi-endpoint-detector/effects_object_storage_v1.yaml revision: "2" contracts: - # Object identity requires the pair (Bucket, Key), which schema v1 cannot - # encode. These exact operation contracts therefore abstain from resource - # identity instead of emitting a false Key-only coupling. + # Direct object operations use the ordered (Bucket, Key) composite identity. + # CopySource and other compound identities remain unavailable until each + # component can be represented without guessing. - id: typed-s3-get-object symbol: mypy_boto3_s3.client.S3Client.get_object invocation: instance_method operation: read channel: object_storage + resource: + kind: composite + components: + - {kind: keyword, name: Bucket} + - {kind: keyword, name: Key} package: &s3 {distribution: mypy-boto3-s3, version: ">=1.34,<2"} - id: typed-s3-head-object symbol: mypy_boto3_s3.client.S3Client.head_object @@ -27,6 +32,11 @@ contracts: invocation: instance_method operation: write channel: object_storage + resource: + kind: composite + components: + - {kind: keyword, name: Bucket} + - {kind: keyword, name: Key} value: {kind: keyword, name: Body} package: *s3 - id: typed-s3-copy-object @@ -41,6 +51,11 @@ contracts: invocation: instance_method operation: delete channel: object_storage + resource: + kind: composite + components: + - {kind: keyword, name: Bucket} + - {kind: keyword, name: Key} package: *s3 - id: typed-s3-list-objects-v2 symbol: mypy_boto3_s3.client.S3Client.list_objects_v2 diff --git a/tests/unit/test_effect_contract_audit.py b/tests/unit/test_effect_contract_audit.py index b76e04b..787ec7a 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, @@ -134,6 +135,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" + + @pytest.mark.parametrize( ("preset", "symbol", "invocation", "contract_id", "negative_symbol"), [ diff --git a/tests/unit/test_effect_contract_presets.py b/tests/unit/test_effect_contract_presets.py index c5929fc..68c5f12 100644 --- a/tests/unit/test_effect_contract_presets.py +++ b/tests/unit/test_effect_contract_presets.py @@ -27,7 +27,7 @@ "http-clients-v1": "sha256:c1f6bcb56e06525f20e2eac5a68c9bc5812c281c054209fb6feb3c7df63cc890", "message-bus-v1": "sha256:05c2d745da13fc39984d20b6cd260221eeac40ba7049a492cef6979488ac81a1", "mongodb-v1": "sha256:1541057fa430ee8ced171b379aa9dab1f4007156fd9b8632784c0ccdfd2f2032", - "object-storage-v1": "sha256:e105008879ac0fdccec5dd03b0eb9a031749539713b617de089d8d82a796683c", + "object-storage-v1": "sha256:6f33763d1502349481bca4b470f991e4c5a49d6a9b82e34df74a0f4b174161f9", "sqlalchemy-v1": "sha256:132982ba61f04626df531dc80c71ce5d21c12ec583a932d21c220486785c8d04", } @@ -269,26 +269,35 @@ def test_receiver_http_clients_abstain_from_incomplete_url_identity() -> None: assert contracts["requests-api-get"].package.version == ">=2.34,<3" -def test_object_storage_abstains_from_false_key_only_object_identity() -> None: +def test_object_storage_uses_only_complete_resource_identities() -> None: loaded = load_effect_preset("object-storage-v1") - object_contracts = { - contract.id: contract - for contract in loaded.document.contracts - if contract.id - in { - "typed-s3-copy-object", - "typed-s3-delete-object", - "typed-s3-get-object", - "typed-s3-head-object", - "typed-s3-put-object", - } - } + contracts = {contract.id: contract for contract in loaded.document.contracts} - assert object_contracts - assert all( - contract.resource.kind == SelectorKind.NONE for contract in object_contracts.values() - ) - assert all(contract.resource.name is None for contract in object_contracts.values()) + for contract_id in ( + "typed-s3-delete-object", + "typed-s3-get-object", + "typed-s3-put-object", + ): + resource = contracts[contract_id].resource + assert resource.kind == "composite" + assert [(item.kind, item.name) for item in resource.components] == [ + (SelectorKind.KEYWORD, "Bucket"), + (SelectorKind.KEYWORD, "Key"), + ] + + for contract_id in ("typed-s3-copy-object", "typed-s3-head-object"): + resource = contracts[contract_id].resource + assert resource.kind == SelectorKind.NONE + assert resource.name is None + + for contract_id in ( + "typed-s3-create-bucket", + "typed-s3-delete-bucket", + "typed-s3-list-objects-v2", + ): + resource = contracts[contract_id].resource + assert resource.kind == SelectorKind.KEYWORD + assert resource.name == "Bucket" def test_async_presets_declare_only_supported_effect_timing() -> None: From 62ba1256d0e1d880f4801f1dfbeea5efbc5637b6 Mon Sep 17 00:00:00 2001 From: shaggitza Date: Thu, 30 Jul 2026 02:02:39 +0300 Subject: [PATCH 4/4] docs: preserve schema v4 preset guidance --- docs/effect-contracts.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/effect-contracts.md b/docs/effect-contracts.md index 96ff975..6501770 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`