dir-core 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. {dir_core-0.4.0 → dir_core-0.4.2}/PKG-INFO +1 -1
  2. {dir_core-0.4.0 → dir_core-0.4.2}/pyproject.toml +1 -1
  3. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/__init__.py +2 -1
  4. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/contract_projection.py +50 -0
  5. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/invariants.py +18 -4
  6. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/models.py +1 -0
  7. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core.egg-info/PKG-INFO +1 -1
  8. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core.egg-info/SOURCES.txt +2 -0
  9. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_contract_studio.py +151 -10
  10. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_contract_tool.py +174 -110
  11. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_governance_validation.py +23 -1
  12. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_invariants.py +46 -0
  13. dir_core-0.4.2/tests/test_responsibility_adapter.py +69 -0
  14. dir_core-0.4.2/tests/test_samples_isolation.py +39 -0
  15. {dir_core-0.4.0 → dir_core-0.4.2}/LICENSE +0 -0
  16. {dir_core-0.4.0 → dir_core-0.4.2}/README.md +0 -0
  17. {dir_core-0.4.0 → dir_core-0.4.2}/setup.cfg +0 -0
  18. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/agent_registry.py +0 -0
  19. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/amendments.py +0 -0
  20. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/arbitration.py +0 -0
  21. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/context_store.py +0 -0
  22. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/data_types.py +0 -0
  23. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/dfid.py +0 -0
  24. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/dim.py +0 -0
  25. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/escalation.py +0 -0
  26. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/event_bus.py +0 -0
  27. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/idempotency.py +0 -0
  28. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/intent_retry.py +0 -0
  29. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/invariant_compile.py +0 -0
  30. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/jit.py +0 -0
  31. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/ledger.py +0 -0
  32. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/lifecycle.py +0 -0
  33. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/pci.py +0 -0
  34. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/resource_lock.py +0 -0
  35. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/runtime.py +0 -0
  36. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/saga.py +0 -0
  37. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/storage/__init__.py +0 -0
  38. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/storage/base.py +0 -0
  39. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/storage/json_util.py +0 -0
  40. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/storage/memory.py +0 -0
  41. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/storage/schema.sql +0 -0
  42. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/storage/sqlite.py +0 -0
  43. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/utils/__init__.py +0 -0
  44. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/utils/llm_client.py +0 -0
  45. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/utils/logging_utils.py +0 -0
  46. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core/wakeup.py +0 -0
  47. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core.egg-info/dependency_links.txt +0 -0
  48. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core.egg-info/requires.txt +0 -0
  49. {dir_core-0.4.0 → dir_core-0.4.2}/src/dir_core.egg-info/top_level.txt +0 -0
  50. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_agent_registry.py +0 -0
  51. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_arbitration.py +0 -0
  52. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_context_store.py +0 -0
  53. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_contract_env.py +0 -0
  54. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_decision_ledger_storage.py +0 -0
  55. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_dfid.py +0 -0
  56. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_dim_ttl.py +0 -0
  57. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_escalation.py +0 -0
  58. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_event_bus.py +0 -0
  59. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_governance_context.py +0 -0
  60. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_idempotency.py +0 -0
  61. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_insurance_agent.py +0 -0
  62. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_insurance_invariants.py +0 -0
  63. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_insurance_report.py +0 -0
  64. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_intent_retry.py +0 -0
  65. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_jit.py +0 -0
  66. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_ledger.py +0 -0
  67. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_lifecycle.py +0 -0
  68. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_llm_json_parse.py +0 -0
  69. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_logging_utils.py +0 -0
  70. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_ollama.py +0 -0
  71. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_pci.py +0 -0
  72. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_pg_repo.py +0 -0
  73. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_resource_lock.py +0 -0
  74. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_runtime.py +0 -0
  75. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_saga.py +0 -0
  76. {dir_core-0.4.0 → dir_core-0.4.2}/tests/test_wakeup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dir-core
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Decision Intelligence Runtime - deterministic Kernel Space for agentic AI
5
5
  Author-email: Artur Huk <artur.huk.it@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  # dir-core keeps pip install name and import path aligned (frictionless adoption).
7
7
  name = "dir-core"
8
- version = "0.4.0"
8
+ version = "0.4.2"
9
9
  description = "Decision Intelligence Runtime - deterministic Kernel Space for agentic AI"
10
10
  readme = "README.md"
11
11
  license = "Apache-2.0"
@@ -94,7 +94,7 @@ from .pci import (
94
94
  hash_content,
95
95
  proposal_params_for_hash,
96
96
  )
97
- from .contract_projection import project_contract
97
+ from .contract_projection import project_contract, responsibility_contract_from_projection
98
98
  from .invariants import evaluate_invariants
99
99
  from .amendments import validate_amendment_patch
100
100
  from .wakeup import (
@@ -227,6 +227,7 @@ __all__ = [
227
227
  "ContractParameterAmendment",
228
228
  "AmendmentResult",
229
229
  "project_contract",
230
+ "responsibility_contract_from_projection",
230
231
  "evaluate_invariants",
231
232
  "validate_amendment_patch",
232
233
  "PolicyProposal",
@@ -10,6 +10,7 @@ from .models import (
10
10
  ContractParameterAmendment,
11
11
  ContractReleaseRef,
12
12
  InvariantSpec,
13
+ ResponsibilityContract,
13
14
  RuntimeContractProjection,
14
15
  )
15
16
 
@@ -105,6 +106,7 @@ def _build_projection_from_contract_mapping(data: Mapping[str, Any]) -> RuntimeC
105
106
  release=release,
106
107
  agent_id=str(subject.get("agent_id", data.get("agent_id", ""))),
107
108
  role=subject.get("role", data.get("role", "EXECUTOR")),
109
+ parent_agent_id=subject.get("parent_agent_id", data.get("parent_agent_id")),
108
110
  mission=str(mission_value),
109
111
  allowed_policy_types=list(
110
112
  authority.get("allowed_policy_types")
@@ -186,3 +188,51 @@ def project_contract(contract: Mapping[str, Any]) -> RuntimeContractProjection:
186
188
  if _is_projection_dump(data):
187
189
  return _parse_projection_dump(data)
188
190
  return ensure_projection_invariants(_build_projection_from_contract_mapping(data))
191
+
192
+
193
+ def _limit_value(projection: RuntimeContractProjection, key: str) -> float | None:
194
+ raw = projection.transaction_limits.get(key)
195
+ if not isinstance(raw, Mapping):
196
+ return None
197
+ value = raw.get("value")
198
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
199
+ return None
200
+ return float(value)
201
+
202
+
203
+ def responsibility_contract_from_projection(
204
+ projection: RuntimeContractProjection,
205
+ ) -> ResponsibilityContract:
206
+ """Derive the flat agent-facing ``ResponsibilityContract`` from a projection.
207
+
208
+ ``max_drawdown_limit`` is a fraction (0.04 = 4%); ``max_drawdown_limit_pct`` is in percent points.
209
+ """
210
+ fields: dict[str, Any] = {
211
+ "agent_id": projection.agent_id,
212
+ "role": projection.role,
213
+ "mission": projection.mission,
214
+ "authorized_instruments": list(
215
+ projection.resource_scope.get("instruments")
216
+ or projection.resource_scope.get("categories")
217
+ or []
218
+ ),
219
+ "allowed_policy_types": list(projection.allowed_policy_types),
220
+ "parent_agent_id": projection.parent_agent_id,
221
+ }
222
+
223
+ confidence_below = projection.escalation_policy.get("confidence_below")
224
+ if confidence_below is not None:
225
+ fields["escalate_on_uncertainty"] = float(confidence_below)
226
+
227
+ wake_up = projection.execution_conditions.get("wake_up_threshold_pct")
228
+ if wake_up is not None:
229
+ fields["wake_up_threshold_pct"] = float(wake_up)
230
+
231
+ drawdown = _limit_value(projection, "max_drawdown_limit")
232
+ if drawdown is None:
233
+ drawdown_pct = _limit_value(projection, "max_drawdown_limit_pct")
234
+ drawdown = drawdown_pct / 100.0 if drawdown_pct is not None else None
235
+ if drawdown is not None:
236
+ fields["max_drawdown_limit"] = drawdown
237
+
238
+ return ResponsibilityContract(**fields)
@@ -30,11 +30,24 @@ def get_nested_value(obj: Any, path: str) -> Any:
30
30
  return current
31
31
 
32
32
 
33
+ def resolve_field(
34
+ field: str,
35
+ proposal: PolicyProposal,
36
+ context: Optional[dict[str, Any]] = None,
37
+ ) -> Any:
38
+ """Resolve a rule field: ``state.*`` reads authoritative state, anything else the proposal."""
39
+ if field.startswith("state."):
40
+ state = (context or {}).get("state")
41
+ return get_nested_value(state, field.removeprefix("state."))
42
+ return get_nested_value(proposal, field)
43
+
44
+
33
45
  def evaluate_range_rule(
34
46
  rule: InvariantSpec,
35
47
  proposal: PolicyProposal,
48
+ context: Optional[dict[str, Any]] = None,
36
49
  ) -> Optional[InvariantEvaluationFailure]:
37
- value = get_nested_value(proposal, rule.field)
50
+ value = resolve_field(rule.field, proposal, context)
38
51
  if value is None:
39
52
  return InvariantEvaluationFailure(
40
53
  reason=DimReasonCode.CONTRACT_PARAMETER_MISSING,
@@ -63,8 +76,9 @@ def evaluate_range_rule(
63
76
  def evaluate_set_rule(
64
77
  rule: InvariantSpec,
65
78
  proposal: PolicyProposal,
79
+ context: Optional[dict[str, Any]] = None,
66
80
  ) -> Optional[InvariantEvaluationFailure]:
67
- value = get_nested_value(proposal, rule.field)
81
+ value = resolve_field(rule.field, proposal, context)
68
82
  if value is None:
69
83
  return InvariantEvaluationFailure(
70
84
  reason=DimReasonCode.CONTRACT_PARAMETER_MISSING,
@@ -184,9 +198,9 @@ def evaluate_invariants(
184
198
  ):
185
199
  continue
186
200
  if rule.type == "range":
187
- failure = evaluate_range_rule(rule, proposal)
201
+ failure = evaluate_range_rule(rule, proposal, context)
188
202
  elif rule.type == "set":
189
- failure = evaluate_set_rule(rule, proposal)
203
+ failure = evaluate_set_rule(rule, proposal, context)
190
204
  elif rule.type == "state_match":
191
205
  failure = evaluate_state_match_rule(rule, proposal, context)
192
206
  else:
@@ -130,6 +130,7 @@ class RuntimeContractProjection(BaseModel):
130
130
  release: ContractReleaseRef = Field(default_factory=ContractReleaseRef)
131
131
  agent_id: str
132
132
  role: ContractRole = ContractRole.EXECUTOR
133
+ parent_agent_id: Optional[str] = None
133
134
  mission: str = ""
134
135
  allowed_policy_types: List[str] = Field(default_factory=list)
135
136
  resource_scope: Dict[str, List[str]] = Field(default_factory=dict)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dir-core
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Decision Intelligence Runtime - deterministic Kernel Space for agentic AI
5
5
  Author-email: Artur Huk <artur.huk.it@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -67,6 +67,8 @@ tests/test_ollama.py
67
67
  tests/test_pci.py
68
68
  tests/test_pg_repo.py
69
69
  tests/test_resource_lock.py
70
+ tests/test_responsibility_adapter.py
70
71
  tests/test_runtime.py
71
72
  tests/test_saga.py
73
+ tests/test_samples_isolation.py
72
74
  tests/test_wakeup.py
@@ -3,19 +3,18 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- import os
7
6
  import tempfile
8
7
  from pathlib import Path
9
8
 
10
9
  import pytest
11
10
 
11
+ from samples.shared.llm.clients import MockLLMClient
12
12
  from tools.contract.db.store import ContractStudioStore
13
13
  from tools.contract.llm_interview import (
14
14
  empty_draft_contract,
15
15
  mock_contract_llm_strategy,
16
16
  process_chat_turn,
17
17
  )
18
- from samples.shared.llm.clients import MockLLMClient
19
18
 
20
19
 
21
20
  @pytest.fixture
@@ -77,6 +76,42 @@ def test_process_chat_turn_mock() -> None:
77
76
  assert turn.change_summary
78
77
 
79
78
 
79
+ def test_noncanonical_analysis_key_remains_warning_through_integrity() -> None:
80
+ from tools.contract.integrity import verify_contract_yaml
81
+ from tools.contract.schema import IRREVERSIBLE_LIMIT_KEYS
82
+
83
+ def strategy(prompt: str, system: str | None = None) -> str:
84
+ response = json.loads(mock_contract_llm_strategy(prompt, system))
85
+ response["governance_analysis"]["invariant_candidates"][0][
86
+ "linked_limit_key"
87
+ ] = "max_payout_usd"
88
+ return json.dumps(response)
89
+
90
+ turn = process_chat_turn(
91
+ MockLLMClient(strategy=strategy),
92
+ current_contract=empty_draft_contract("trading"),
93
+ chat_history=[],
94
+ user_message="Build a crypto trading agent with limits",
95
+ preset="trading",
96
+ )
97
+ assert turn.validation_ok
98
+ assert not turn.blocking_errors
99
+ assert any("INVALID_LIMIT_KEY" in warning for warning in turn.warnings)
100
+
101
+ result = verify_contract_yaml(
102
+ turn.contract_yaml,
103
+ preset="trading",
104
+ governance_analysis=turn.governance_analysis,
105
+ )
106
+ assert result["integrity_ok"]
107
+ assert result["checks"]["contract_validation"]["ok"]
108
+ assert not result["errors"]
109
+ warning = next(w for w in result["validation_warnings"] if "INVALID_LIMIT_KEY" in w)
110
+ assert "max_payout_usd" in warning
111
+ for key in IRREVERSIBLE_LIMIT_KEYS:
112
+ assert key in warning
113
+
114
+
80
115
  @pytest.fixture
81
116
  def studio_client(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
82
117
  pytest.importorskip("fastapi")
@@ -118,14 +153,15 @@ def test_api_create_chat_export(studio_client) -> None:
118
153
  assert vbody["checks"]["bootstrap"]["ok"] is True
119
154
  assert vbody["sha256"]
120
155
 
121
- export = studio_client.post(
122
- f"/api/sessions/{session_id}/export",
123
- json={"emit": "registry"},
124
- )
125
- assert export.status_code == 200
126
- paths = export.json()["paths"]
127
- assert any(p.endswith(".yaml") for p in paths)
128
- assert Path(paths[0]).is_file()
156
+ with tempfile.TemporaryDirectory() as output_dir:
157
+ export = studio_client.post(
158
+ f"/api/sessions/{session_id}/export",
159
+ json={"emit": "registry", "out": output_dir},
160
+ )
161
+ assert export.status_code == 200
162
+ paths = export.json()["paths"]
163
+ assert any(p.endswith(".yaml") for p in paths)
164
+ assert Path(paths[0]).is_file()
129
165
 
130
166
  revisions = studio_client.get(f"/api/sessions/{session_id}/revisions")
131
167
  assert revisions.status_code == 200
@@ -239,6 +275,12 @@ def test_index_serves_versioned_assets(studio_client) -> None:
239
275
  assert 'id="yamlEditor"' in res.text
240
276
  assert "/static/app.js?v=" in res.text
241
277
  assert "/static/styles.css?v=" in res.text
278
+ assert '<details id="integrityPanel"' in res.text
279
+ assert '<details id="governancePanel"' in res.text
280
+ assert 'id="integritySummary"' in res.text
281
+ assert 'id="yamlHighlight" aria-hidden="true"' in res.text
282
+ assert 'wrap="off"' in res.text
283
+ assert "<details" in res.text and " open" not in res.text
242
284
 
243
285
 
244
286
  def test_verify_contract_yaml_rejects_garbage() -> None:
@@ -249,6 +291,32 @@ def test_verify_contract_yaml_rejects_garbage() -> None:
249
291
  assert result["checks"]["yaml_parse"]["ok"] is False
250
292
 
251
293
 
294
+ def test_integrity_preserves_authoring_export_block(studio_client) -> None:
295
+ session_id = studio_client.post(
296
+ "/api/sessions", json={"title": "Blocked", "preset": "trading"}
297
+ ).json()["session"]["id"]
298
+ chat = studio_client.post(
299
+ f"/api/sessions/{session_id}/chat", json={"message": "Build a crypto trading agent"}
300
+ ).json()
301
+ edited = chat["contract_yaml"].replace("jane.doe@example.com", "owner@example.com")
302
+ saved = studio_client.put(
303
+ f"/api/sessions/{session_id}/contract", json={"yaml": edited}
304
+ ).json()
305
+ assert saved["saved"]
306
+ assert not saved["validation_ok"]
307
+ result = studio_client.post(
308
+ f"/api/sessions/{session_id}/validate", json={}
309
+ ).json()
310
+ assert not result["integrity_ok"]
311
+ assert not result["validation_ok"]
312
+ assert not result["checks"]["contract_validation"]["ok"]
313
+ assert any("PLACEHOLDER_IDENTITY" in error for error in result["validation_errors"])
314
+ assert studio_client.get(f"/api/sessions/{session_id}").json()["session"]["status"] == "drafting"
315
+ assert studio_client.post(
316
+ f"/api/sessions/{session_id}/export", json={}
317
+ ).status_code == 400
318
+
319
+
252
320
  def test_rename_and_delete_session(studio_client) -> None:
253
321
  a = studio_client.post("/api/sessions", json={"title": "Alpha", "preset": "generic"})
254
322
  b = studio_client.post("/api/sessions", json={"title": "Beta", "preset": "trading"})
@@ -323,3 +391,76 @@ responsibility:
323
391
  result = verify_contract_yaml(yaml_text, preset="generic")
324
392
  assert result["integrity_ok"] is True
325
393
  assert result["checks"]["bootstrap"]["ok"] is True
394
+
395
+
396
+ def test_normalize_llm_resource_scope_and_invariant_candidate() -> None:
397
+ from tools.contract.invariants import compile_authority_invariants
398
+ from tools.contract.schema import CanonicalContract, normalize_contract_dict
399
+ from tools.contract.validation import validate_contract
400
+
401
+ raw = empty_draft_contract("generic")
402
+ raw["metadata"].update(
403
+ contract_id="storm_claims_agent",
404
+ owner="claims.owner@example.org",
405
+ )
406
+ raw["subject"]["agent_id"] = "storm_claims_agent"
407
+ raw["mission"]["statement"] = "Review residential storm damage claims."
408
+ raw["authority"]["limits"] = {
409
+ "max_transaction_usd": {"value": 5000.0, "unit": "USD"}
410
+ }
411
+ raw["authority"]["resource_scope"] = {
412
+ "coverage": ["homeowner policy endorsement"],
413
+ "category": ["fine arts and rugs"],
414
+ "exclusions": {},
415
+ "match": {
416
+ "substring": ["contents coverage", "fine arts and rugs"]
417
+ },
418
+ }
419
+ raw["authority"]["invariants"] = [
420
+ {
421
+ "invariant_id": "INV-RUG-PAYMENT-RANGE",
422
+ "constraint_class": "transaction_invariant",
423
+ "predicate": {
424
+ "op": "and",
425
+ "left": {
426
+ "op": "ge",
427
+ "variable": "proposed_rug_payment_usd",
428
+ "value": 0,
429
+ },
430
+ "right": {
431
+ "op": "le",
432
+ "variable": "proposed_rug_payment_usd",
433
+ "value": 5000,
434
+ },
435
+ },
436
+ "code": "POLICY_SUBLIMIT_EXCEEDED",
437
+ "applies_to_actions": ["PROPOSE_SETTLEMENT"],
438
+ "linked_limit_key": "max_transaction_usd",
439
+ }
440
+ ]
441
+
442
+ normalized = normalize_contract_dict(raw)
443
+ contract = CanonicalContract.model_validate(normalized)
444
+ validated, validation_ok, errors, _ = validate_contract(
445
+ normalized, preset="generic"
446
+ )
447
+ invariants = compile_authority_invariants(contract)
448
+ rug_limit = next(inv for inv in invariants if inv.id == "INV-RUG-PAYMENT-RANGE")
449
+
450
+ assert validated is not None
451
+ assert not validation_ok
452
+ assert any("RUNTIME_PROJECTION_INVALID" in error for error in errors)
453
+ assert contract.authority.exclusions == {}
454
+ assert contract.authority.resource_scope["match"] == {
455
+ "substring": ["contents coverage", "fine arts and rugs"]
456
+ }
457
+ assert rug_limit.type == "range"
458
+ assert rug_limit.field == "params.proposed_rug_payment_usd"
459
+ assert rug_limit.min == 0
460
+ assert rug_limit.max == 5000
461
+ assert rug_limit.reason_code == "POLICY_SUBLIMIT_EXCEEDED"
462
+ assert rug_limit.applies_to_policy_kinds == ["PROPOSE_SETTLEMENT"]
463
+
464
+ del normalized["authority"]["resource_scope"]["match"]
465
+ _, validation_ok, errors, _ = validate_contract(normalized, preset="generic")
466
+ assert validation_ok, errors
@@ -1,48 +1,204 @@
1
- """Tests for tools/contract Bootstrap wizard."""
1
+ """Tests for contract validation, compilation and canonical rendering."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import tempfile
6
7
  from pathlib import Path
7
8
 
9
+ import pytest
8
10
  import yaml
9
11
 
10
- from dir_core import ResponsibilityContract, RuntimeContractProjection, project_contract
12
+ from dir_core import RuntimeContractProjection, project_contract
13
+ from samples.shared.contracts.provider import YamlContractProvider
11
14
  from tools.contract.bootstrap_rules import validate_bootstrap
12
- from tools.contract.flatten import flatten_canonical, flatten_contract_dict
13
- from tools.contract.from_sample import answers_from_sample
15
+ from tools.contract.cli import main
14
16
  from tools.contract.render import render_registry_yaml, render_sample_fragment
15
- from tools.contract.schema import CanonicalContract, InterviewAnswers
16
- from samples.shared.contracts.provider import YamlContractProvider
17
+ from tools.contract.schema import CanonicalContract
18
+ from tools.contract.validation import validate_contract
19
+
20
+
21
+ @pytest.fixture
22
+ def contract_path(tmp_path: Path) -> Path:
23
+ contract = CanonicalContract(
24
+ agent_id="payments_01",
25
+ owner="payments.owner@example.org",
26
+ mission="Issue bounded refunds.",
27
+ authority={
28
+ "allowed_policy_types": ["REFUND"],
29
+ "limits": {"max_refund_usd": {"value": 50.0, "unit": "USD"}},
30
+ "invariants": [
31
+ {
32
+ "id": "INV_COUNTRY",
33
+ "type": "set",
34
+ "field": "state.property.country",
35
+ "denied": ["KP", "IR", "SY"],
36
+ }
37
+ ],
38
+ },
39
+ )
40
+ path = tmp_path / "contract.yaml"
41
+ path.write_text(render_registry_yaml(contract), encoding="utf-8")
42
+ return path
43
+
44
+
45
+ def test_cli_validate(contract_path: Path, capsys) -> None:
46
+ assert main(["validate", str(contract_path)]) == 0
47
+ captured = capsys.readouterr()
48
+ assert captured.out.startswith("OK:")
49
+ assert "ANALYSIS_MISSING" in captured.err
50
+
51
+
52
+ def test_cli_compile_stdout_is_projection_json(contract_path: Path, capsys) -> None:
53
+ assert main(["compile", str(contract_path)]) == 0
54
+ captured = capsys.readouterr()
55
+ result = json.loads(captured.out)
56
+ projection = RuntimeContractProjection.model_validate(result)
57
+ expected = project_contract(
58
+ CanonicalContract.from_raw(yaml.safe_load(contract_path.read_text(encoding="utf-8")))
59
+ .model_dump(mode="json", exclude_none=True)
60
+ )
61
+ assert projection == expected
62
+ assert projection.release.contract_id == "payments_01"
63
+ assert projection.release.contract_version == "1.0.0"
64
+ assert any(spec.field == "state.property.country" for spec in projection.invariants)
65
+ assert any(spec.max == 50 for spec in projection.invariants)
66
+ assert "ANALYSIS_MISSING" in captured.err
67
+ assert main(["compile", str(contract_path)]) == 0
68
+ assert capsys.readouterr().out == captured.out
69
+
70
+
71
+ def test_cli_compile_file(contract_path: Path, tmp_path: Path, capsys) -> None:
72
+ output = tmp_path / "projection.json"
73
+ assert main(["compile", str(contract_path), "--out", str(output)]) == 0
74
+ assert capsys.readouterr().out == ""
75
+ projection = RuntimeContractProjection.model_validate_json(output.read_text(encoding="utf-8"))
76
+ assert projection.agent_id == "payments_01"
77
+
78
+
79
+ @pytest.mark.parametrize("command", ["validate", "compile"])
80
+ @pytest.mark.parametrize("content", ["", "- item", "authority: {{{", "null", "42"])
81
+ def test_cli_invalid_yaml(command: str, content: str, tmp_path: Path, capsys) -> None:
82
+ path = tmp_path / "invalid.yaml"
83
+ path.write_text(content, encoding="utf-8")
84
+ assert main([command, str(path)]) == 1
85
+ captured = capsys.readouterr()
86
+ assert captured.out == ""
87
+ assert "failed" in captured.err.lower()
88
+
89
+
90
+ @pytest.mark.parametrize("command", ["validate", "compile"])
91
+ def test_cli_missing_file(command: str, tmp_path: Path, capsys) -> None:
92
+ assert main([command, str(tmp_path / "missing.yaml")]) == 1
93
+ assert capsys.readouterr().err
94
+
95
+
96
+ def test_cli_invalid_contract_does_not_overwrite_output(contract_path: Path, tmp_path: Path, capsys) -> None:
97
+ contract_path.write_text(
98
+ contract_path.read_text(encoding="utf-8").replace("payments.owner@example.org", "owner@example.com"),
99
+ encoding="utf-8",
100
+ )
101
+ output = tmp_path / "projection.json"
102
+ output.write_text("unchanged", encoding="utf-8")
103
+ assert main(["compile", str(contract_path), "--out", str(output)]) == 1
104
+ assert output.read_text(encoding="utf-8") == "unchanged"
105
+ assert "PLACEHOLDER_IDENTITY" in capsys.readouterr().err
106
+
107
+
108
+ def test_cli_compile_reports_write_failure(contract_path: Path, tmp_path: Path, capsys) -> None:
109
+ assert main(["compile", str(contract_path), "--out", str(tmp_path)]) == 1
110
+ assert "compile failed" in capsys.readouterr().err
111
+
112
+
113
+ def test_cli_preset_validation(contract_path: Path, capsys) -> None:
114
+ assert main(["validate", str(contract_path), "--preset", "trading"]) == 1
115
+ assert "max_order_size_usd" in capsys.readouterr().err
116
+
117
+
118
+ @pytest.mark.parametrize("command", ["init", "from-sample"])
119
+ def test_cli_authoring_commands_removed(command: str, capsys) -> None:
120
+ with pytest.raises(SystemExit) as exc:
121
+ main([command])
122
+ assert exc.value.code == 2
123
+ assert "invalid choice" in capsys.readouterr().err
124
+
125
+
126
+ def test_shared_validation_rejects_runtime_incompatible_role(contract_path: Path) -> None:
127
+ data = yaml.safe_load(contract_path.read_text(encoding="utf-8"))
128
+ data["subject"]["role"] = "INTERFACE"
129
+ data["authority"] = {}
130
+ contract, ok, errors, _ = validate_contract(data)
131
+ assert contract is not None
132
+ assert not ok
133
+ assert any("RUNTIME_PROJECTION_INVALID" in error for error in errors)
134
+ assert data["subject"]["role"] == "INTERFACE"
135
+
136
+
137
+ @pytest.mark.parametrize("command", ["validate", "compile"])
138
+ def test_cli_rejects_runtime_incompatible_scope(command: str, contract_path: Path, capsys) -> None:
139
+ data = yaml.safe_load(contract_path.read_text(encoding="utf-8"))
140
+ data["authority"]["resource_scope"] = {"match": {"substring": ["home"]}}
141
+ contract_path.write_text(yaml.safe_dump(data), encoding="utf-8")
142
+ assert main([command, str(contract_path)]) == 1
143
+ captured = capsys.readouterr()
144
+ assert captured.out == ""
145
+ assert "RUNTIME_PROJECTION_INVALID" in captured.err
146
+
147
+
148
+ def test_shared_validation_rejects_invalid_invariant(contract_path: Path) -> None:
149
+ data = yaml.safe_load(contract_path.read_text(encoding="utf-8"))
150
+ data["authority"]["invariants"][0]["allowed"] = ["US"]
151
+ _, ok, errors, _ = validate_contract(data)
152
+ assert not ok
153
+ assert any("INVARIANT_SET_MODE" in error for error in errors)
154
+
155
+
156
+ def test_integrity_revalidates_governance_analysis(contract_path: Path) -> None:
157
+ from tools.contract.governance.models import GoalModel, GovernanceAnalysis, SourceBinding
158
+ from tools.contract.integrity import verify_contract_yaml
159
+
160
+ analysis = GovernanceAnalysis(
161
+ goal=GoalModel(
162
+ objective="Issue bounded refunds",
163
+ source_bindings=[SourceBinding(clause_id="UNKNOWN_CLAUSE")],
164
+ )
165
+ )
166
+ result = verify_contract_yaml(
167
+ contract_path.read_text(encoding="utf-8"), governance_analysis=analysis
168
+ )
169
+ assert not result["integrity_ok"]
170
+ assert any("INVALID_CLAUSE_REF" in error for error in result["errors"])
17
171
 
18
172
 
19
- def test_interview_answers_to_canonical_and_validate() -> None:
20
- answers = InterviewAnswers(
21
- preset="fraud_gate",
173
+ def test_canonical_contract_and_validate() -> None:
174
+ contract = CanonicalContract(
22
175
  agent_id="fraud_guard_v1",
23
- owner="owner@example.com",
176
+ owner="fraud.owner@example.org",
24
177
  role="EXECUTOR",
25
178
  mission="Evaluate transactions.",
26
- allowed_policy_types=["ALLOW", "BLOCK"],
27
- irreversible_limits={"max_transaction_usd": 5000.0},
179
+ authority={
180
+ "allowed_policy_types": ["ALLOW", "BLOCK"],
181
+ "limits": {"max_transaction_usd": {"value": 5000.0, "unit": "USD"}},
182
+ },
28
183
  )
29
- contract = answers.to_canonical()
30
184
  validate_bootstrap(contract, preset="fraud_gate")
31
185
  limit = contract.authority.limits["max_transaction_usd"]
32
186
  assert limit.value == 5000.0
33
187
  assert limit.unit == "USD"
34
188
  assert contract.subject.agent_id == "fraud_guard_v1"
35
- assert contract.metadata.owner == "owner@example.com"
189
+ assert contract.metadata.owner == "fraud.owner@example.org"
36
190
 
37
191
 
38
192
  def test_renderers_emit_only_canonical_contract_shape() -> None:
39
- contract = InterviewAnswers(
193
+ contract = CanonicalContract(
40
194
  agent_id="bot_01",
41
195
  owner="owner@example.com",
42
196
  mission="Trade safely.",
43
- allowed_policy_types=["BUY"],
44
- irreversible_limits={"max_order_size_usd": 1000.0},
45
- ).to_canonical()
197
+ authority={
198
+ "allowed_policy_types": ["BUY"],
199
+ "limits": {"max_order_size_usd": {"value": 1000.0, "unit": "USD"}},
200
+ },
201
+ )
46
202
 
47
203
  for rendered in (render_registry_yaml(contract), render_sample_fragment(contract)):
48
204
  data = yaml.safe_load(rendered)
@@ -172,41 +328,6 @@ def test_validate_authoring_rejects_placeholder_and_dup_limit() -> None:
172
328
  assert any("AGGREGATE_DUP_LIMIT" in e for e in errors)
173
329
 
174
330
 
175
- def test_flatten_round_trip_dir_core() -> None:
176
- contract = CanonicalContract(
177
- agent_id="bot_01",
178
- owner="o@example.com",
179
- mission="Trade safely.",
180
- authority={
181
- "authorized_instruments": ["ETH-USD"],
182
- "allowed_policy_types": ["BUY"],
183
- "max_order_size_usd": 1000.0,
184
- "max_drawdown_limit_pct": 4.0,
185
- },
186
- )
187
- flat = flatten_canonical(contract)
188
- rc = ResponsibilityContract(**flat)
189
- assert rc.agent_id == "bot_01"
190
- assert rc.max_drawdown_limit == 0.04
191
- assert flat["permissions"]["max_order_size_usd"] == 1000.0
192
-
193
-
194
- def test_flatten_contract_dict_nested() -> None:
195
- nested = {
196
- "agent_id": "a1",
197
- "role": "EXECUTOR",
198
- "mission": "m",
199
- "authority": {
200
- "allowed_policy_types": ["HOLD"],
201
- "max_order_size_usd": 500.0,
202
- },
203
- "responsibility": {"escalate_on_uncertainty": 0.8},
204
- }
205
- flat = flatten_contract_dict(nested)
206
- assert flat["allowed_policy_types"] == ["HOLD"]
207
- assert flat["permissions"]["max_order_size_usd"] == 500.0
208
-
209
-
210
331
  def test_project_contract_canonical_shape() -> None:
211
332
  projection = project_contract(
212
333
  {
@@ -324,31 +445,6 @@ def test_explicit_invariant_conflict_detected() -> None:
324
445
  assert errors
325
446
 
326
447
 
327
- def test_from_sample_quick_start() -> None:
328
- repo_root = Path(__file__).resolve().parents[1]
329
- answers = answers_from_sample(repo_root / "samples" / "00_quick_start")
330
- assert answers.agent_id == "trading_bot_01"
331
- assert "max_order_size_usd" in answers.irreversible_limits
332
- assert "max_drawdown_limit_pct" in answers.irreversible_limits
333
- contract = answers.to_canonical()
334
- validate_bootstrap(contract, preset=answers.preset)
335
-
336
-
337
- def test_from_sample_preserves_canonical_invariant_ir() -> None:
338
- repo_root = Path(__file__).resolve().parents[1]
339
- answers = answers_from_sample(
340
- repo_root / "samples" / "33_insurance_underwriting"
341
- )
342
- contract = answers.to_canonical()
343
- limit = next(
344
- spec
345
- for spec in contract.authority.invariants
346
- if spec.id == "INV_LIMIT_PROPOSAL_TIV"
347
- )
348
- assert limit.max == 3000000.0
349
- assert limit.field == "params.total_insured_value"
350
-
351
-
352
448
  def test_normalize_preserves_explicit_currency_limit() -> None:
353
449
  contract = CanonicalContract.from_raw(
354
450
  {
@@ -371,38 +467,6 @@ def test_normalize_preserves_explicit_currency_limit() -> None:
371
467
  assert contract.authority.limits["max_refund_eur"].unit == "EUR"
372
468
 
373
469
 
374
- def test_yaml_contract_provider_flattens_nested() -> None:
375
- from samples.shared.contracts.provider import YamlContractProvider
376
-
377
- nested = {
378
- "agents": [
379
- {
380
- "agent_id": "nested_agent",
381
- "contract": {
382
- "role": "EXECUTOR",
383
- "mission": "Test nested load.",
384
- "authority": {
385
- "allowed_policy_types": ["ACTION"],
386
- "max_transaction_usd": 100.0,
387
- },
388
- "responsibility": {"escalate_on_uncertainty": 0.6},
389
- },
390
- }
391
- ]
392
- }
393
- with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as handle:
394
- yaml.dump(nested, handle)
395
- path = handle.name
396
-
397
- try:
398
- provider = YamlContractProvider(path)
399
- contract = provider.get_contract("nested_agent")
400
- assert contract.agent_id == "nested_agent"
401
- assert contract.allowed_policy_types == ["ACTION"]
402
- finally:
403
- Path(path).unlink(missing_ok=True)
404
-
405
-
406
470
  def test_yaml_contract_provider_loads_canonical_envelope() -> None:
407
471
  nested = {
408
472
  "agents": [
@@ -4,13 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  from tools.contract.governance.models import (
6
6
  ActionClass,
7
- GovernanceAnalysis,
8
7
  GoalModel,
8
+ GovernanceAnalysis,
9
9
  InvariantCandidate,
10
10
  PredicateAST,
11
11
  SourceBinding,
12
12
  )
13
13
  from tools.contract.governance.validation import validate_governance_analysis
14
+ from tools.contract.schema import IRREVERSIBLE_LIMIT_KEYS
14
15
 
15
16
 
16
17
  def _valid_analysis() -> GovernanceAnalysis:
@@ -130,3 +131,24 @@ def test_valid_analysis_passes() -> None:
130
131
  }
131
132
  report = validate_governance_analysis(analysis=analysis, contract_dict=contract)
132
133
  assert report.blocking_ok
134
+
135
+
136
+ def test_noncanonical_linked_limit_is_warning_with_allowed_keys() -> None:
137
+ analysis = _valid_analysis()
138
+ analysis.invariant_candidates[0].linked_limit_key = "max_payout_usd"
139
+ report = validate_governance_analysis(
140
+ analysis=analysis,
141
+ contract_dict={
142
+ "authority": {
143
+ "allowed_policy_types": ["BUY"],
144
+ "limits": {"max_order_size_usd": {"value": 50000.0, "unit": "USD"}},
145
+ },
146
+ },
147
+ )
148
+
149
+ assert report.blocking_ok
150
+ issue = next(issue for issue in report.warnings if issue.code == "INVALID_LIMIT_KEY")
151
+ assert issue.severity == "warning"
152
+ assert "max_payout_usd" in issue.message
153
+ for key in IRREVERSIBLE_LIMIT_KEYS:
154
+ assert key in issue.message
@@ -203,3 +203,49 @@ def test_evaluate_invariants_collects_all_failures() -> None:
203
203
  "PROHIBITED_TERRITORY",
204
204
  "AUTHORITY_CEILING",
205
205
  ]
206
+
207
+
208
+ def test_set_rule_allowlist_reads_authoritative_state() -> None:
209
+ rule = InvariantSpec(
210
+ id="INV_SUPPORTED_POLICY_TYPES",
211
+ type="set",
212
+ field="state.policy.type",
213
+ allowed=["HOMEOWNERS", "CONDO"],
214
+ reason_code="UNSUPPORTED_POLICY_TYPE",
215
+ )
216
+ proposal = _proposal(params={})
217
+ assert evaluate_set_rule(rule, proposal, {"state": {"policy": {"type": "CONDO"}}}) is None
218
+ failure = evaluate_set_rule(rule, proposal, {"state": {"policy": {"type": "COMMERCIAL"}}})
219
+ assert failure is not None
220
+ assert failure.reason == "UNSUPPORTED_POLICY_TYPE"
221
+
222
+
223
+ def test_set_rule_on_state_ignores_value_claimed_by_agent() -> None:
224
+ rule = InvariantSpec(
225
+ id="INV_RESTRICTED_COUNTRIES",
226
+ type="set",
227
+ field="state.property.country",
228
+ denied=["KP", "IR", "SY"],
229
+ reason_code="COUNTRY_NOT_PERMITTED",
230
+ )
231
+ lying_proposal = _proposal(params={"property_country": "US"})
232
+ failure = evaluate_set_rule(
233
+ rule,
234
+ lying_proposal,
235
+ {"state": {"property": {"country": "KP"}}},
236
+ )
237
+ assert failure is not None
238
+ assert failure.reason == "COUNTRY_NOT_PERMITTED"
239
+
240
+
241
+ def test_range_rule_on_state_fails_closed_when_state_missing() -> None:
242
+ rule = InvariantSpec(
243
+ id="INV_STATE_LIMIT",
244
+ type="range",
245
+ field="state.claim.reserve_usd",
246
+ max=1000,
247
+ )
248
+ failure = evaluate_range_rule(rule, _proposal(params={"reserve_usd": 1}), {"state": {}})
249
+ assert failure is not None
250
+ assert failure.reason == DimReasonCode.CONTRACT_PARAMETER_MISSING
251
+ assert evaluate_range_rule(rule, _proposal(params={}), None) is not None
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from dir_core import (
6
+ ContractRole,
7
+ RuntimeContractProjection,
8
+ project_contract,
9
+ responsibility_contract_from_projection,
10
+ )
11
+
12
+
13
+ def _canonical(**overrides):
14
+ contract = {
15
+ "api_version": "roa.dir/v1",
16
+ "kind": "ResponsibilityContract",
17
+ "metadata": {"version": "1.0.0"},
18
+ "subject": {
19
+ "agent_id": "pos_01",
20
+ "role": "EXECUTOR",
21
+ "parent_agent_id": "strategist_01",
22
+ },
23
+ "mission": {"statement": "Hold safely."},
24
+ "authority": {
25
+ "allowed_policy_types": ["HOLD", "CLOSE"],
26
+ "resource_scope": {"instruments": ["ETH-USD"]},
27
+ "limits": {"max_order_size_usd": {"value": 1000, "unit": "USD"}},
28
+ },
29
+ "execution_conditions": {"wake_up_threshold_pct": 0.25},
30
+ "responsibility": {"escalation": {"mode": "mandatory", "confidence_below": 0.6}},
31
+ }
32
+ contract.update(overrides)
33
+ return contract
34
+
35
+
36
+ def test_projection_keeps_parent_agent_id() -> None:
37
+ projection = project_contract(_canonical())
38
+ assert isinstance(projection, RuntimeContractProjection)
39
+ assert projection.parent_agent_id == "strategist_01"
40
+
41
+
42
+ def test_adapter_maps_canonical_projection_to_flat_contract() -> None:
43
+ rc = responsibility_contract_from_projection(project_contract(_canonical()))
44
+ assert rc.agent_id == "pos_01"
45
+ assert rc.role == ContractRole.EXECUTOR
46
+ assert rc.mission == "Hold safely."
47
+ assert rc.authorized_instruments == ["ETH-USD"]
48
+ assert rc.allowed_policy_types == ["HOLD", "CLOSE"]
49
+ assert rc.escalate_on_uncertainty == 0.6
50
+ assert rc.wake_up_threshold_pct == 0.25
51
+ assert rc.parent_agent_id == "strategist_01"
52
+
53
+
54
+ def test_adapter_uses_defaults_when_optional_blocks_missing() -> None:
55
+ contract = _canonical(execution_conditions={}, responsibility={})
56
+ rc = responsibility_contract_from_projection(project_contract(contract))
57
+ assert rc.escalate_on_uncertainty == 0.7
58
+ assert rc.wake_up_threshold_pct == 0.5
59
+ assert rc.max_drawdown_limit == 0.05
60
+
61
+
62
+ def test_adapter_drawdown_fraction_and_percent() -> None:
63
+ fraction = _canonical()
64
+ fraction["authority"]["limits"]["max_drawdown_limit"] = {"value": 0.04, "unit": "fraction"}
65
+ assert responsibility_contract_from_projection(project_contract(fraction)).max_drawdown_limit == 0.04
66
+
67
+ percent = _canonical()
68
+ percent["authority"]["limits"]["max_drawdown_limit_pct"] = {"value": 4.0, "unit": "percent"}
69
+ assert responsibility_contract_from_projection(project_contract(percent)).max_drawdown_limit == pytest.approx(0.04)
@@ -0,0 +1,39 @@
1
+ """Samples are Runtime consumers: they may depend on ``dir_core`` but never on Build-Time ``tools``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+
8
+ SAMPLES_DIR = Path(__file__).resolve().parents[1] / "samples"
9
+
10
+
11
+ def _imported_modules(path: Path) -> list[str]:
12
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
13
+ modules: list[str] = []
14
+ for node in ast.walk(tree):
15
+ if isinstance(node, ast.Import):
16
+ modules.extend(alias.name for alias in node.names)
17
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
18
+ modules.append(node.module)
19
+ return modules
20
+
21
+
22
+ def test_samples_do_not_import_build_time_tools() -> None:
23
+ offenders = [
24
+ f"{path.relative_to(SAMPLES_DIR)}: {module}"
25
+ for path in SAMPLES_DIR.rglob("*.py")
26
+ if "__pycache__" not in path.parts
27
+ for module in _imported_modules(path)
28
+ if module == "tools" or module.startswith("tools.")
29
+ ]
30
+ assert not offenders, f"samples must not depend on tools: {offenders}"
31
+
32
+
33
+ def test_samples_do_not_mutate_sys_path_with_repo_root() -> None:
34
+ offenders = [
35
+ str(path.relative_to(SAMPLES_DIR))
36
+ for path in SAMPLES_DIR.rglob("*.py")
37
+ if "__pycache__" not in path.parts and "parents[3]" in path.read_text(encoding="utf-8")
38
+ ]
39
+ assert not offenders, f"samples must not add the repo root to sys.path: {offenders}"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes