From b6f7fcd561fd92363f396c3ad20a24e8e5496c94 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:36:33 +0800 Subject: [PATCH 1/4] Add phase-1 QSL manifest and compatibility tooling Co-Authored-By: Codex --- README.md | 12 ++ compat/bundles/2026.07.0.toml | 12 ++ compat/repo-tiers.toml | 29 +++ docs/qsl_compat_upgrade.md | 56 +++++ qsl.toml | 8 + scripts/check_qsl_compat.py | 271 +++++++++++++++++++++++++ scripts/render_qsl_dependency_graph.py | 160 +++++++++++++++ 7 files changed, 548 insertions(+) create mode 100644 compat/bundles/2026.07.0.toml create mode 100644 compat/repo-tiers.toml create mode 100644 docs/qsl_compat_upgrade.md create mode 100644 qsl.toml create mode 100755 scripts/check_qsl_compat.py create mode 100755 scripts/render_qsl_dependency_graph.py diff --git a/README.md b/README.md index b9420b7..54bc12c 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,23 @@ It supports the system but does not decide which strategy should be live. Strate - `python/`: Python tooling (scripts, tests, pyproject.toml) — validation, code generation, deployment scripts. - `web/`: JavaScript web app (Cloudflare Workers strategy switch console). +- `compat/`: QSL compatibility manifests (bundle matrix and repo tier rules). - `schemas/`: JSON Schema files shared by both Python and JS. - `tests/`: JavaScript unit/integration tests. - `.github/workflows/`: CI, scheduled jobs, release, or deployment workflows. - `docs/ARCHITECTURE.md`: Detailed architecture documentation. +## QSL compatibility manifest (phase 1) + +This repository is the central manifest source for QSL compatibility checks. + +- `qsl.toml`: current repo QSL metadata (`tier`, `upgrade_ring`, `bundle`). +- `scripts/check_qsl_compat.py`: validate a repo's QSL compliance in `pyproject.toml`/`uv.lock` against central bundle. +- `scripts/render_qsl_dependency_graph.py`: render dependency graph for review. +- `compat/bundles/2026.07.0.toml`: first compatibility bundle (CalVer). +- `compat/repo-tiers.toml`: repo tier and upgrade ring policy notes. +- [QSL compatibility docs](docs/qsl_compat_upgrade.md) + ## Quick start ```bash diff --git a/compat/bundles/2026.07.0.toml b/compat/bundles/2026.07.0.toml new file mode 100644 index 0000000..ca90b02 --- /dev/null +++ b/compat/bundles/2026.07.0.toml @@ -0,0 +1,12 @@ +name = "2026.07.0" +description = "Phase-1 QSL compatibility bundle" +created_at = "2026-07-03" + +[repos] +QuantPlatformKit = "0063af3b4a974650ea58a7d3f26dd1b94f65d3e8" +UsEquityStrategies = "46887bc3f5454d5b59623b1f5efb7c65912c6b8b" +HkEquityStrategies = "61993bf261aeccf64b5a75428b9405f4e1d1d682" +CnEquityStrategies = "ffbdf7303179ba6e7f9d3e28c21202f77e04762c" +CryptoStrategies = "8039ddddde7634ad3615496c9b79d2918996938c" +QuantStrategyPlugins = "6c10aef8989f52d82571e676092b14d315401989" +MarketSignalSources = "ea8771b6b65f356e7edc4a2b08cd621186726646" diff --git a/compat/repo-tiers.toml b/compat/repo-tiers.toml new file mode 100644 index 0000000..3926e26 --- /dev/null +++ b/compat/repo-tiers.toml @@ -0,0 +1,29 @@ +[tiers] +core = { name = "core", description = "平台最小闭环、运行核心与共享基础设施" } +strategy_lib = { name = "strategy-lib", description = "策略库、插件与市场信号源" } +pipeline = { name = "pipeline", description = "策略研发与快照流水线" } +runtime = { name = "runtime", description = "平台运行时与执行服务" } +ops = { name = "ops/tooling", description = "运维/发布与中央配置仓库" } + +[upgrade_rings] +ring_a = "core" +ring_b = "strategy-lib" +ring_c = "pipeline" +ring_d = "runtime" +ring_e = "ops/tooling" + +[upgrade_rules] +# 允许上升方向:稳定性从低到高(ops/tooling -> runtime -> pipeline -> strategy-lib -> core) +# 建议原则:较外层可回退引用内层,内层通常不直接引用同级以上的低稳定性 ring。 +allow_drift = [ + "ops/tooling:runtime", + "ops/tooling:pipeline", + "ops/tooling:strategy-lib", + "ops/tooling:core", + "runtime:pipeline", + "runtime:strategy-lib", + "runtime:core", + "pipeline:strategy-lib", + "pipeline:core", + "strategy-lib:core", +] diff --git a/docs/qsl_compat_upgrade.md b/docs/qsl_compat_upgrade.md new file mode 100644 index 0000000..779e686 --- /dev/null +++ b/docs/qsl_compat_upgrade.md @@ -0,0 +1,56 @@ +# QSL Compatibility & Upgrade Checklist (Central Manifest Phase-1) + +This repository carries the first version of the QSL central compatibility manifest. + +## 文件结构 + +- `compat/bundles/*.toml` + - 每个 bundle 用 Calendar Version 命名(当前:`2026.07.0`)。 + - 记录 QSL 管控的固定内部仓库提交。 +- `compat/repo-tiers.toml` + - 记录仓库层级(`core/strategy-lib/pipeline/runtime/ops`)与升级 ring 规则。 +- `qsl.toml` + - 本仓库自身的 QSL 元信息:`tier`、`compat`/`bundle`、`upgrade_ring`。 +- `scripts/check_qsl_compat.py` + - 在任意仓库根目录运行,校验: + - 禁止 `@main` + - 禁止短 SHA + - 禁止 `requirements.txt` / `constraints.txt`(未设置 `allow_legacy=true` 时) + - 内部依赖 Ref 是否与 `compat/bundles/.toml` 一致 +- `scripts/render_qsl_dependency_graph.py` + - 输出当前仓库的 QSL 依赖图(Markdown / Text)。 + +## 如何接入一个新仓库 + +1. 在仓库根目录新建 `qsl.toml`: + +```toml +[qsl] +bundle = "2026.07.0" # 选择要对齐的 central bundle +compat = "2026.07.0" # 兼容检查入口(与 bundle 相同) +tier = "ops/tooling" +upgrade_ring = "ring_e" +allow_legacy = false # 需要临时兼容时可先放开 +``` + +2. 在 `pyproject.toml`/`uv.lock` 中用完整 SHA 固定 QuantStrategyLab 的内部 git 依赖。 + +3. 运行自检: + +```bash +python scripts/check_qsl_compat.py --repo-root . --non-strict +python scripts/render_qsl_dependency_graph.py --repo-root . --format md +``` + +说明:默认脚本会输出错误并以非零退出码返回;`--non-strict` 仅用于本地快速预览。 + +## 当前中心兼容基线 + +- Bundle: `2026.07.0` +- QPK: `0063af3b4a974650ea58a7d3f26dd1b94f65d3e8` +- UsEquityStrategies: `46887bc3f5454d5b59623b1f5efb7c65912c6b8b` +- HkEquityStrategies: `61993bf261aeccf64b5a75428b9405f4e1d1d682` +- CnEquityStrategies: `ffbdf7303179ba6e7f9d3e28c21202f77e04762c` +- CryptoStrategies: `8039ddddde7634ad3615496c9b79d2918996938c` +- QuantStrategyPlugins: `6c10aef8989f52d82571e676092b14d315401989` +- MarketSignalSources: `ea8771b6b65f356e7edc4a2b08cd621186726646` diff --git a/qsl.toml b/qsl.toml new file mode 100644 index 0000000..b265c88 --- /dev/null +++ b/qsl.toml @@ -0,0 +1,8 @@ +[qsl] +# QuantRuntimeSettings 作为 QSL 中央仓库 +repo = "QuantRuntimeSettings" +bundle = "2026.07.0" +compat = "2026.07.0" +tier = "ops/tooling" +upgrade_ring = "ring_e" +allow_legacy = false diff --git a/scripts/check_qsl_compat.py b/scripts/check_qsl_compat.py new file mode 100755 index 0000000..941ce08 --- /dev/null +++ b/scripts/check_qsl_compat.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Validate repository QSL compatibility policy against a central bundle manifest.""" + +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - py<3.11 compatibility fallback + import toml as tomllib # type: ignore[import-not-found] + + +GITHUB_OWNER = "QuantStrategyLab" + + +@dataclass(frozen=True) +class GitRef: + repo: str + ref: str + source: str + line_no: int | None + + +PATTERNS = ( + re.compile( + r"git\+https://github\.com/" + re.escape(GITHUB_OWNER) + r"/(?P[A-Za-z0-9_.-]+)\.git@(?P[A-Za-z0-9_.-]+)" + ), + re.compile( + r"https://github\.com/" + re.escape(GITHUB_OWNER) + r"/(?P[A-Za-z0-9_.-]+)\.git\?rev=(?P[A-Za-z0-9_.-]+)" + ), +) + + +def _read_toml(path: Path) -> dict[str, Any]: + with path.open("rb") as handle: + return tomllib.load(handle) + + +def _load_qsl_config(repo_root: Path) -> dict[str, str | bool]: + qsl_path = repo_root / "qsl.toml" + if not qsl_path.exists(): + raise FileNotFoundError(f"missing qsl.toml: {qsl_path}") + + payload = _read_toml(qsl_path) + config = payload.get("qsl", payload) + if not isinstance(config, dict): + raise TypeError("qsl section must be a table") + + bundle = config.get("bundle") or config.get("compat") + if not isinstance(bundle, str) or not bundle.strip(): + raise ValueError("qsl.bundle (or qsl.compat) is required") + + tier = config.get("tier") + if not isinstance(tier, str) or not tier.strip(): + raise ValueError("qsl.tier is required") + + upgrade_ring = config.get("upgrade_ring") + if not isinstance(upgrade_ring, str) or not upgrade_ring.strip(): + raise ValueError("qsl.upgrade_ring is required") + + allow_legacy = bool(config.get("allow_legacy", False)) + + return { + "bundle": bundle.strip(), + "tier": tier.strip(), + "upgrade_ring": str(upgrade_ring).strip(), + "allow_legacy": allow_legacy, + "qsl_path": qsl_path.as_posix(), + } + + +def _load_bundle(compat_root: Path, bundle: str) -> dict[str, str]: + bundle_path = compat_root / "compat" / "bundles" / f"{bundle}.toml" + if not bundle_path.exists(): + raise FileNotFoundError(f"bundle manifest not found: {bundle_path}") + + payload = _read_toml(bundle_path) + repos = payload.get("repos") + if not isinstance(repos, dict) or not repos: + raise ValueError(f"bundle manifest missing [repos] in {bundle_path}") + + parsed: dict[str, str] = {} + for repo_name, ref in repos.items(): + if not isinstance(repo_name, str) or not repo_name.strip(): + continue + if not isinstance(ref, str) or not ref.strip(): + raise ValueError(f"bundle manifest invalid ref for {repo_name}") + parsed[repo_name.strip()] = ref.strip() + + return parsed + + +def _extract_git_refs(path: Path) -> list[GitRef]: + refs: list[GitRef] = [] + if not path.exists(): + return refs + + for index, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): + for pattern in PATTERNS: + for match in pattern.finditer(line): + refs.append( + GitRef( + repo=match.group("repo"), + ref=match.group("ref"), + source=f"{path.name}", + line_no=index, + ) + ) + + return refs + + +def _gather_repo_refs(repo_root: Path) -> list[GitRef]: + refs = [] + refs.extend(_extract_git_refs(repo_root / "pyproject.toml")) + refs.extend(_extract_git_refs(repo_root / "uv.lock")) + return refs + + +def _gather_legacy_refs(repo_root: Path) -> list[GitRef]: + refs = [] + for legacy in ("requirements.txt", "constraints.txt"): + refs.extend(_extract_git_refs(repo_root / legacy)) + return refs + + +def _is_full_sha(value: str) -> bool: + return bool(re.fullmatch(r"[0-9a-f]{40}", value.lower())) + + +def _is_main_ref(value: str) -> bool: + return value.lower() == "main" + + +def _check(repo_root: Path, compat_root: Path) -> tuple[bool, list[str], list[str], list[str]]: + issues: list[str] = [] + warnings: list[str] = [] + notes: list[str] = [] + + qsl_cfg = _load_qsl_config(repo_root) + bundle = str(qsl_cfg["bundle"]) + tier = str(qsl_cfg["tier"]) + upgrade_ring = str(qsl_cfg["upgrade_ring"]) + allow_legacy = bool(qsl_cfg["allow_legacy"]) + qsl_path = str(qsl_cfg["qsl_path"]) + + notes.append(f"qsl={qsl_path}") + notes.append(f"bundle={bundle}") + notes.append(f"tier={tier}") + notes.append(f"upgrade_ring={upgrade_ring}") + + bundle_refs = _load_bundle(compat_root, bundle) + + if not allow_legacy: + for legacy_file in ("requirements.txt", "constraints.txt"): + if (repo_root / legacy_file).exists(): + issues.append(f"legacy file forbidden: {legacy_file}") + else: + legacy_refs = _gather_legacy_refs(repo_root) + if legacy_refs: + warnings.append("legacy dependency files detected but allowed by qsl.allow_legacy=true") + for legacy_ref in legacy_refs: + if legacy_ref.repo not in bundle_refs: + issues.append( + f"unmanaged qsl dependency in {legacy_ref.source}:{legacy_ref.line_no}: " + f"{legacy_ref.repo}@{legacy_ref.ref}" + ) + continue + _validate_ref(legacy_ref, bundle_refs[legacy_ref.repo], issues) + + discovered = _gather_repo_refs(repo_root) + for pin in discovered: + if pin.repo not in bundle_refs: + issues.append(f"unmanaged qsl dependency in {pin.source}:{pin.line_no}: {pin.repo}@{pin.ref}") + continue + expected_ref = bundle_refs[pin.repo] + _validate_ref(pin, expected_ref, issues) + + return (len(issues) == 0, issues, warnings, notes) + + +def _validate_ref(pin: GitRef, expected_ref: str, issues: list[str]) -> None: + if _is_main_ref(pin.ref): + issues.append(f"forbidden ref 'main' in {pin.source}:{pin.line_no}: {pin.repo}") + return + if not _is_full_sha(pin.ref): + issues.append(f"forbidden short/invalid ref '{pin.ref}' in {pin.source}:{pin.line_no}: {pin.repo}") + return + if pin.ref != expected_ref: + issues.append( + f"bundle pin mismatch for {pin.repo} in {pin.source}:{pin.line_no}: " + f"expected {expected_ref}, found {pin.ref}" + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Check repo QSL compatibility against the central manifest.") + parser.add_argument("--repo-root", default=".", type=Path, help="Repository root to check") + parser.add_argument( + "--compat-root", + type=Path, + default=None, + help="Path to QuantRuntimeSettings checkout (defaults to checker script parent)", + ) + parser.add_argument("--json", action="store_true", help="Emit JSON payload") + parser.add_argument("--non-strict", action="store_true", help="Exit 0 even when issues exist") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + + repo_root = args.repo_root.resolve() + compat_root = args.compat_root + if compat_root is None: + compat_root = Path(__file__).resolve().parents[1] + else: + compat_root = compat_root.resolve() + + try: + ok, issues, warnings, notes = _check(repo_root=repo_root, compat_root=compat_root) + except (FileNotFoundError, ValueError, TypeError) as exc: + if args.json: + payload = {"ok": False, "issues": [str(exc)], "repo_root": str(repo_root), "compat_root": str(compat_root)} + print(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + print(f"[qsl-compat] ERROR: {exc}") + return 2 + + if args.json: + print( + json.dumps( + { + "ok": ok, + "issues": issues, + "warnings": warnings, + "notes": notes, + "repo_root": str(repo_root), + "compat_root": str(compat_root), + }, + ensure_ascii=False, + indent=2, + ) + ) + else: + print(f"Repository: {repo_root.name}") + for note in notes: + print(note) + if issues: + print("Issues:") + for issue in issues: + print(f"- {issue}") + if warnings: + print("Warnings:") + for warning in warnings: + print(f"- {warning}") + print(f"Result: {'pass' if ok else 'fail'}") + + if issues and not args.non_strict: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/render_qsl_dependency_graph.py b/scripts/render_qsl_dependency_graph.py new file mode 100755 index 0000000..094612e --- /dev/null +++ b/scripts/render_qsl_dependency_graph.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Render a lightweight QSL dependency graph for a repository.""" + +from __future__ import annotations + +import argparse +import re +from collections import OrderedDict +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover + import toml as tomllib # type: ignore[import-not-found] + + +GITHUB_OWNER = "QuantStrategyLab" + +PATTERNS = ( + re.compile( + r"git\+https://github\.com/" + re.escape(GITHUB_OWNER) + r"/(?P[A-Za-z0-9_.-]+)\.git@(?P[A-Za-z0-9_.-]+)" + ), + re.compile( + r"https://github\.com/" + re.escape(GITHUB_OWNER) + r"/(?P[A-Za-z0-9_.-]+)\.git\?rev=(?P[A-Za-z0-9_.-]+)" + ), +) + + +def _read_toml(path: Path) -> dict: + with path.open("rb") as handle: + return tomllib.load(handle) + + +def _load_qsl_config(repo_root: Path) -> dict[str, str | bool]: + qsl_path = repo_root / "qsl.toml" + payload = _read_toml(qsl_path) + config = payload.get("qsl", payload) + if not isinstance(config, dict): + raise TypeError("qsl section must be a table") + + bundle = config.get("bundle") or config.get("compat") + if not isinstance(bundle, str) or not bundle.strip(): + raise ValueError("qsl.bundle (or qsl.compat) is required") + return { + "bundle": bundle.strip(), + "tier": str(config.get("tier", "")), + "upgrade_ring": str(config.get("upgrade_ring", "")), + } + + +def _load_bundle(compat_root: Path, bundle: str) -> dict[str, str]: + payload = _read_toml(compat_root / "compat" / "bundles" / f"{bundle}.toml") + repos = payload.get("repos") + if not isinstance(repos, dict): + raise ValueError("bundle manifest missing [repos]") + return {str(k): str(v).strip() for k, v in repos.items() if isinstance(k, str) and isinstance(v, str)} + + +def _scan_refs(path: Path) -> list[tuple[str, str]]: + refs = [] + if not path.exists(): + return refs + + for line in path.read_text(encoding="utf-8").splitlines(): + for pattern in PATTERNS: + for match in pattern.finditer(line): + refs.append((match.group("repo"), match.group("ref"))) + return refs + + +def _unique_refs(pairs: list[tuple[str, str]]) -> list[tuple[str, str]]: + seen = set() + out: list[tuple[str, str]] = [] + for item in pairs: + if item in seen: + continue + seen.add(item) + out.append(item) + return out + + +def _render_markdown(repo: str, tier: str, upgrade_ring: str, bundle: str, refs: list[tuple[str, str]], bundle_refs: dict[str, str]) -> str: + lines = [ + f"# QSL dependency graph: {repo}", + "", + f"- bundle: {bundle}", + f"- tier: {tier or 'unknown'}", + f"- upgrade_ring: {upgrade_ring or 'unknown'}", + "", + "## Direct QSL dependencies", + ] + + if not refs: + lines.append("- (none)") + else: + for source_repo, ref in refs: + status = "✓" if bundle_refs.get(source_repo) == ref else "!" + lines.append(f"- {status} `{source_repo}` @ `{ref}`") + + lines.extend(["", "## Bundle node refs (source of truth)", ""]) + for source_repo, expected_ref in OrderedDict(sorted(bundle_refs.items())).items(): + lines.append(f"- `{source_repo}` -> {expected_ref}") + + return "\n".join(lines) + + +def _render_text(repo: str, tier: str, upgrade_ring: str, bundle: str, refs: list[tuple[str, str]], bundle_refs: dict[str, str]) -> str: + lines = [ + f"QSL Dependency Graph - {repo}", + f"bundle: {bundle}", + f"tier: {tier or 'unknown'}", + f"upgrade_ring: {upgrade_ring or 'unknown'}", + "", + "Direct QSL refs:", + ] + if not refs: + lines.append(" (none)") + else: + for source_repo, ref in refs: + lines.append(f" - {source_repo} -> {ref}") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Render QSL dependency graph") + parser.add_argument("--repo-root", default=".", type=Path, help="Repository root") + parser.add_argument( + "--compat-root", + default=None, + type=Path, + help="Path to QuantRuntimeSettings checkout (defaults to checker script parent)", + ) + parser.add_argument("--format", choices=("md", "text"), default="md") + args = parser.parse_args(argv) + + repo_root = args.repo_root.resolve() + compat_root = args.compat_root + if compat_root is None: + compat_root = Path(__file__).resolve().parents[1] + else: + compat_root = compat_root.resolve() + + config = _load_qsl_config(repo_root) + bundle = str(config["bundle"]) + bundle_refs = _load_bundle(compat_root, bundle) + + direct_refs = _scan_refs(repo_root / "pyproject.toml") + _scan_refs(repo_root / "uv.lock") + direct_refs = _unique_refs(direct_refs) + + if args.format == "text": + output = _render_text(repo_root.name, str(config["tier"]), str(config["upgrade_ring"]), bundle, direct_refs, bundle_refs) + else: + output = _render_markdown(repo_root.name, str(config["tier"]), str(config["upgrade_ring"]), bundle, direct_refs, bundle_refs) + + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a2f59385499e06eb38e09d78aed5b82deb6e493f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:49:09 +0800 Subject: [PATCH 2/4] Fix internal dependency matrix check for pyproject.toml consumers Co-Authored-By: Codex --- internal_dependency_matrix.json | 22 +++--- .../check_internal_dependency_matrix.py | 69 +++++++++++++++---- .../tests/test_internal_dependency_matrix.py | 25 +++++++ 3 files changed, 92 insertions(+), 24 deletions(-) diff --git a/internal_dependency_matrix.json b/internal_dependency_matrix.json index 6db27ba..a96bd56 100644 --- a/internal_dependency_matrix.json +++ b/internal_dependency_matrix.json @@ -34,14 +34,14 @@ "path": "requirements.txt", "package": "us-equity-strategies", "source_repo": "UsEquityStrategies", - "ref": "6568c315ce3be6f7ae5b799374cf7fb44232c170" + "ref": "46887bc3f5454d5b59623b1f5efb7c65912c6b8b" }, { "consumer_repo": "CharlesSchwabPlatform", "path": "requirements.txt", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "b0eacd2fe4884f7f2447b704a232e9a121f396c4" + "ref": "0063af3b4a974650ea58a7d3f26dd1b94f65d3e8" }, { "consumer_repo": "CnEquitySnapshotPipelines", @@ -55,21 +55,21 @@ "path": "pyproject.toml", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "b0eacd2fe4884f7f2447b704a232e9a121f396c4" + "ref": "0063af3b4a974650ea58a7d3f26dd1b94f65d3e8" }, { "consumer_repo": "FirstradePlatform", "path": "requirements.txt", "package": "us-equity-strategies", "source_repo": "UsEquityStrategies", - "ref": "6568c315ce3be6f7ae5b799374cf7fb44232c170" + "ref": "46887bc3f5454d5b59623b1f5efb7c65912c6b8b" }, { "consumer_repo": "FirstradePlatform", "path": "requirements.txt", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "b0eacd2fe4884f7f2447b704a232e9a121f396c4" + "ref": "0063af3b4a974650ea58a7d3f26dd1b94f65d3e8" }, { "consumer_repo": "HkEquitySnapshotPipelines", @@ -83,42 +83,42 @@ "path": "requirements.txt", "package": "hk-equity-strategies", "source_repo": "HkEquityStrategies", - "ref": "e9e3058c1eaf3f43b25d50df5eb14442816e568e" + "ref": "61993bf261aeccf64b5a75428b9405f4e1d1d682" }, { "consumer_repo": "InteractiveBrokersPlatform", "path": "requirements.txt", "package": "us-equity-strategies", "source_repo": "UsEquityStrategies", - "ref": "6568c315ce3be6f7ae5b799374cf7fb44232c170" + "ref": "46887bc3f5454d5b59623b1f5efb7c65912c6b8b" }, { "consumer_repo": "InteractiveBrokersPlatform", "path": "requirements.txt", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "b0eacd2fe4884f7f2447b704a232e9a121f396c4" + "ref": "0063af3b4a974650ea58a7d3f26dd1b94f65d3e8" }, { "consumer_repo": "LongBridgePlatform", "path": "requirements.txt", "package": "hk-equity-strategies", "source_repo": "HkEquityStrategies", - "ref": "e9e3058c1eaf3f43b25d50df5eb14442816e568e" + "ref": "61993bf261aeccf64b5a75428b9405f4e1d1d682" }, { "consumer_repo": "LongBridgePlatform", "path": "requirements.txt", "package": "quant-platform-kit", "source_repo": "QuantPlatformKit", - "ref": "b0eacd2fe4884f7f2447b704a232e9a121f396c4" + "ref": "0063af3b4a974650ea58a7d3f26dd1b94f65d3e8" }, { "consumer_repo": "LongBridgePlatform", "path": "requirements.txt", "package": "us-equity-strategies", "source_repo": "UsEquityStrategies", - "ref": "6568c315ce3be6f7ae5b799374cf7fb44232c170" + "ref": "46887bc3f5454d5b59623b1f5efb7c65912c6b8b" }, { "consumer_repo": "QmtPlatform", diff --git a/python/scripts/check_internal_dependency_matrix.py b/python/scripts/check_internal_dependency_matrix.py index ed06150..120e9a4 100644 --- a/python/scripts/check_internal_dependency_matrix.py +++ b/python/scripts/check_internal_dependency_matrix.py @@ -20,6 +20,8 @@ r"(?P[A-Za-z0-9_.-]+)\.git@(?P[A-Za-z0-9_.-]+)" ) TRACKED_DEPENDENCY_PATHS = ("requirements.txt", "requirements-lock.txt", "pyproject.toml") +LEGACY_DEPENDENCY_PATHS = ("requirements.txt", "requirements-lock.txt") +PYPROJECT_FALLBACK_PATH = "pyproject.toml" def _sort_dependency_pins(pins: list[DependencyPin]) -> list[DependencyPin]: @@ -126,6 +128,40 @@ def parse_dependency_pins(consumer_repo: str, path: str, text: str) -> list[Depe ] +def _parse_repo_pins(projects_root: Path, consumer_repo: str, relative_path: str) -> list[DependencyPin]: + path = projects_root / consumer_repo / relative_path + if not path.is_file(): + return [] + return parse_dependency_pins(consumer_repo, relative_path, path.read_text(encoding="utf-8")) + + +def _fallback_path_for_legacy_requirements(projects_root: Path, consumer_repo: str, expected_path: str) -> str | None: + if expected_path not in LEGACY_DEPENDENCY_PATHS: + return None + pyproject_path = projects_root / consumer_repo / PYPROJECT_FALLBACK_PATH + if pyproject_path.is_file(): + return PYPROJECT_FALLBACK_PATH + return None + + +def _compare_dependency_pins( + *, expected_pins: list[DependencyPin], actual_pins: list[DependencyPin], issues: list[str] +) -> None: + actual_by_key = {pin.key: pin for pin in actual_pins} + expected_by_key = {pin.key: pin for pin in expected_pins} + + for key, expected in sorted(expected_by_key.items()): + actual = actual_by_key.get(key) + if actual is None: + issues.append(f"missing {expected.label()} expected @{expected.ref}") + elif actual.ref != expected.ref: + issues.append(f"ref mismatch {expected.label()}: expected @{expected.ref}, found @{actual.ref}") + + for key, actual in sorted(actual_by_key.items()): + if key not in expected_by_key: + issues.append(f"untracked internal dependency {actual.label()} @{actual.ref}") + + def check_matrix(*, matrix_pins: list[DependencyPin], projects_root: Path) -> MatrixReport: expected_by_file: dict[tuple[str, str], list[DependencyPin]] = {} for pin in matrix_pins: @@ -137,23 +173,30 @@ def check_matrix(*, matrix_pins: list[DependencyPin], projects_root: Path) -> Ma for (consumer_repo, relative_path), expected_pins in sorted(expected_by_file.items()): path = projects_root / consumer_repo / relative_path if not path.exists(): + fallback_path = _fallback_path_for_legacy_requirements(projects_root, consumer_repo, relative_path) + if fallback_path is not None: + fallback_pins = _parse_repo_pins(projects_root, consumer_repo, fallback_path) + if fallback_pins: + _compare_dependency_pins( + expected_pins=[ + DependencyPin( + consumer_repo=pin.consumer_repo, + path=fallback_path, + package=pin.package, + source_repo=pin.source_repo, + ref=pin.ref, + ) + for pin in expected_pins + ], + actual_pins=fallback_pins, + issues=issues, + ) + continue missing_files.append(f"{consumer_repo}/{relative_path}") continue checked_files += 1 actual_pins = parse_dependency_pins(consumer_repo, relative_path, path.read_text(encoding="utf-8")) - actual_by_key = {pin.key: pin for pin in actual_pins} - expected_by_key = {pin.key: pin for pin in expected_pins} - - for key, expected in sorted(expected_by_key.items()): - actual = actual_by_key.get(key) - if actual is None: - issues.append(f"missing {expected.label()} expected @{expected.ref}") - elif actual.ref != expected.ref: - issues.append(f"ref mismatch {expected.label()}: expected @{expected.ref}, found @{actual.ref}") - - for key, actual in sorted(actual_by_key.items()): - if key not in expected_by_key: - issues.append(f"untracked internal dependency {actual.label()} @{actual.ref}") + _compare_dependency_pins(expected_pins=expected_pins, actual_pins=actual_pins, issues=issues) return MatrixReport(checked_files=checked_files, missing_files=missing_files, issues=issues) diff --git a/python/tests/test_internal_dependency_matrix.py b/python/tests/test_internal_dependency_matrix.py index 8b54410..9258504 100644 --- a/python/tests/test_internal_dependency_matrix.py +++ b/python/tests/test_internal_dependency_matrix.py @@ -101,6 +101,31 @@ def test_require_consumer_files_treats_missing_paths_as_issues(self): self.assertEqual(report.missing_files, ["ExamplePlatform/requirements.txt"]) self.assertEqual(report.issues, []) + def test_check_matrix_accepts_pyproject_for_migrated_legacy_requirements(self): + projects_root = self._make_projects_root( + { + "ExamplePlatform/pyproject.toml": ( + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/" + "QuantPlatformKit.git@v0.7.35" + ) + } + ) + expected = [ + check_internal_dependency_matrix.DependencyPin( + consumer_repo="ExamplePlatform", + path="requirements.txt", + package="quant-platform-kit", + source_repo="QuantPlatformKit", + ref="v0.7.35", + ) + ] + + report = check_internal_dependency_matrix.check_matrix(matrix_pins=expected, projects_root=projects_root) + + self.assertEqual(report.checked_files, 0) + self.assertEqual(report.missing_files, []) + self.assertEqual(report.issues, []) + def test_collect_dependency_pins_from_projects(self): projects_root = self._make_projects_root( { From a6929295cc61f637f142409bfae4d7cbbb2d9017 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:58:45 +0800 Subject: [PATCH 3/4] fix: support transitional qsl compatibility metadata Co-Authored-By: Codex --- docs/qsl_compat_upgrade.md | 1 + scripts/check_qsl_compat.py | 57 ++++++++++++++++++++------ scripts/render_qsl_dependency_graph.py | 21 +++++++--- 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/docs/qsl_compat_upgrade.md b/docs/qsl_compat_upgrade.md index 779e686..324122b 100644 --- a/docs/qsl_compat_upgrade.md +++ b/docs/qsl_compat_upgrade.md @@ -31,6 +31,7 @@ compat = "2026.07.0" # 兼容检查入口(与 bundle 相同) tier = "ops/tooling" upgrade_ring = "ring_e" allow_legacy = false # 需要临时兼容时可先放开 +enforce_bundle = true # 过渡仓库可设 false;ref drift 会降级为 warning ``` 2. 在 `pyproject.toml`/`uv.lock` 中用完整 SHA 固定 QuantStrategyLab 的内部 git 依赖。 diff --git a/scripts/check_qsl_compat.py b/scripts/check_qsl_compat.py index 941ce08..8c06699 100755 --- a/scripts/check_qsl_compat.py +++ b/scripts/check_qsl_compat.py @@ -42,6 +42,29 @@ def _read_toml(path: Path) -> dict[str, Any]: return tomllib.load(handle) +def _get_bundle(config: dict[str, Any]) -> str: + bundle = config.get("bundle") or config.get("compat") + if isinstance(bundle, dict): + bundle = bundle.get("bundle") + if not isinstance(bundle, str) or not bundle.strip(): + raise ValueError("qsl.bundle (or qsl.compat.bundle) is required") + return bundle.strip() + + +def _get_upgrade_ring(config: dict[str, Any]) -> str: + upgrade_ring = config.get("upgrade_ring", config.get("ring")) + if upgrade_ring is None or str(upgrade_ring).strip() == "": + raise ValueError("qsl.upgrade_ring (or qsl.ring) is required") + return str(upgrade_ring).strip() + + +def _get_enforce_bundle(config: dict[str, Any]) -> bool: + compat = config.get("compat") + if isinstance(compat, dict) and "enforce_bundle" in compat: + return bool(compat["enforce_bundle"]) + return bool(config.get("enforce_bundle", True)) + + def _load_qsl_config(repo_root: Path) -> dict[str, str | bool]: qsl_path = repo_root / "qsl.toml" if not qsl_path.exists(): @@ -52,25 +75,23 @@ def _load_qsl_config(repo_root: Path) -> dict[str, str | bool]: if not isinstance(config, dict): raise TypeError("qsl section must be a table") - bundle = config.get("bundle") or config.get("compat") - if not isinstance(bundle, str) or not bundle.strip(): - raise ValueError("qsl.bundle (or qsl.compat) is required") + bundle = _get_bundle(config) tier = config.get("tier") if not isinstance(tier, str) or not tier.strip(): raise ValueError("qsl.tier is required") - upgrade_ring = config.get("upgrade_ring") - if not isinstance(upgrade_ring, str) or not upgrade_ring.strip(): - raise ValueError("qsl.upgrade_ring is required") + upgrade_ring = _get_upgrade_ring(config) allow_legacy = bool(config.get("allow_legacy", False)) + enforce_bundle = _get_enforce_bundle(config) return { - "bundle": bundle.strip(), + "bundle": bundle, "tier": tier.strip(), - "upgrade_ring": str(upgrade_ring).strip(), + "upgrade_ring": upgrade_ring, "allow_legacy": allow_legacy, + "enforce_bundle": enforce_bundle, "qsl_path": qsl_path.as_posix(), } @@ -148,12 +169,14 @@ def _check(repo_root: Path, compat_root: Path) -> tuple[bool, list[str], list[st tier = str(qsl_cfg["tier"]) upgrade_ring = str(qsl_cfg["upgrade_ring"]) allow_legacy = bool(qsl_cfg["allow_legacy"]) + enforce_bundle = bool(qsl_cfg["enforce_bundle"]) qsl_path = str(qsl_cfg["qsl_path"]) notes.append(f"qsl={qsl_path}") notes.append(f"bundle={bundle}") notes.append(f"tier={tier}") notes.append(f"upgrade_ring={upgrade_ring}") + notes.append(f"enforce_bundle={enforce_bundle}") bundle_refs = _load_bundle(compat_root, bundle) @@ -172,7 +195,7 @@ def _check(repo_root: Path, compat_root: Path) -> tuple[bool, list[str], list[st f"{legacy_ref.repo}@{legacy_ref.ref}" ) continue - _validate_ref(legacy_ref, bundle_refs[legacy_ref.repo], issues) + _validate_ref(legacy_ref, bundle_refs[legacy_ref.repo], issues, warnings, enforce_bundle) discovered = _gather_repo_refs(repo_root) for pin in discovered: @@ -180,23 +203,31 @@ def _check(repo_root: Path, compat_root: Path) -> tuple[bool, list[str], list[st issues.append(f"unmanaged qsl dependency in {pin.source}:{pin.line_no}: {pin.repo}@{pin.ref}") continue expected_ref = bundle_refs[pin.repo] - _validate_ref(pin, expected_ref, issues) + _validate_ref(pin, expected_ref, issues, warnings, enforce_bundle) return (len(issues) == 0, issues, warnings, notes) -def _validate_ref(pin: GitRef, expected_ref: str, issues: list[str]) -> None: +def _validate_ref(pin: GitRef, expected_ref: str, issues: list[str], warnings: list[str], enforce_bundle: bool) -> None: if _is_main_ref(pin.ref): issues.append(f"forbidden ref 'main' in {pin.source}:{pin.line_no}: {pin.repo}") return if not _is_full_sha(pin.ref): - issues.append(f"forbidden short/invalid ref '{pin.ref}' in {pin.source}:{pin.line_no}: {pin.repo}") + message = f"forbidden short/invalid ref '{pin.ref}' in {pin.source}:{pin.line_no}: {pin.repo}" + if enforce_bundle: + issues.append(message) + else: + warnings.append(message) return if pin.ref != expected_ref: - issues.append( + message = ( f"bundle pin mismatch for {pin.repo} in {pin.source}:{pin.line_no}: " f"expected {expected_ref}, found {pin.ref}" ) + if enforce_bundle: + issues.append(message) + else: + warnings.append(message) def build_parser() -> argparse.ArgumentParser: diff --git a/scripts/render_qsl_dependency_graph.py b/scripts/render_qsl_dependency_graph.py index 094612e..8f06376 100755 --- a/scripts/render_qsl_dependency_graph.py +++ b/scripts/render_qsl_dependency_graph.py @@ -31,6 +31,20 @@ def _read_toml(path: Path) -> dict: return tomllib.load(handle) +def _get_bundle(config: dict) -> str: + bundle = config.get("bundle") or config.get("compat") + if isinstance(bundle, dict): + bundle = bundle.get("bundle") + if not isinstance(bundle, str) or not bundle.strip(): + raise ValueError("qsl.bundle (or qsl.compat.bundle) is required") + return bundle.strip() + + +def _get_upgrade_ring(config: dict) -> str: + upgrade_ring = config.get("upgrade_ring", config.get("ring")) + return "" if upgrade_ring is None else str(upgrade_ring).strip() + + def _load_qsl_config(repo_root: Path) -> dict[str, str | bool]: qsl_path = repo_root / "qsl.toml" payload = _read_toml(qsl_path) @@ -38,13 +52,10 @@ def _load_qsl_config(repo_root: Path) -> dict[str, str | bool]: if not isinstance(config, dict): raise TypeError("qsl section must be a table") - bundle = config.get("bundle") or config.get("compat") - if not isinstance(bundle, str) or not bundle.strip(): - raise ValueError("qsl.bundle (or qsl.compat) is required") return { - "bundle": bundle.strip(), + "bundle": _get_bundle(config), "tier": str(config.get("tier", "")), - "upgrade_ring": str(config.get("upgrade_ring", "")), + "upgrade_ring": _get_upgrade_ring(config), } From 4c3a64ee68c8a2e9c3809b472e4de67d566ba60c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:11:52 +0800 Subject: [PATCH 4/4] chore: retrigger qsl compatibility checks Co-Authored-By: Codex