From ca350231bc9edfa5df4a5580c0b4bcda5e55f72b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 19 Oct 2026 00:43:09 +0800 Subject: [PATCH] fix: prepare canonical optional AI dependency identity Co-Authored-By: Codex --- .github/workflows/qsl_exception_lifecycle.yml | 74 ++++--- python/tests/test_qsl_compat_checker.py | 59 ++++++ .../test_qsl_exception_lifecycle_workflow.py | 186 +++++++++++++++++- 3 files changed, 292 insertions(+), 27 deletions(-) diff --git a/.github/workflows/qsl_exception_lifecycle.yml b/.github/workflows/qsl_exception_lifecycle.yml index 7447b17a..ad03ed1e 100644 --- a/.github/workflows/qsl_exception_lifecycle.yml +++ b/.github/workflows/qsl_exception_lifecycle.yml @@ -88,7 +88,7 @@ jobs: fi done < qsl-bundle-repos.tsv - - name: Prepare CN research dependency identity + - name: Prepare optional AI dependency identity id: research_dependency if: steps.workspace.outputs.scope == 'current' env: @@ -101,28 +101,47 @@ jobs: import subprocess import tomllib from pathlib import Path + from scripts.check_qsl_compat import ( + GitRef, _extract_git_refs, _gather_declared_current_refs, + _is_research_extra_only_dependency, _load_qsl_config, + ) workspace = Path(os.environ["RUNNER_TEMP"]) / "qsl-workspace" - manifest = workspace / "CnEquityStrategies" / "pyproject.toml" - if not manifest.exists(): - raise SystemExit(0) - project = tomllib.loads(manifest.read_text(encoding="utf-8")).get("project", {}) - research = project.get("optional-dependencies", {}).get("research", []) pattern = re.compile( r"^ai-gateway-client\s*@\s*git\+https://github\.com/" - r"QuantStrategyLab/(?:AIAuditBridge|QuantAIService)\.git@([0-9a-f]{40})$" + r"QuantStrategyLab/(AIAuditBridge|QuantAIService)\.git@([0-9a-f]{40})$" ) - matches = [pattern.fullmatch(item.strip()) for item in research if isinstance(item, str)] - matches = [match for match in matches if match] - if not matches: + refs = set() + for consumer, extra in (("CnEquityStrategies", "research"), ("QuantStrategyPlugins", "ai")): + repo_root = workspace / consumer + manifest = repo_root / "pyproject.toml" + if not manifest.exists(): + continue + project = tomllib.loads(manifest.read_text(encoding="utf-8")).get("project", {}) + requirements = project.get("optional-dependencies", {}).get(extra, []) + candidates = [item.strip() for item in requirements if isinstance(item, str) + and re.match(r"^ai[-_.]gateway[-_.]client\b", item.strip(), re.IGNORECASE)] + if not candidates: + continue + if len(candidates) != 1 or not (match := pattern.fullmatch(candidates[0])): + raise SystemExit(f"{consumer} optional {extra} AI identity is unapproved or ambiguous") + repository, ref = match.groups() + pin = GitRef(repo=repository, ref=ref, source="pyproject.toml", line_no=None) + declared = {item.ref for item in _gather_declared_current_refs(_load_qsl_config(repo_root)) + if item.repo == repository} + locked = {item.ref for item in _extract_git_refs(repo_root / "uv.lock") if item.repo == repository} + if declared != {ref} or locked != {ref} or not _is_research_extra_only_dependency(repo_root, pin): + raise SystemExit(f"{consumer} AI identity lacks matching optional manifest/lock/declaration proof") + refs.add(ref) + if not refs: raise SystemExit(0) - if len(matches) != 1: - raise SystemExit("CN research AIAuditBridge dependency is missing or ambiguous") - ref = matches[0].group(1) - destination = Path(os.environ["GITHUB_WORKSPACE"]).parent / "AIAuditBridge" + if len(refs) != 1: + raise SystemExit("optional AI consumers declare conflicting identity pins") + ref = refs.pop() + destination = Path(os.environ["GITHUB_WORKSPACE"]).parent / "QuantAIService" if destination.exists() or destination.is_symlink(): raise SystemExit(f"refusing to overwrite existing dependency identity checkout: {destination}") - Path(os.environ["RUNNER_TEMP"], "qsl-aiauditbridge-identity-created").write_text( + Path(os.environ["RUNNER_TEMP"], "qsl-quantaiservice-identity-created").write_text( str(destination), encoding="utf-8" ) subprocess.run( @@ -132,8 +151,19 @@ jobs: subprocess.run(["git", "-C", str(destination), "checkout", "--detach", ref], check=True) actual = subprocess.check_output(["git", "-C", str(destination), "rev-parse", "HEAD"], text=True).strip() if actual != ref: - raise SystemExit(f"AIAuditBridge identity checkout mismatch: expected {ref}, got {actual}") - Path("qsl-aiauditbridge-identity.tsv").write_text(f"AIAuditBridge\t{actual}\n", encoding="utf-8") + raise SystemExit(f"QuantAIService identity checkout mismatch: expected {ref}, got {actual}") + origin = subprocess.check_output( + ["git", "-C", str(destination), "remote", "get-url", "origin"], text=True + ).strip().rstrip("/").removesuffix(".git") + if origin not in {"https://github.com/QuantStrategyLab/QuantAIService", + "git@github.com:QuantStrategyLab/QuantAIService", + "ssh://git@github.com/QuantStrategyLab/QuantAIService"}: + raise SystemExit("QuantAIService identity checkout has an unexpected origin") + identity = tomllib.loads((destination / "qsl.toml").read_text(encoding="utf-8")) + if (identity.get("tier") != "ops/tooling" or identity.get("upgrade_ring") != "ring_e" + or identity.get("runtime_dependency") is not False): + raise SystemExit("QuantAIService identity must remain non-runtime ops/tooling") + Path("qsl-quantaiservice-identity.tsv").write_text(f"QuantAIService\t{actual}\n", encoding="utf-8") PY - name: Generate QSL compatibility report @@ -183,10 +213,10 @@ jobs: repo, sha = line.split("\t", 1) handle.write(f"- `{repo}`: `{sha}`\n") - identity_refs = Path("qsl-aiauditbridge-identity.tsv") + identity_refs = Path("qsl-quantaiservice-identity.tsv") if identity_refs.exists(): with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("a", encoding="utf-8") as handle: - handle.write("\n### Research dependency identity checkout\n") + handle.write("\n### Optional AI dependency identity checkout\n") for line in identity_refs.read_text(encoding="utf-8").splitlines(): repo, sha = line.split("\t", 1) handle.write(f"- `{repo}`: `{sha}` (identity-only)\n") @@ -198,14 +228,14 @@ jobs: QSL_BUNDLE: ${{ steps.workspace.outputs.bundle }} QSL_WORKSPACE: ${{ steps.workspace.outputs.workspace }} - - name: Clean up research dependency identity + - name: Clean up optional AI dependency identity if: always() run: | set -euo pipefail - marker="${RUNNER_TEMP}/qsl-aiauditbridge-identity-created" + marker="${RUNNER_TEMP}/qsl-quantaiservice-identity-created" if [ -f "$marker" ]; then destination="$(realpath -m "$(cat "$marker")")" - expected="$(realpath -m "${GITHUB_WORKSPACE}/../AIAuditBridge")" + expected="$(realpath -m "${GITHUB_WORKSPACE}/../QuantAIService")" if [ "$destination" = "$expected" ]; then rm -rf "$destination" "$marker" else diff --git a/python/tests/test_qsl_compat_checker.py b/python/tests/test_qsl_compat_checker.py index b804b357..03800493 100644 --- a/python/tests/test_qsl_compat_checker.py +++ b/python/tests/test_qsl_compat_checker.py @@ -2,6 +2,7 @@ import importlib.util import sys +import subprocess import tempfile import unittest from pathlib import Path @@ -16,6 +17,64 @@ class QSLCompatCheckerTest(unittest.TestCase): + def test_canonical_ai_workspace_identity_preserves_optional_and_managed_boundaries(self): + ref = "65c8bcf432f578f657c12d5dab0e16ebec0bef5a" + cases = [ + # Original failure: four unmanaged declarations even if the + # canonical remote has been cloned into the old alias directory. + ("AIAuditBridge", "AIAuditBridge", "QuantStrategyLab/QuantAIService", ref, False, False, "unmanaged"), + ("QuantAIService", None, None, ref, False, False, "unmanaged"), + ("QuantAIService", "AIAuditBridge", "QuantStrategyLab/QuantAIService", ref, False, False, "unmanaged"), + ("QuantAIService", "QuantAIService", "Pigbibi/QuantAIService", ref, False, False, "unmanaged"), + ("UnknownAI", "QuantAIService", "QuantStrategyLab/QuantAIService", ref, False, False, "unmanaged"), + ("QuantAIService", "QuantAIService", "QuantStrategyLab/QuantAIService", ref, False, True, None), + ("QuantAIService", "QuantAIService", "QuantStrategyLab/QuantAIService", ref, True, False, "forbidden dependency direction"), + ("QuantAIService", "QuantAIService", "QuantStrategyLab/QuantAIService", "main", False, False, "forbidden ref"), + ("QuantAIService", "QuantAIService", "QuantStrategyLab/QuantAIService", ref[:12], False, False, "forbidden short/invalid ref"), + ] + for repository, dirname, remote, pin, base, allowed, failure in cases: + with self.subTest(repository=repository, dirname=dirname, remote=remote, pin=pin, base=base), \ + tempfile.TemporaryDirectory() as directory: + root = Path(directory) + consumer = root / "QuantStrategyPlugins" + consumer.mkdir() + requirement = f"ai-gateway-client @ git+https://github.com/QuantStrategyLab/{repository}.git@{pin}" + (consumer / "qsl.toml").write_text( + 'tier = "strategy-lib"\nupgrade_ring = "ring_b"\n[compat]\nbundle = "2026.07.1"\n' + f'requires = ["pandas>=2.0", "yfinance>=0.2.40", "{requirement}"]\n', encoding="utf-8", + ) + (consumer / "pyproject.toml").write_text( + '[project]\nname = "quant-strategy-plugins"\ndependencies = ' + + repr([requirement] if base else []) + '\n[project.optional-dependencies]\nai = ' + + repr([requirement]) + '\n', encoding="utf-8", + ) + url = f"https://github.com/QuantStrategyLab/{repository}.git?rev={pin}" + (consumer / "uv.lock").write_text( + 'version = 1\n[[package]]\nname = "ai-gateway-client"\n' + f'source = {{ git = "{url}#{pin}" }}\n[[package]]\n' + 'name = "quant-strategy-plugins"\nsource = { editable = "." }\n' + '[package.metadata]\nrequires-dist = [{ name = "ai-gateway-client", ' + f'marker = "extra == \'ai\'", git = "{url}" }}]\n', encoding="utf-8", + ) + if dirname is not None: + identity = root / dirname + subprocess.run(["git", "init", "-q", str(identity)], check=True) + subprocess.run(["git", "-C", str(identity), "remote", "add", "origin", + f"https://github.com/{remote}.git"], check=True) + (identity / "qsl.toml").write_text( + 'tier = "ops/tooling"\nupgrade_ring = "ring_e"\nruntime_dependency = false\n' + '[compat]\nbundle = "2026.07.0"\n', encoding="utf-8", + ) + result = check_qsl_compat._check(repo_root=consumer, compat_root=ROOT, scope="current") + self.assertEqual(result[0], allowed, result[1]) + if failure: + self.assertTrue(any(failure in issue for issue in result[1]), result[1]) + else: + self.assertEqual((result[1], result[2]), ([], [])) + if repository == "AIAuditBridge": + self.assertEqual(len(result[1]), 4, result[1]) + self.assertTrue(all("unmanaged" in issue for issue in result[1])) + def _make_repo_root(self, qsl_toml: str, pyproject: str) -> Path: root = Path(tempfile.mkdtemp()) (root / "qsl.toml").write_text(qsl_toml, encoding="utf-8") diff --git a/python/tests/test_qsl_exception_lifecycle_workflow.py b/python/tests/test_qsl_exception_lifecycle_workflow.py index 7864e791..a9748978 100644 --- a/python/tests/test_qsl_exception_lifecycle_workflow.py +++ b/python/tests/test_qsl_exception_lifecycle_workflow.py @@ -8,6 +8,7 @@ import subprocess import sys import textwrap +from unittest import mock from pathlib import Path @@ -18,8 +19,183 @@ sys.modules[CHECKER_SPEC.name] = CHECKER CHECKER_SPEC.loader.exec_module(CHECKER) +AI_REF = "65c8bcf432f578f657c12d5dab0e16ebec0bef5a" +AI_URL = "https://github.com/QuantStrategyLab/QuantAIService.git" +AI_QSL = 'tier = "ops/tooling"\nupgrade_ring = "ring_e"\nruntime_dependency = false\n[compat]\nbundle = "2026.07.0"\n' + class QslExceptionLifecycleWorkflowTest(unittest.TestCase): + def _identity_script(self) -> str: + workflow = (ROOT / ".github/workflows/qsl_exception_lifecycle.yml").read_text(encoding="utf-8") + step = workflow.split(" id: research_dependency\n", 1)[1] + return textwrap.dedent(step.split("python3 - <<'PY'\n", 1)[1].split(" PY\n", 1)[0]) + + def _write_ai_consumer( + self, workspace: Path, name: str, extra: str, *, ref: str = AI_REF, + repository: str = "QuantStrategyLab/QuantAIService", duplicate: bool = False, + base: bool = False, lock_ref: str | None = None, marker: str | None = None, + ) -> None: + consumer = workspace / name + consumer.mkdir(parents=True, exist_ok=True) + requirement = f'ai-gateway-client @ git+https://github.com/{repository}.git@{ref}' + entries = [requirement] * (2 if duplicate else 1) + (consumer / "qsl.toml").write_text( + 'tier = "strategy-lib"\nupgrade_ring = "ring_b"\n[compat]\nbundle = "2026.07.1"\n' + f'requires = ["{requirement}"]\n', encoding="utf-8", + ) + (consumer / "pyproject.toml").write_text( + '[project]\nname = "synthetic-consumer"\ndependencies = ' + + repr([requirement] if base else []) + '\n[project.optional-dependencies]\n' + + extra + ' = ' + repr(entries) + '\n', encoding="utf-8", + ) + lock_ref = lock_ref or ref + marker = marker or f"extra == '{extra}'" + (consumer / "uv.lock").write_text( + 'version = 1\n[[package]]\nname = "synthetic-consumer"\nsource = { editable = "." }\n' + '[package.metadata]\nrequires-dist = [{ name = "ai-gateway-client", ' + f'marker = "{marker}", git = "https://github.com/{repository}.git?rev={lock_ref}" }}]\n' + '[[package]]\nname = "ai-gateway-client"\n' + f'source = {{ git = "https://github.com/{repository}.git?rev={lock_ref}#{lock_ref}" }}\n', + encoding="utf-8", + ) + + def _run_identity( + self, root: Path, *, actual: str = AI_REF, origin: str = AI_URL, qsl: str = AI_QSL, + ) -> tuple[int, list[list[str]]]: + # Only the identity orchestration is simulated. No gh/network operation + # or dependency installation is allowed; the origin is a real local Git config. + checkout = root / "checkout" / "QuantRuntimeSettings" + checkout.mkdir(parents=True, exist_ok=True) + calls: list[list[str]] = [] + real_run = subprocess.run + + def run(argv, **kwargs): + calls.append(argv) + if argv[:4] == ["gh", "repo", "clone", "QuantStrategyLab/QuantAIService"]: + destination = Path(argv[4]) + real_run(["git", "init", "-q", str(destination)], check=True) + real_run(["git", "-C", str(destination), "remote", "add", "origin", origin], check=True) + (destination / "qsl.toml").write_text(qsl, encoding="utf-8") + return subprocess.CompletedProcess(argv, 0) + if argv[:1] == ["git"] and argv[3:] == ["checkout", "--detach", AI_REF]: + return subprocess.CompletedProcess(argv, 0) + raise AssertionError(f"unexpected subprocess: {argv}") + + def output(argv, **kwargs): + if argv[:1] == ["git"] and argv[3:] == ["rev-parse", "HEAD"]: + return actual + "\n" + return real_run(argv, check=True, capture_output=True, text=True).stdout + + env = {"RUNNER_TEMP": str(root), "GITHUB_WORKSPACE": str(checkout)} + previous = os.getcwd() + with mock.patch.dict(os.environ, env), mock.patch.object(subprocess, "run", side_effect=run), \ + mock.patch.object(subprocess, "check_output", side_effect=output), \ + mock.patch("sys.path", [str(ROOT), *sys.path]): + # The workflow's relative TSV output belongs to this isolated temp dir. + os.chdir(root) + try: + try: + exec(compile(self._identity_script(), "workflow-identity", "exec"), {}) + except SystemExit as error: + return (error.code if isinstance(error.code, int) else 1), calls + finally: + os.chdir(previous) + return 0, calls + + def test_qsp_ai_identity_uses_canonical_checkout_and_exact_pin(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._write_ai_consumer(root / "qsl-workspace", "QuantStrategyPlugins", "ai") + code, calls = self._run_identity(root) + self.assertEqual(code, 0) + destination = root / "checkout" / "QuantAIService" + self.assertEqual(calls, [ + ["gh", "repo", "clone", "QuantStrategyLab/QuantAIService", str(destination)], + ["git", "-C", str(destination), "checkout", "--detach", AI_REF], + ]) + self.assertEqual((root / "qsl-quantaiservice-identity.tsv").read_text(), f"QuantAIService\t{AI_REF}\n") + self.assertFalse((root / "checkout" / "AIAuditBridge").exists()) + + def test_cn_research_and_shared_consumers_use_one_identity_checkout(self) -> None: + for shared in (False, True): + with self.subTest(shared=shared), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + workspace = root / "qsl-workspace" + self._write_ai_consumer(workspace, "CnEquityStrategies", "research") + if shared: + self._write_ai_consumer(workspace, "QuantStrategyPlugins", "ai") + code, calls = self._run_identity(root) + self.assertEqual(code, 0) + self.assertEqual(sum(call[0] == "gh" for call in calls), 1) + self.assertTrue((root / "checkout" / "QuantAIService").is_dir()) + + def test_no_active_optional_ai_consumer_is_noop(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + consumer = root / "qsl-workspace" / "CnEquityStrategies" + consumer.mkdir(parents=True) + (consumer / "pyproject.toml").write_text('[project.optional-dependencies]\nresearch = []\n') + code, calls = self._run_identity(root) + self.assertEqual((code, calls), (0, [])) + + def test_invalid_optional_identity_is_rejected_before_clone(self) -> None: + cases = [ + {"ref": "main"}, {"ref": AI_REF[:12]}, + {"repository": "QuantStrategyLab/UnknownAI"}, + {"repository": "Pigbibi/PersonalAIService"}, + {"duplicate": True}, {"base": True}, {"lock_ref": "a" * 40}, + {"marker": "extra == 'ai' or extra == 'test'"}, + ] + for case in cases: + with self.subTest(case=case), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._write_ai_consumer(root / "qsl-workspace", "QuantStrategyPlugins", "ai", **case) + code, calls = self._run_identity(root) + self.assertNotEqual(code, 0) + self.assertEqual(calls, []) + + def test_conflicting_consumer_pins_are_rejected_before_clone(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + workspace = root / "qsl-workspace" + self._write_ai_consumer(workspace, "QuantStrategyPlugins", "ai") + self._write_ai_consumer(workspace, "CnEquityStrategies", "research", ref="a" * 40) + code, calls = self._run_identity(root) + self.assertNotEqual(code, 0) + self.assertEqual(calls, []) + + def test_identity_checkout_attestation_is_fail_closed(self) -> None: + for case in ({"actual": "a" * 40}, {"origin": "https://github.com/Pigbibi/QuantAIService.git"}, + {"qsl": AI_QSL.replace('false', 'true')}, + {"qsl": AI_QSL.replace('false', '0')}, + {"qsl": AI_QSL.replace('ops/tooling', 'core')}, + {"qsl": AI_QSL.replace('ring_e', 'ring_a')}): + with self.subTest(case=case), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._write_ai_consumer(root / "qsl-workspace", "QuantStrategyPlugins", "ai") + code, calls = self._run_identity(root, **case) + self.assertNotEqual(code, 0) + self.assertEqual(len(calls), 2) + self.assertFalse((root / "qsl-quantaiservice-identity.tsv").exists()) + + def test_existing_identity_directory_or_symlink_is_never_overwritten(self) -> None: + for symlink in (False, True): + with self.subTest(symlink=symlink), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self._write_ai_consumer(root / "qsl-workspace", "QuantStrategyPlugins", "ai") + destination = root / "checkout" / "QuantAIService" + destination.parent.mkdir() + if symlink: + destination.symlink_to(root / "missing-unrelated-target", target_is_directory=True) + else: + destination.mkdir() + (destination / "unrelated").write_text("preserve me") + code, calls = self._run_identity(root) + self.assertNotEqual(code, 0) + self.assertEqual(calls, []) + self.assertTrue(destination.is_symlink() if symlink else (destination / "unrelated").is_file()) + self.assertFalse((root / "qsl-quantaiservice-identity-created").exists()) + def test_manual_scope_and_schedule_current_are_explicit(self) -> None: workflow = (ROOT / ".github/workflows/qsl_exception_lifecycle.yml").read_text(encoding="utf-8") self.assertIn("scope:", workflow) @@ -37,18 +213,18 @@ def test_workflow_keeps_frozen_checkout_and_current_main_sha_recording(self) -> self.assertIn('git -C "$workspace/$repo" rev-parse HEAD', workflow) self.assertIn('if report["strict_repositories"] or report["warning_repositories"]:', workflow) self.assertIn('destination="$(realpath -m "$(cat "$marker")")"', workflow) - self.assertIn('expected="$(realpath -m "${GITHUB_WORKSPACE}/../AIAuditBridge")"', workflow) + self.assertIn('expected="$(realpath -m "${GITHUB_WORKSPACE}/../QuantAIService")"', workflow) def test_cleanup_shell_normalizes_resolved_identity_marker_path(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) workspace = root / "runner" / "work" / "QRT" - identity = root / "runner" / "work" / "AIAuditBridge" + identity = root / "runner" / "work" / "QuantAIService" workspace.mkdir(parents=True) identity.mkdir() workflow = (ROOT / ".github/workflows/qsl_exception_lifecycle.yml").read_text(encoding="utf-8") match = re.search( - r"- name: Clean up research dependency identity\n" + r"- name: Clean up optional AI dependency identity\n" r"\s+if: always\(\)\n\s+run: \|\n((?:\s{10}.*\n)+)", workflow, ) @@ -72,13 +248,13 @@ def test_cleanup_shell_normalizes_resolved_identity_marker_path(self) -> None: "GITHUB_WORKSPACE": str(workspace.resolve()), "PATH": f"{bin_dir}:{os.environ.get('PATH', '')}", } - marker = root / "runner" / "qsl-aiauditbridge-identity-created" + marker = root / "runner" / "qsl-quantaiservice-identity-created" marker.write_text(str(identity.resolve()), encoding="utf-8") subprocess.run(["bash", "-c", script], check=True, env=env, capture_output=True, text=True) self.assertFalse(identity.exists()) self.assertFalse(marker.exists()) - unexpected = root / "runner" / "unexpected-AIAuditBridge" + unexpected = root / "runner" / "unexpected-QuantAIService" unexpected.mkdir() marker.write_text(str(unexpected.resolve()), encoding="utf-8") result = subprocess.run(["bash", "-c", script], check=False, env=env, capture_output=True, text=True)