1313import json
1414import subprocess
1515import sys
16+ import tomllib
17+ from collections import Counter
1618from dataclasses import dataclass
1719from pathlib import Path
1820from 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
5662def _is_quant_repo (repo_dir : Path ) -> bool :
@@ -82,18 +88,24 @@ def iter_qsl_repos(projects_root: Path) -> list[Path]:
8288def 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+
127366def _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+
167427def _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