diff --git a/.github/codex_auto_merge_policy.json b/.github/codex_auto_merge_policy.json index a2cc18c..e1f0a35 100644 --- a/.github/codex_auto_merge_policy.json +++ b/.github/codex_auto_merge_policy.json @@ -44,6 +44,7 @@ ], "timeout_minutes": 20, "max_diff_lines": 2400, + "auto_converge_after": 3, "block_on_review_failure": false } } diff --git a/.github/workflows/codex_pr_review.yml b/.github/workflows/codex_pr_review.yml index 5f80923..27351f6 100644 --- a/.github/workflows/codex_pr_review.yml +++ b/.github/workflows/codex_pr_review.yml @@ -20,6 +20,8 @@ jobs: with: caller_concurrency_key: pr-${{ github.event.pull_request.number || github.run_id }} allow_unconfigured_backend: true + api_fallback_enabled: "false" + direct_api_primary_enabled: "false" secrets: inherit permissions: contents: read diff --git a/.github/workflows/manual-strategy-switch.yml b/.github/workflows/manual-strategy-switch.yml index b8d025f..a1be85b 100644 --- a/.github/workflows/manual-strategy-switch.yml +++ b/.github/workflows/manual-strategy-switch.yml @@ -129,6 +129,9 @@ on: type: string default: sync-cloud-run-env.yml +permissions: + contents: read + concurrency: group: runtime-strategy-switch-${{ inputs.platform }}-${{ inputs.target_name }} cancel-in-progress: false diff --git a/README.md b/README.md index 3b77796..ec6f33a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ This repository is the central manifest source for QSL compatibility checks. - `scripts/check_qsl_compat.py`: validate a repo's QSL compliance in `pyproject.toml`/`uv.lock` against central bundle. - `python/scripts/qslctl.py`: unified QSL version-control CLI for repo checks, workspace checks, ring reports/plans, and matrix generation. - `scripts/render_qsl_dependency_graph.py`: render dependency graph for review. -- `compat/bundles/2026.07.2.toml`: current compatibility bundle baseline (CalVer). +- `compat/bundles/2026.07.3.toml`: current compatibility bundle baseline (CalVer). - `compat/repo-tiers.toml`: repo tier and upgrade ring policy notes. - [QSL compatibility docs](docs/qsl_compat_upgrade.md) diff --git a/compat/bundles/2026.07.3.toml b/compat/bundles/2026.07.3.toml new file mode 100644 index 0000000..f7017ef --- /dev/null +++ b/compat/bundles/2026.07.3.toml @@ -0,0 +1,12 @@ +name = "2026.07.3" +description = "QSL compatibility bundle after runtime QPK pin convergence" +created_at = "2026-07-10" + +[repos] +QuantPlatformKit = "69a0256934d081b5ef309a885384b9eb9f62cf90" +UsEquityStrategies = "17ddb86c72d44b2c7b78ba7a10d8f71b21180166" +HkEquityStrategies = "b6a8ac2ad3c8110b5ea74fb059c8206388d63bcd" +CnEquityStrategies = "12c0cd4801060fcb2f9452ffd9a7f48df446ddd0" +CryptoStrategies = "ef78312d7653095f585c4f75d45bf765bedc2751" +QuantStrategyPlugins = "1f3a27b8fd83d71b583f4f5160a748e95fbefaa1" +MarketSignalSources = "bda8ab10b80a646e4b579ef4c0d5dfe90776e908" diff --git a/docs/qsl_compat_upgrade.md b/docs/qsl_compat_upgrade.md index 8449af0..1e26e0d 100644 --- a/docs/qsl_compat_upgrade.md +++ b/docs/qsl_compat_upgrade.md @@ -5,7 +5,7 @@ This repository defines the QSL central compatibility manifest and its upgrade p ## 文件结构 - `compat/bundles/*.toml` - - 每个 bundle 用 Calendar Version 命名(当前:`2026.07.2`)。 + - 每个 bundle 用 Calendar Version 命名(当前:`2026.07.3`)。 - 记录 QSL 管控的固定内部仓库提交。 - `compat/repo-tiers.toml` - 记录仓库层级(`core/strategy-lib/pipeline/runtime/ops`)与升级 ring 规则。 @@ -26,8 +26,8 @@ This repository defines the QSL central compatibility manifest and its upgrade p ```toml [qsl] -bundle = "2026.07.2" # 选择要对齐的 central bundle -compat = "2026.07.2" # 兼容检查入口(与 bundle 相同) +bundle = "2026.07.3" # 选择要对齐的 central bundle +compat = "2026.07.3" # 兼容检查入口(与 bundle 相同) tier = "ops/tooling" upgrade_ring = "ring_e" allow_legacy = false # 需要临时兼容时可先放开 @@ -53,7 +53,7 @@ python scripts/render_qsl_dependency_graph.py --repo-root . --format md ## 当前中心兼容基线 -- Bundle: `2026.07.2` +- Bundle: `2026.07.3` - QPK: `37c81901160c5b31127a27dba1c63944933fb6bf` - UsEquityStrategies: `17ddb86c72d44b2c7b78ba7a10d8f71b21180166` - HkEquityStrategies: `b6a8ac2ad3c8110b5ea74fb059c8206388d63bcd` diff --git a/internal_dependency_matrix.json b/internal_dependency_matrix.json index 88592a0..6605f4c 100644 --- a/internal_dependency_matrix.json +++ b/internal_dependency_matrix.json @@ -13,7 +13,7 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "BinancePlatform", @@ -27,14 +27,14 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "CharlesSchwabPlatform", "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "CharlesSchwabPlatform", @@ -48,7 +48,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "CharlesSchwabPlatform", @@ -76,14 +76,14 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "CnEquityStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "CryptoLivePoolPipelines", @@ -118,21 +118,21 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "CryptoStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "FirstradePlatform", "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "FirstradePlatform", @@ -146,7 +146,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "FirstradePlatform", @@ -174,14 +174,14 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "HkEquityStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -195,7 +195,7 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -216,7 +216,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "InteractiveBrokersPlatform", @@ -237,7 +237,7 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "LongBridgePlatform", @@ -258,7 +258,7 @@ "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "LongBridgePlatform", @@ -272,28 +272,28 @@ "path": "pyproject.toml", "package": "cn-equity-strategies", "source_repo": "CnEquityStrategies", - "ref": "73844e92a8570a61e5a9dc6c245809d0b27b89bc" + "ref": "12c0cd4801060fcb2f9452ffd9a7f48df446ddd0" }, { "consumer_repo": "QmtPlatform", "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "37c81901160c5b31127a27dba1c63944933fb6bf" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "QmtPlatform", "path": "uv.lock", "package": "cn-equity-strategies", "source_repo": "CnEquityStrategies", - "ref": "73844e92a8570a61e5a9dc6c245809d0b27b89bc" + "ref": "12c0cd4801060fcb2f9452ffd9a7f48df446ddd0" }, { "consumer_repo": "QmtPlatform", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "37c81901160c5b31127a27dba1c63944933fb6bf" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "UsEquitySnapshotPipelines", @@ -342,14 +342,14 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" }, { "consumer_repo": "UsEquityStrategies", "path": "uv.lock", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "53b2ca73a5a50257b5d1a3c769b75c40924e4ba6" + "ref": "69a0256934d081b5ef309a885384b9eb9f62cf90" } ] } diff --git a/python/scripts/qslctl.py b/python/scripts/qslctl.py index 3bfe7b1..7ebe1c9 100644 --- a/python/scripts/qslctl.py +++ b/python/scripts/qslctl.py @@ -187,6 +187,14 @@ def _status_bucket(result: RepoCheckResult) -> str: def _issue_kind(message: str) -> str: + if message.startswith("invalid qsl.tier "): + return "invalid qsl.tier" + if message.startswith("invalid qsl.upgrade_ring "): + return "invalid qsl.upgrade_ring" + if message.startswith("tier/ring mismatch "): + return "tier/ring mismatch" + if message.startswith("forbidden dependency direction "): + return "forbidden dependency direction" if message.startswith("bundle pin mismatch for "): return "bundle pin mismatch" if message.startswith("forbidden short/invalid ref "): @@ -197,6 +205,10 @@ def _issue_kind(message: str) -> str: return "legacy dependency files detected" if message.startswith("unmanaged qsl dependency "): return "unmanaged qsl dependency" + if message.startswith("non-canonical qsl.tier "): + return "non-canonical qsl.tier" + if message.startswith("non-canonical qsl.upgrade_ring "): + return "non-canonical qsl.upgrade_ring" return "other" diff --git a/python/tests/test_qsl_compat_checker.py b/python/tests/test_qsl_compat_checker.py index ce0c55d..026dbfa 100644 --- a/python/tests/test_qsl_compat_checker.py +++ b/python/tests/test_qsl_compat_checker.py @@ -30,9 +30,42 @@ def _write_bundle(self, compat_root: Path, bundle_name: str, repos: dict[str, st block.append(f'{repo} = "{ref}"') bundle_path.write_text("\n".join(block) + "\n", encoding="utf-8") + def _write_repo_tiers(self, compat_root: Path) -> None: + path = compat_root / "compat" / "repo-tiers.toml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "[tiers]\n" + 'core = { name = "core" }\n' + 'strategy_lib = { name = "strategy-lib" }\n' + 'pipeline = { name = "pipeline" }\n' + 'runtime = { name = "runtime" }\n' + 'ops = { name = "ops/tooling" }\n' + "\n[upgrade_rings]\n" + 'ring_a = "core"\n' + 'ring_b = "strategy-lib"\n' + 'ring_c = "pipeline"\n' + 'ring_d = "runtime"\n' + 'ring_e = "ops/tooling"\n' + "\n[upgrade_rules]\n" + "allow_drift = [\n" + ' "ops/tooling:runtime",\n' + ' "ops/tooling:pipeline",\n' + ' "ops/tooling:strategy-lib",\n' + ' "ops/tooling:core",\n' + ' "runtime:pipeline",\n' + ' "runtime:strategy-lib",\n' + ' "runtime:core",\n' + ' "pipeline:strategy-lib",\n' + ' "pipeline:core",\n' + ' "strategy-lib:core",\n' + "]\n", + encoding="utf-8", + ) + def test_root_compat_bundle_and_ring_schema(self): with tempfile.TemporaryDirectory() as workspace: compat_root = Path(workspace) + self._write_repo_tiers(compat_root) self._write_bundle( compat_root, "2026.07.2", @@ -63,6 +96,7 @@ def test_root_compat_bundle_and_ring_schema(self): def test_not_enforced_bundle_reports_warning_for_short_sha_and_mismatch_but_main_stays_issue(self): with tempfile.TemporaryDirectory() as workspace: compat_root = Path(workspace) + self._write_repo_tiers(compat_root) self._write_bundle( compat_root, "2026.07.2", @@ -103,6 +137,7 @@ def test_not_enforced_bundle_reports_warning_for_short_sha_and_mismatch_but_main def test_live_constraint_files_allow_full_sha_drift_from_bundle(self): with tempfile.TemporaryDirectory() as workspace: compat_root = Path(workspace) + self._write_repo_tiers(compat_root) self._write_bundle( compat_root, "2026.07.2", @@ -111,7 +146,7 @@ def test_live_constraint_files_allow_full_sha_drift_from_bundle(self): repo_root = self._make_repo_root( qsl_toml=( 'tier = "core"\n' - "ring = 0\n" + 'upgrade_ring = "ring_a"\n' "allow_legacy = true\n" "[compat]\n" 'bundle = "2026.07.2"\n' @@ -135,6 +170,7 @@ def test_live_constraint_files_allow_full_sha_drift_from_bundle(self): def test_live_constraint_files_still_block_short_refs(self): with tempfile.TemporaryDirectory() as workspace: compat_root = Path(workspace) + self._write_repo_tiers(compat_root) self._write_bundle( compat_root, "2026.07.2", @@ -143,7 +179,7 @@ def test_live_constraint_files_still_block_short_refs(self): repo_root = self._make_repo_root( qsl_toml=( 'tier = "core"\n' - "ring = 0\n" + 'upgrade_ring = "ring_a"\n' "allow_legacy = true\n" "[compat]\n" 'bundle = "2026.07.2"\n' @@ -167,6 +203,7 @@ def test_live_constraint_files_still_block_short_refs(self): def test_not_enforced_bundle_exception_metadata_notes(self): with tempfile.TemporaryDirectory() as workspace: compat_root = Path(workspace) + self._write_repo_tiers(compat_root) self._write_bundle( compat_root, "2026.07.2", @@ -175,7 +212,7 @@ def test_not_enforced_bundle_exception_metadata_notes(self): repo_root = self._make_repo_root( qsl_toml=( 'tier = "pipeline"\n' - "ring = 2\n" + 'upgrade_ring = "ring_c"\n' 'owner = "pipeline-team"\n' 'expires_at = "2099-12-31"\n' 'next_action = "remove transition pin drift"\n' @@ -198,6 +235,7 @@ def test_not_enforced_bundle_exception_metadata_notes(self): def test_legacy_reason_suppresses_allowed_legacy_warning(self): with tempfile.TemporaryDirectory() as workspace: compat_root = Path(workspace) + self._write_repo_tiers(compat_root) self._write_bundle( compat_root, "2026.07.2", @@ -206,7 +244,7 @@ def test_legacy_reason_suppresses_allowed_legacy_warning(self): repo_root = self._make_repo_root( qsl_toml=( 'tier = "pipeline"\n' - "ring = 2\n" + 'upgrade_ring = "ring_c"\n' "allow_legacy = true\n" 'legacy_reason = "runtime deployment compatibility"\n' 'owner = "runtime-team"\n' @@ -232,6 +270,104 @@ def test_legacy_reason_suppresses_allowed_legacy_warning(self): self.assertIn("bundle pin mismatch", warnings[0]) self.assertIn("legacy_reason=runtime deployment compatibility", notes) + def test_non_canonical_tier_and_ring_emit_warnings(self): + with tempfile.TemporaryDirectory() as workspace: + compat_root = Path(workspace) + self._write_repo_tiers(compat_root) + self._write_bundle( + compat_root, + "2026.07.2", + {"QuantPlatformKit": "37c81901160c5b31127a27dba1c63944933fb6bf"}, + ) + repo_root = self._make_repo_root( + qsl_toml=( + 'tier = "strategy-library"\n' + "ring = 1\n" + "[compat]\n" + 'bundle = "2026.07.2"\n' + ), + pyproject=( + 'dependencies = ["quant-platform-kit @ ' + 'git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@37c81901160c5b31127a27dba1c63944933fb6bf"]\n' + ), + ) + + ok, issues, warnings, _notes = check_qsl_compat._check(repo_root=repo_root, compat_root=compat_root) + + self.assertTrue(ok) + self.assertEqual(issues, []) + self.assertTrue(any("non-canonical qsl.tier 'strategy-library'" in item for item in warnings)) + self.assertTrue(any("non-canonical qsl.upgrade_ring '1'" in item for item in warnings)) + + def test_runtime_platform_alias_maps_to_runtime_without_direction_error(self): + with tempfile.TemporaryDirectory() as workspace: + root = Path(workspace) + compat_root = root / "QuantRuntimeSettings" + self._write_repo_tiers(compat_root) + self._write_bundle( + compat_root, + "2026.07.2", + {"QuantPlatformKit": "37c81901160c5b31127a27dba1c63944933fb6bf"}, + ) + (root / "QuantPlatformKit").mkdir(parents=True, exist_ok=True) + (root / "QuantPlatformKit" / "qsl.toml").write_text( + 'tier = "core"\nupgrade_ring = "ring_a"\n[compat]\nbundle = "2026.07.2"\n', + encoding="utf-8", + ) + repo_root = self._make_repo_root( + qsl_toml=( + 'tier = "runtime-platform"\n' + "ring = 3\n" + "[compat]\n" + 'bundle = "2026.07.2"\n' + ), + pyproject=( + 'dependencies = ["quant-platform-kit @ ' + 'git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@37c81901160c5b31127a27dba1c63944933fb6bf"]\n' + ), + ) + + ok, issues, warnings, _notes = check_qsl_compat._check(repo_root=repo_root, compat_root=compat_root) + + self.assertTrue(ok) + self.assertEqual(issues, []) + self.assertTrue(any("non-canonical qsl.tier 'runtime-platform'" in item for item in warnings)) + self.assertTrue(any("non-canonical qsl.upgrade_ring '3'" in item for item in warnings)) + + def test_forbidden_dependency_direction_is_reported(self): + with tempfile.TemporaryDirectory() as workspace: + root = Path(workspace) + compat_root = root / "QuantRuntimeSettings" + self._write_repo_tiers(compat_root) + self._write_bundle( + compat_root, + "2026.07.2", + {"UsEquityStrategies": "17ddb86c72d44b2c7b78ba7a10d8f71b21180166"}, + ) + (root / "UsEquityStrategies").mkdir(parents=True, exist_ok=True) + (root / "UsEquityStrategies" / "qsl.toml").write_text( + 'tier = "strategy-lib"\nupgrade_ring = "ring_b"\n[compat]\nbundle = "2026.07.2"\n', + encoding="utf-8", + ) + repo_root = self._make_repo_root( + qsl_toml=( + 'tier = "core"\n' + 'upgrade_ring = "ring_a"\n' + "[compat]\n" + 'bundle = "2026.07.2"\n' + ), + pyproject=( + 'dependencies = ["us-equity-strategies @ ' + 'git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@17ddb86c72d44b2c7b78ba7a10d8f71b21180166"]\n' + ), + ) + + ok, issues, warnings, _notes = check_qsl_compat._check(repo_root=repo_root, compat_root=compat_root) + + self.assertFalse(ok) + self.assertEqual(warnings, []) + self.assertTrue(any("forbidden dependency direction" in item for item in issues)) + if __name__ == "__main__": unittest.main() diff --git a/python/tests/test_qslctl.py b/python/tests/test_qslctl.py index 2cd07a6..9f99f32 100644 --- a/python/tests/test_qslctl.py +++ b/python/tests/test_qslctl.py @@ -24,11 +24,12 @@ def test_check_all_reports_repo_issues(self) -> None: with tempfile.TemporaryDirectory() as workspace: root = Path(workspace) compat_root = root / "QuantRuntimeSettings" + self._write_repo_tiers(compat_root) self._write_bundle(compat_root, "2026.07.2", {"QuantPlatformKit": "37c81901160c5b31127a27dba1c63944933fb6bf"}) good = root / "GoodRepo" bad = root / "BadRepo" - self._write_repo(good, "2026.07.2", "37c81901160c5b31127a27dba1c63944933fb6bf") - self._write_repo(bad, "2026.07.2", "b" * 40) + self._write_repo(good, "2026.07.2", "37c81901160c5b31127a27dba1c63944933fb6bf", tier="strategy-lib", ring="ring_b") + self._write_repo(bad, "2026.07.2", "b" * 40, tier="strategy-lib", ring="ring_b") with patch.object(qslctl, "_is_quant_repo", return_value=True): results = qslctl.check_all(projects_root=root, compat_root=compat_root) @@ -38,6 +39,40 @@ def test_check_all_reports_repo_issues(self) -> None: self.assertFalse(by_repo["BadRepo"].ok) self.assertIn("bundle pin mismatch", by_repo["BadRepo"].issues[0]) + def test_check_all_reports_forbidden_dependency_direction(self) -> None: + with tempfile.TemporaryDirectory() as workspace: + root = Path(workspace) + compat_root = root / "QuantRuntimeSettings" + self._write_repo_tiers(compat_root) + self._write_bundle( + compat_root, + "2026.07.2", + {"UsEquityStrategies": "17ddb86c72d44b2c7b78ba7a10d8f71b21180166"}, + ) + self._write_repo( + root / "QuantPlatformKit", + "2026.07.2", + "17ddb86c72d44b2c7b78ba7a10d8f71b21180166", + package="us-equity-strategies", + source_repo="UsEquityStrategies", + tier="core", + ring="ring_a", + ) + self._write_repo( + root / "UsEquityStrategies", + "2026.07.2", + "a" * 40, + tier="strategy-lib", + ring="ring_b", + ) + + with patch.object(qslctl, "_is_quant_repo", return_value=True): + results = qslctl.check_all(projects_root=root, compat_root=compat_root) + + by_repo = {result.repo: result for result in results} + self.assertFalse(by_repo["QuantPlatformKit"].ok) + self.assertTrue(any("forbidden dependency direction" in item for item in by_repo["QuantPlatformKit"].issues)) + def test_report_groups_repositories_by_ring(self) -> None: with tempfile.TemporaryDirectory() as workspace: root = Path(workspace) @@ -139,7 +174,13 @@ def _write_repo_tiers(self, compat_root: Path) -> None: path = compat_root / "compat" / "repo-tiers.toml" path.parent.mkdir(parents=True, exist_ok=True) path.write_text( - "[upgrade_rings]\n" + "[tiers]\n" + 'core = { name = "core" }\n' + 'strategy_lib = { name = "strategy-lib" }\n' + 'pipeline = { name = "pipeline" }\n' + 'runtime = { name = "runtime" }\n' + 'ops = { name = "ops/tooling" }\n' + "\n[upgrade_rings]\n" 'ring_a = "core"\n' 'ring_b = "strategy-lib"\n' 'ring_c = "pipeline"\n' @@ -154,18 +195,20 @@ def _write_repo( bundle: str, ref: str, *, - tier: str = "strategy-library", - ring: str = "1", + package: str = "quant-platform-kit", + source_repo: str = "QuantPlatformKit", + tier: str = "strategy-lib", + ring: str = "ring_b", enforce_bundle: bool = True, ) -> None: repo_root.mkdir(parents=True, exist_ok=True) (repo_root / "qsl.toml").write_text( - f'tier = "{tier}"\nring = "{ring}"\n[compat]\nbundle = "{bundle}"\n' + f'tier = "{tier}"\nupgrade_ring = "{ring}"\n[compat]\nbundle = "{bundle}"\n' f'enforce_bundle = {"true" if enforce_bundle else "false"}\n', encoding="utf-8", ) (repo_root / "pyproject.toml").write_text( - f'dependencies = ["quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@{ref}"]\n', + f'dependencies = ["{package} @ git+https://github.com/QuantStrategyLab/{source_repo}.git@{ref}"]\n', encoding="utf-8", ) diff --git a/qsl.toml b/qsl.toml index d252291..9c26a87 100644 --- a/qsl.toml +++ b/qsl.toml @@ -1,8 +1,8 @@ [qsl] # QuantRuntimeSettings 作为 QSL 中央仓库 repo = "QuantRuntimeSettings" -bundle = "2026.07.2" -compat = "2026.07.2" +bundle = "2026.07.3" +compat = "2026.07.3" tier = "ops/tooling" upgrade_ring = "ring_e" allow_legacy = false diff --git a/scripts/check_qsl_compat.py b/scripts/check_qsl_compat.py index 1a1a1ef..a46be71 100755 --- a/scripts/check_qsl_compat.py +++ b/scripts/check_qsl_compat.py @@ -18,6 +18,23 @@ GITHUB_OWNER = "QuantStrategyLab" +LEGACY_TIER_ALIASES = { + "strategy-library": "strategy-lib", + "strategy_lib": "strategy-lib", + "runtime-platform": "runtime", + "runtime_plaform": "runtime", + "runtime-ops": "ops/tooling", + "platform-tooling": "ops/tooling", + "ops-tooling": "ops/tooling", + "research": "pipeline", +} +LEGACY_RING_ALIASES = { + "0": "ring_a", + "1": "ring_b", + "2": "ring_c", + "3": "ring_d", + "4": "ring_e", +} @dataclass(frozen=True) @@ -144,6 +161,133 @@ def _load_bundle(compat_root: Path, bundle: str) -> dict[str, str]: return parsed +def _load_repo_tier_policy(compat_root: Path) -> tuple[set[str], dict[str, str], set[tuple[str, str]]]: + manifest_path = compat_root / "compat" / "repo-tiers.toml" + payload = _read_toml(manifest_path) + + tiers = payload.get("tiers") + if not isinstance(tiers, dict) or not tiers: + raise ValueError(f"repo tier manifest missing [tiers] in {manifest_path}") + canonical_tiers: set[str] = set() + for name, meta in tiers.items(): + if isinstance(meta, dict) and str(meta.get("name") or "").strip(): + canonical_tiers.add(str(meta["name"]).strip()) + elif isinstance(name, str) and name.strip(): + canonical_tiers.add(name.strip()) + + upgrade_rings = payload.get("upgrade_rings") + if not isinstance(upgrade_rings, dict) or not upgrade_rings: + raise ValueError(f"repo tier manifest missing [upgrade_rings] in {manifest_path}") + ring_to_tier: dict[str, str] = {} + for ring, tier in upgrade_rings.items(): + ring_name = str(ring or "").strip() + tier_name = str(tier or "").strip() + if not ring_name or not tier_name: + continue + ring_to_tier[ring_name] = tier_name + + allowed_directions: set[tuple[str, str]] = set() + upgrade_rules = payload.get("upgrade_rules") + if isinstance(upgrade_rules, dict): + allow_drift = upgrade_rules.get("allow_drift") + if isinstance(allow_drift, list): + for item in allow_drift: + consumer_tier, _, dependency_tier = str(item or "").partition(":") + consumer_name = consumer_tier.strip() + dependency_name = dependency_tier.strip() + if consumer_name and dependency_name: + allowed_directions.add((consumer_name, dependency_name)) + + return canonical_tiers, ring_to_tier, allowed_directions + + +def _canonical_tier(value: str) -> str: + normalized = value.strip() + return LEGACY_TIER_ALIASES.get(normalized, normalized) + + +def _canonical_ring(value: str) -> str: + normalized = value.strip().lower() + return LEGACY_RING_ALIASES.get(normalized, value.strip()) + + +def _resolve_repo_declared_tier(repo_root: Path) -> str | None: + qsl_path = repo_root / "qsl.toml" + if not qsl_path.exists(): + return None + try: + payload = _read_toml(qsl_path) + except Exception: + return None + config = payload.get("qsl", payload) + if not isinstance(config, dict): + return None + tier = config.get("tier") + if not isinstance(tier, str) or not tier.strip(): + return None + return _canonical_tier(tier) + + +def _validate_repo_taxonomy( + *, + repo_root: Path, + compat_root: Path, + tier: str, + upgrade_ring: str, + issues: list[str], + warnings: list[str], +) -> str: + canonical_tiers, ring_to_tier, _ = _load_repo_tier_policy(compat_root) + canonical_tier = _canonical_tier(tier) + canonical_ring = _canonical_ring(upgrade_ring) + + if canonical_tier not in canonical_tiers: + issues.append( + f"invalid qsl.tier '{tier}' in {repo_root / 'qsl.toml'}; allowed: {', '.join(sorted(canonical_tiers))}" + ) + return canonical_tier + if canonical_ring not in ring_to_tier: + issues.append( + f"invalid qsl.upgrade_ring '{upgrade_ring}' in {repo_root / 'qsl.toml'}; allowed: {', '.join(sorted(ring_to_tier))}" + ) + return canonical_tier + + expected_tier = ring_to_tier[canonical_ring] + if canonical_tier != expected_tier: + issues.append( + f"tier/ring mismatch in {repo_root / 'qsl.toml'}: tier={tier} upgrade_ring={upgrade_ring} " + f"resolves to {canonical_tier}/{canonical_ring}, expected tier {expected_tier}" + ) + if tier.strip() != canonical_tier: + warnings.append(f"non-canonical qsl.tier '{tier}' in {repo_root / 'qsl.toml'}; use '{canonical_tier}'") + if upgrade_ring.strip() != canonical_ring: + warnings.append( + f"non-canonical qsl.upgrade_ring '{upgrade_ring}' in {repo_root / 'qsl.toml'}; use '{canonical_ring}'" + ) + return canonical_tier + + +def _validate_dependency_direction( + *, + consumer_repo: str, + consumer_tier: str, + pin: GitRef, + compat_root: Path, + issues: list[str], +) -> None: + _, _, allowed_directions = _load_repo_tier_policy(compat_root) + dependency_repo_root = compat_root.parent / pin.repo + dependency_tier = _resolve_repo_declared_tier(dependency_repo_root) + if not dependency_tier or dependency_tier == consumer_tier: + return + if (consumer_tier, dependency_tier) in allowed_directions: + return + issues.append( + f"forbidden dependency direction {consumer_repo}({consumer_tier}) -> " + f"{pin.repo}({dependency_tier}) in {pin.source}:{pin.line_no}" + ) + + def _extract_git_refs(path: Path) -> list[GitRef]: refs: list[GitRef] = [] if not path.exists(): @@ -228,6 +372,15 @@ def _check(repo_root: Path, compat_root: Path) -> tuple[bool, list[str], list[st warnings=warnings, ) + canonical_tier = _validate_repo_taxonomy( + repo_root=repo_root, + compat_root=compat_root, + tier=tier, + upgrade_ring=upgrade_ring, + issues=issues, + warnings=warnings, + ) + bundle_refs = _load_bundle(compat_root, bundle) if not allow_legacy: @@ -251,6 +404,13 @@ def _check(repo_root: Path, compat_root: Path) -> tuple[bool, list[str], list[st _validate_live_ref(legacy_ref, issues, warnings, enforce_bundle) else: _validate_ref(legacy_ref, bundle_refs[legacy_ref.repo], issues, warnings, enforce_bundle) + _validate_dependency_direction( + consumer_repo=repo_root.name, + consumer_tier=canonical_tier, + pin=legacy_ref, + compat_root=compat_root, + issues=issues, + ) discovered = _gather_repo_refs(repo_root) for pin in discovered: @@ -259,6 +419,13 @@ def _check(repo_root: Path, compat_root: Path) -> tuple[bool, list[str], list[st continue expected_ref = bundle_refs[pin.repo] _validate_ref(pin, expected_ref, issues, warnings, enforce_bundle) + _validate_dependency_direction( + consumer_repo=repo_root.name, + consumer_tier=canonical_tier, + pin=pin, + compat_root=compat_root, + issues=issues, + ) return (len(issues) == 0, issues, warnings, notes)