Skip to content

Commit 9fc0433

Browse files
Pigbibicodex
andcommitted
feat: add QSL workspace report plan
Co-Authored-By: Codex <noreply@openai.com>
1 parent 3a5e452 commit 9fc0433

5 files changed

Lines changed: 383 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ This repository is the central manifest source for QSL compatibility checks.
3333

3434
- `qsl.toml`: current repo QSL metadata (`tier`, `upgrade_ring`, `bundle`).
3535
- `scripts/check_qsl_compat.py`: validate a repo's QSL compliance in `pyproject.toml`/`uv.lock` against central bundle.
36-
- `python/scripts/qslctl.py`: unified QSL version-control CLI for repo checks, workspace checks, and matrix generation.
36+
- `python/scripts/qslctl.py`: unified QSL version-control CLI for repo checks, workspace checks, ring reports/plans, and matrix generation.
3737
- `scripts/render_qsl_dependency_graph.py`: render dependency graph for review.
3838
- `compat/bundles/2026.07.0.toml`: first compatibility bundle (CalVer).
3939
- `compat/repo-tiers.toml`: repo tier and upgrade ring policy notes.

docs/qsl_version_control.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Use `qslctl` for repository and workspace checks:
1717
```bash
1818
python3 python/scripts/qslctl.py check --repo-root ../UsEquityStrategies
1919
python3 python/scripts/qslctl.py check-all --projects-root /Users/lisiyi/Projects
20+
python3 python/scripts/qslctl.py report --projects-root /Users/lisiyi/Projects
21+
python3 python/scripts/qslctl.py plan --projects-root /Users/lisiyi/Projects
2022
python3 python/scripts/qslctl.py generate-matrix --projects-root /Users/lisiyi/Projects --check
2123
python3 python/scripts/qslctl.py generate-matrix --projects-root /Users/lisiyi/Projects --sync
2224
```
@@ -27,3 +29,9 @@ python3 python/scripts/qslctl.py generate-matrix --projects-root /Users/lisiyi/P
2729
2. Apply that bundle by ring: core → strategy libraries → pipelines/research → runtime platforms → ops/tooling.
2830
3. Regenerate `internal_dependency_matrix.json` after consumer pins are updated.
2931
4. Keep strict checks enabled only after a ring is converged.
32+
33+
## Workspace report / plan
34+
35+
- `qslctl report` is read-only. It groups the current workspace by ring, status, and bundle hotspot.
36+
- `qslctl plan` is read-only. It renders the ring-by-ring convergence order and highlights which repos should be fixed before the next ring starts.
37+
- Use `report` to answer “what is broken right now?” and `plan` to answer “what should we fix first?”

internal_dependency_matrix.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
"path": "requirements.txt",
9191
"package": "us-equity-strategies",
9292
"source_repo": "UsEquityStrategies",
93-
"ref": "67e1ed69d7b9c318f94c8809121d2b9481d6b11a"
93+
"ref": "1643f37d723c4d0fdc475ce1030b3d911c07aacb"
9494
},
9595
{
9696
"consumer_repo": "InteractiveBrokersPlatform",

python/scripts/qslctl.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import json
1414
import subprocess
1515
import sys
16+
import tomllib
17+
from collections import Counter
1618
from dataclasses import dataclass
1719
from pathlib import Path
1820
from typing import Any
@@ -51,6 +53,10 @@ class RepoCheckResult:
5153
warnings: list[str]
5254
notes: list[str]
5355
repo_root: str
56+
bundle: str
57+
tier: str
58+
upgrade_ring: str
59+
enforce_bundle: bool
5460

5561

5662
def _is_quant_repo(repo_dir: Path) -> bool:
@@ -82,18 +88,24 @@ def iter_qsl_repos(projects_root: Path) -> list[Path]:
8288
def check_repo(repo_root: Path, compat_root: Path) -> RepoCheckResult:
8389
try:
8490
ok, issues, warnings, notes = check_qsl_compat._check(repo_root=repo_root, compat_root=compat_root)
91+
qsl_cfg = check_qsl_compat._load_qsl_config(repo_root)
8592
except (FileNotFoundError, ValueError, TypeError) as exc:
8693
ok = False
8794
issues = [str(exc)]
8895
warnings = []
8996
notes = []
97+
qsl_cfg = {"bundle": "", "tier": "", "upgrade_ring": "", "enforce_bundle": False}
9098
return RepoCheckResult(
9199
repo=repo_root.name,
92100
ok=bool(ok),
93101
issues=list(issues),
94102
warnings=list(warnings),
95103
notes=list(notes),
96104
repo_root=str(repo_root),
105+
bundle=str(qsl_cfg["bundle"]),
106+
tier=str(qsl_cfg["tier"]),
107+
upgrade_ring=str(qsl_cfg["upgrade_ring"]),
108+
enforce_bundle=bool(qsl_cfg["enforce_bundle"]),
97109
)
98110

99111

@@ -109,6 +121,10 @@ def _result_payload(result: RepoCheckResult) -> dict[str, Any]:
109121
"warnings": result.warnings,
110122
"notes": result.notes,
111123
"repo_root": result.repo_root,
124+
"bundle": result.bundle,
125+
"tier": result.tier,
126+
"upgrade_ring": result.upgrade_ring,
127+
"enforce_bundle": result.enforce_bundle,
112128
}
113129

114130

@@ -124,6 +140,229 @@ def _summary_payload(results: list[RepoCheckResult]) -> dict[str, Any]:
124140
}
125141

126142

143+
def _load_repo_tiers(compat_root: Path) -> tuple[list[str], dict[str, str], dict[str, str]]:
144+
with (compat_root / "compat" / "repo-tiers.toml").open("rb") as handle:
145+
payload = tomllib.load(handle)
146+
upgrade_rings = payload.get("upgrade_rings")
147+
if not isinstance(upgrade_rings, dict) or not upgrade_rings:
148+
raise ValueError(f"repo tier manifest missing [upgrade_rings] in {compat_root / 'compat' / 'repo-tiers.toml'}")
149+
150+
ring_order: list[str] = []
151+
ring_to_tier: dict[str, str] = {}
152+
for ring, tier in upgrade_rings.items():
153+
if not isinstance(ring, str) or not ring.strip():
154+
continue
155+
if not isinstance(tier, str) or not tier.strip():
156+
raise ValueError(f"repo tier manifest invalid tier for {ring}")
157+
ring_name = ring.strip()
158+
ring_order.append(ring_name)
159+
ring_to_tier[ring_name] = tier.strip()
160+
tier_to_ring = {tier: ring for ring, tier in ring_to_tier.items()}
161+
return ring_order, ring_to_tier, tier_to_ring
162+
163+
164+
def _normalize_ring(value: str) -> str:
165+
ring = value.strip().lower()
166+
aliases = {
167+
"0": "ring_a",
168+
"ring_a": "ring_a",
169+
"1": "ring_b",
170+
"ring_b": "ring_b",
171+
"2": "ring_c",
172+
"ring_c": "ring_c",
173+
"3": "ring_d",
174+
"ring_d": "ring_d",
175+
"4": "ring_e",
176+
"ring_e": "ring_e",
177+
}
178+
return aliases.get(ring, value.strip())
179+
180+
181+
def _status_bucket(result: RepoCheckResult) -> str:
182+
if result.issues:
183+
return "strict"
184+
if result.warnings:
185+
return "warning"
186+
return "clean"
187+
188+
189+
def _issue_kind(message: str) -> str:
190+
if message.startswith("bundle pin mismatch for "):
191+
return "bundle pin mismatch"
192+
if message.startswith("forbidden short/invalid ref "):
193+
return "forbidden short/invalid ref"
194+
if message.startswith("forbidden ref 'main'"):
195+
return "forbidden ref 'main'"
196+
if message.startswith("legacy dependency files detected"):
197+
return "legacy dependency files detected"
198+
if message.startswith("unmanaged qsl dependency "):
199+
return "unmanaged qsl dependency"
200+
return "other"
201+
202+
203+
def _parse_bundle_mismatch(message: str) -> tuple[str | None, str | None]:
204+
prefix = "bundle pin mismatch for "
205+
if not message.startswith(prefix):
206+
return None, None
207+
try:
208+
after_prefix = message[len(prefix) :]
209+
package, remainder = after_prefix.split(" in ", 1)
210+
source = remainder.split(":", 1)[0]
211+
except ValueError:
212+
return None, None
213+
return package.strip(), source.strip()
214+
215+
216+
def _workspace_report(results: list[RepoCheckResult], compat_root: Path) -> dict[str, Any]:
217+
ring_order, ring_to_tier, tier_to_ring = _load_repo_tiers(compat_root)
218+
ring_index = {ring: idx for idx, ring in enumerate(ring_order)}
219+
grouped: dict[str, dict[str, Any]] = {
220+
ring: {
221+
"ring": ring,
222+
"tier": ring_to_tier.get(ring, "unknown"),
223+
"total": 0,
224+
"strict": 0,
225+
"warning": 0,
226+
"clean": 0,
227+
"repositories": [],
228+
}
229+
for ring in ring_order
230+
}
231+
issue_counts: Counter[str] = Counter()
232+
package_hotspots: Counter[tuple[str, str]] = Counter()
233+
234+
for result in sorted(
235+
results,
236+
key=lambda item: (ring_index.get(_normalize_ring(tier_to_ring.get(item.tier, item.upgrade_ring)), len(ring_order)), item.repo),
237+
):
238+
canonical_ring = _normalize_ring(tier_to_ring.get(result.tier, result.upgrade_ring))
239+
bucket = grouped.setdefault(
240+
canonical_ring,
241+
{
242+
"ring": canonical_ring,
243+
"tier": ring_to_tier.get(canonical_ring, result.tier or "unknown"),
244+
"total": 0,
245+
"strict": 0,
246+
"warning": 0,
247+
"clean": 0,
248+
"repositories": [],
249+
},
250+
)
251+
status = _status_bucket(result)
252+
bucket["total"] += 1
253+
bucket[status] += 1
254+
bucket["repositories"].append(
255+
{
256+
"repo": result.repo,
257+
"status": status,
258+
"issues": len(result.issues),
259+
"warnings": len(result.warnings),
260+
}
261+
)
262+
263+
for message in result.issues + result.warnings:
264+
issue_counts[_issue_kind(message)] += 1
265+
package, source = _parse_bundle_mismatch(message)
266+
if package and source:
267+
package_hotspots[(package, source)] += 1
268+
269+
rings = [grouped[ring] for ring in ring_order if ring in grouped and grouped[ring]["total"]]
270+
extra_rings = [grouped[ring] for ring in grouped if ring not in ring_order and grouped[ring]["total"]]
271+
272+
return {
273+
"schema_version": 1,
274+
"total_repositories": len(results),
275+
"strict_repositories": sum(1 for result in results if result.issues),
276+
"warning_repositories": sum(1 for result in results if result.warnings and not result.issues),
277+
"clean_repositories": sum(1 for result in results if not result.issues and not result.warnings),
278+
"issue_counts": dict(sorted(issue_counts.items())),
279+
"bundle_hotspots": [
280+
{"package": package, "source": source, "count": count}
281+
for (package, source), count in package_hotspots.most_common(10)
282+
],
283+
"rings": rings + extra_rings,
284+
"repositories": [_result_payload(result) for result in results],
285+
}
286+
287+
288+
def _workspace_plan(report: dict[str, Any]) -> dict[str, Any]:
289+
phases: list[dict[str, Any]] = []
290+
for ring in report["rings"]:
291+
strict_repos = [repo for repo in ring["repositories"] if repo["status"] == "strict"]
292+
warning_repos = [repo for repo in ring["repositories"] if repo["status"] == "warning"]
293+
clean_repos = [repo for repo in ring["repositories"] if repo["status"] == "clean"]
294+
295+
next_actions: list[str] = []
296+
if strict_repos:
297+
next_actions.append("先清理 strict mismatch,再推进下一 ring。")
298+
next_actions.extend(f"修复 {repo['repo']}{repo['issues']} 个 issue)" for repo in strict_repos)
299+
elif warning_repos:
300+
next_actions.append("当前只有 warning;可先保持只读观察,但在 release 前需要清零。")
301+
next_actions.extend(f"确认 {repo['repo']} 的 warning 是否允许短期保留({repo['warnings']} 个 warning)" for repo in warning_repos)
302+
else:
303+
next_actions.append("当前 ring 已收敛,可以作为下一 ring 的 gate。")
304+
305+
phases.append(
306+
{
307+
"ring": ring["ring"],
308+
"tier": ring["tier"],
309+
"strict_repositories": strict_repos,
310+
"warning_repositories": warning_repos,
311+
"clean_repositories": clean_repos,
312+
"next_actions": next_actions,
313+
}
314+
)
315+
316+
return {
317+
"schema_version": 1,
318+
"phases": phases,
319+
"bundle_hotspots": report["bundle_hotspots"],
320+
"issue_counts": report["issue_counts"],
321+
}
322+
323+
324+
def _print_report(report: dict[str, Any]) -> None:
325+
print(
326+
"QSL workspace report: "
327+
f"repos={report['total_repositories']} "
328+
f"strict={report['strict_repositories']} "
329+
f"warnings={report['warning_repositories']} "
330+
f"clean={report['clean_repositories']}"
331+
)
332+
print("Issue breakdown:")
333+
for kind, count in report["issue_counts"].items():
334+
print(f" {kind}: {count}")
335+
print("Ring summary:")
336+
for ring in report["rings"]:
337+
print(
338+
f" {ring['ring']} / {ring['tier']}: "
339+
f"total={ring['total']} strict={ring['strict']} warning={ring['warning']} clean={ring['clean']}"
340+
)
341+
repos = ", ".join(repo["repo"] for repo in ring["repositories"])
342+
if repos:
343+
print(f" repos: {repos}")
344+
if report["bundle_hotspots"]:
345+
print("Hotspots:")
346+
for hotspot in report["bundle_hotspots"]:
347+
print(f" {hotspot['package']} @ {hotspot['source']}: {hotspot['count']}")
348+
349+
350+
def _print_plan(plan: dict[str, Any]) -> None:
351+
print("QSL ring-by-ring convergence plan:")
352+
for idx, phase in enumerate(plan["phases"], start=1):
353+
print(f"{idx}. {phase['ring']} / {phase['tier']}")
354+
for repo in phase["strict_repositories"]:
355+
print(f" - strict: {repo['repo']} ({repo['issues']} issues)")
356+
for repo in phase["warning_repositories"]:
357+
print(f" - warning: {repo['repo']} ({repo['warnings']} warnings)")
358+
for action in phase["next_actions"]:
359+
print(f" - {action}")
360+
if plan["bundle_hotspots"]:
361+
print("Top bundle hotspots:")
362+
for hotspot in plan["bundle_hotspots"]:
363+
print(f" - {hotspot['package']} @ {hotspot['source']}: {hotspot['count']}")
364+
365+
127366
def _print_check_result(result: RepoCheckResult) -> None:
128367
status = "PASS" if result.ok else "FAIL"
129368
warning_suffix = f", warnings={len(result.warnings)}" if result.warnings else ""
@@ -164,6 +403,27 @@ def _cmd_check_all(args: argparse.Namespace) -> int:
164403
return 1 if args.strict and payload["failed_repositories"] else 0
165404

166405

406+
def _cmd_report(args: argparse.Namespace) -> int:
407+
results = check_all(projects_root=args.projects_root.resolve(), compat_root=args.compat_root.resolve())
408+
report = _workspace_report(results, compat_root=args.compat_root.resolve())
409+
if args.json:
410+
print(json.dumps(report, ensure_ascii=False, indent=2))
411+
else:
412+
_print_report(report)
413+
return 0
414+
415+
416+
def _cmd_plan(args: argparse.Namespace) -> int:
417+
results = check_all(projects_root=args.projects_root.resolve(), compat_root=args.compat_root.resolve())
418+
report = _workspace_report(results, compat_root=args.compat_root.resolve())
419+
plan = _workspace_plan(report)
420+
if args.json:
421+
print(json.dumps(plan, ensure_ascii=False, indent=2))
422+
else:
423+
_print_plan(plan)
424+
return 0
425+
426+
167427
def _matrix_payload(projects_root: Path) -> dict[str, Any]:
168428
return check_internal_dependency_matrix.matrix_payload(
169429
check_internal_dependency_matrix.collect_dependency_pins_from_projects(projects_root)
@@ -224,6 +484,18 @@ def build_parser() -> argparse.ArgumentParser:
224484
check_all_parser.add_argument("--verbose", action="store_true", help="Print passing repositories too.")
225485
check_all_parser.set_defaults(func=_cmd_check_all)
226486

487+
report = subparsers.add_parser("report", help="Summarize current QSL workspace status by ring and issue type.")
488+
report.add_argument("--projects-root", type=Path, default=DEFAULT_PROJECTS_ROOT)
489+
report.add_argument("--compat-root", type=Path, default=DEFAULT_COMPAT_ROOT)
490+
report.add_argument("--json", action="store_true")
491+
report.set_defaults(func=_cmd_report)
492+
493+
plan = subparsers.add_parser("plan", help="Render a ring-by-ring QSL convergence plan from current workspace state.")
494+
plan.add_argument("--projects-root", type=Path, default=DEFAULT_PROJECTS_ROOT)
495+
plan.add_argument("--compat-root", type=Path, default=DEFAULT_COMPAT_ROOT)
496+
plan.add_argument("--json", action="store_true")
497+
plan.set_defaults(func=_cmd_plan)
498+
227499
matrix = subparsers.add_parser("generate-matrix", help="Generate or check the internal dependency matrix.")
228500
matrix.add_argument("--projects-root", type=Path, default=DEFAULT_PROJECTS_ROOT)
229501
matrix.add_argument("--matrix", type=Path, default=DEFAULT_MATRIX_PATH)

0 commit comments

Comments
 (0)