Skip to content

Commit f098fbe

Browse files
authored
Merge pull request #6036 from loopx-project/codex/installed-canary-repository-root-r144
fix(canary): use the caller checkout for installed validation
2 parents 073be70 + bd71c31 commit f098fbe

12 files changed

Lines changed: 333 additions & 112 deletions

File tree

‎docs/development/testing-and-quality.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,28 @@ Canary 使用启动 LoopX 的 `sys.executable` 执行 Python 检查,显示的
526526
目标 worktree。已有兼容虚拟环境也可用 `python -m pip install -e ".[test]"` 安装源码,
527527
随后直接使用该环境的命令。
528528

529+
Canary is a repository development tool even when its CLI comes from a wheel.
530+
Run `premerge`, `run` and `smoke-suite` inside the intended source checkout
531+
(a subdirectory is also supported):
532+
the catalog, discovered examples, subprocess cwd and tracked-write guard share
533+
that checkout root. `canary premerge --catalog PATH` overrides the catalog only;
534+
it does not change the execution root or authorize commands outside its examples.
535+
Standalone read-only catalog commands retain their default catalog resolution;
536+
use their existing `--catalog PATH` when invoking an installed CLI.
537+
The wheel does not bundle the repository's docs/examples. Missing development
538+
inputs fail with a checkout/catalog recovery instruction, rather than an empty
539+
successful validation. `--no-execute` still previews; required CQR verification
540+
and merge authority remain separate gates.
541+
542+
即使用 wheel 的 CLI,Canary 仍是仓库开发工具。`premerge`、`run`、`smoke-suite`
543+
在目标完整源码 checkout
544+
(也支持子目录)后运行;catalog、example 发现、子进程 cwd 与 tracked 写入保护
545+
共用该仓库根。`canary premerge --catalog PATH` 仅指定 catalog,不切执行根,也不
546+
授权执行 examples 之外的命令。独立的只读 catalog 命令保留既有默认解析,安装态
547+
可用其现有 `--catalog PATH` 指定输入。wheel 不携带仓库 docs/examples;缺少输入时明确
548+
提示补齐 checkout/catalog,不会以空检查冒充成功。`--no-execute` 仍是预览,
549+
required CQR 校验与合并权限继续分别判断。
550+
529551
The repository does not currently track `.python-version` or `uv.lock`. `uv`
530552
creates a local lockfile during resolution; keep that generated file out of
531553
unrelated PRs. Introducing a shared lock or interpreter pin is a separate

‎examples/canary/catalog-run-e2e-smoke.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def assert_git_required_smoke_skips_without_git_worktree() -> None:
114114
runner_module.REPO_ROOT = temp_root
115115
try:
116116
payload = build_catalog_canary_run(
117+
catalog_path=REPO_ROOT / "docs/concepts/interaction-pattern-catalog.md",
117118
profiles=["repo-architecture-budget"],
118119
max_checks_per_profile=1,
119120
check_limit=1,

‎examples/canary/premerge-validation-gate-smoke.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,8 @@ def assert_external_dirty_worktree_uses_caller_repo() -> None:
424424
"--from-git-diff",
425425
"--git-diff-base",
426426
"HEAD",
427+
"--catalog",
428+
str(REPO_ROOT / "docs/concepts/interaction-pattern-catalog.md"),
427429
"--tier",
428430
"quick",
429431
"--no-progress",
@@ -465,6 +467,8 @@ def assert_external_dirty_worktree_uses_caller_repo() -> None:
465467
"--from-git-diff",
466468
"--git-diff-base",
467469
"HEAD",
470+
"--catalog",
471+
str(REPO_ROOT / "docs/concepts/interaction-pattern-catalog.md"),
468472
"--tier",
469473
"quick",
470474
"--no-progress",

‎examples/canary/smoke-suite-runner-smoke.py‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,8 @@ def assert_parallel_jobs_execute_and_preserve_report_order() -> None:
425425
original_tracked_change_paths = canary_runner._tracked_change_paths
426426
events: list[dict[str, object]] = []
427427

428-
def fake_tracked_change_paths() -> tuple[bool, list[str], str]:
428+
def fake_tracked_change_paths(repo_root: Path | None = None) -> tuple[bool, list[str], str]:
429+
assert repo_root == REPO_ROOT.resolve(), repo_root
429430
return True, [], ""
430431

431432
try:
@@ -473,7 +474,8 @@ def assert_parallel_jobs_keep_marked_smokes_serial() -> None:
473474
original_tracked_change_paths = canary_runner._tracked_change_paths
474475
observed: list[tuple[str, int | None]] = []
475476

476-
def fake_tracked_change_paths() -> tuple[bool, list[str], str]:
477+
def fake_tracked_change_paths(repo_root: Path | None = None) -> tuple[bool, list[str], str]:
478+
assert repo_root == REPO_ROOT.resolve(), repo_root
477479
return True, [], ""
478480

479481
def fake_run_check(
@@ -482,8 +484,9 @@ def fake_run_check(
482484
timeout_seconds: float,
483485
check_index: int | None = None,
484486
check_count: int | None = None,
487+
repo_root: Path | None = None,
485488
) -> dict[str, object]:
486-
normalized = canary_runner.normalize_canary_command(str(check.get("command") or ""))
489+
normalized = canary_runner.normalize_canary_command(str(check.get("command") or ""), repo_root=repo_root)
487490
observed.append((str(normalized.get("script") or ""), check_index))
488491
return _fake_passed_check(
489492
check,
@@ -531,7 +534,8 @@ def assert_fail_fast_keeps_parallel_jobs_serial() -> None:
531534
original_run_check = canary_runner._run_check
532535
original_tracked_change_paths = canary_runner._tracked_change_paths
533536

534-
def fake_tracked_change_paths() -> tuple[bool, list[str], str]:
537+
def fake_tracked_change_paths(repo_root: Path | None = None) -> tuple[bool, list[str], str]:
538+
assert repo_root == REPO_ROOT.resolve(), repo_root
535539
return True, [], ""
536540

537541
try:
@@ -572,8 +576,9 @@ def _fake_passed_check(
572576
timeout_seconds: float,
573577
check_index: int | None = None,
574578
check_count: int | None = None,
579+
repo_root: Path | None = None,
575580
) -> dict[str, object]:
576-
normalized = canary_runner.normalize_canary_command(str(check.get("command") or ""))
581+
normalized = canary_runner.normalize_canary_command(str(check.get("command") or ""), repo_root=repo_root)
577582
result: dict[str, object] = {
578583
**check,
579584
"normalized": normalized,
@@ -596,13 +601,14 @@ def assert_readonly_run_rejects_and_restores_tracked_side_effects() -> None:
596601
restored: list[list[str]] = []
597602
calls = {"tracked": 0}
598603

599-
def fake_tracked_change_paths() -> tuple[bool, list[str], str]:
604+
def fake_tracked_change_paths(repo_root: Path | None = None) -> tuple[bool, list[str], str]:
605+
assert repo_root == REPO_ROOT.resolve(), repo_root
600606
calls["tracked"] += 1
601607
if calls["tracked"] == 1 or restored:
602608
return True, [], ""
603609
return True, ["examples/generated-tracked-side-effect.txt"], ""
604610

605-
def fake_restore_tracked_paths(paths: list[str]) -> dict[str, object]:
611+
def fake_restore_tracked_paths(paths: list[str], repo_root: Path | None = None) -> dict[str, object]:
606612
restored.append(paths)
607613
return {"ok": True, "restored_paths": paths}
608614

@@ -644,7 +650,8 @@ def assert_unavailable_git_worktree_is_reported_explicitly() -> None:
644650
original_run_check = canary_runner._run_check
645651
original_tracked_change_paths = canary_runner._tracked_change_paths
646652

647-
def fake_tracked_change_paths() -> tuple[bool, list[str], str]:
653+
def fake_tracked_change_paths(repo_root: Path | None = None) -> tuple[bool, list[str], str]:
654+
assert repo_root == REPO_ROOT.resolve(), repo_root
648655
return False, [], "not_a_git_worktree: fixture"
649656

650657
try:
@@ -680,7 +687,8 @@ def assert_tracked_side_effects_require_explicit_allow() -> None:
680687
original_tracked_change_paths = canary_runner._tracked_change_paths
681688
original_restore_tracked_paths = canary_runner._restore_tracked_paths
682689

683-
def fake_tracked_change_paths() -> tuple[bool, list[str], str]:
690+
def fake_tracked_change_paths(repo_root: Path | None = None) -> tuple[bool, list[str], str]:
691+
assert repo_root == REPO_ROOT.resolve(), repo_root
684692
return True, [], ""
685693

686694
def fail_restore_tracked_paths(paths: list[str]) -> dict[str, object]:

‎loopx/canary/planner.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1363,8 +1363,24 @@ def _split_semicolon(value: str) -> list[str]:
13631363
return [item.strip() for item in value.split(";") if item.strip()]
13641364

13651365

1366+
def resolve_canary_catalog_path(
1367+
catalog_path: Path | None = None, *, repo_root: Path | None = None,
1368+
) -> Path:
1369+
"""Resolve development inputs without treating an installed wheel as a checkout."""
1370+
path = (catalog_path or (
1371+
repo_root / "docs/concepts/interaction-pattern-catalog.md"
1372+
if repo_root is not None else DEFAULT_CATALOG_PATH
1373+
)).expanduser()
1374+
if not path.is_file():
1375+
raise ValueError(
1376+
f"canary catalog is missing: {path}; run from a complete LoopX source "
1377+
"checkout or supply --catalog PATH. The wheel does not bundle development inputs."
1378+
)
1379+
return path
1380+
1381+
13661382
def _read_catalog(catalog_path: Path | None = None) -> tuple[Path, str]:
1367-
path = (catalog_path or DEFAULT_CATALOG_PATH).expanduser()
1383+
path = resolve_canary_catalog_path(catalog_path)
13681384
return path, path.read_text(encoding="utf-8")
13691385

13701386

@@ -1831,7 +1847,8 @@ def render_catalog_canary_profiles_markdown(payload: dict[str, Any]) -> str:
18311847

18321848

18331849
def render_catalog_canary_plan_markdown(payload: dict[str, Any]) -> str:
1834-
inputs = payload.get("selection_inputs") if isinstance(payload.get("selection_inputs"), dict) else {}
1850+
inputs_value = payload.get('selection_inputs')
1851+
inputs = inputs_value if isinstance(inputs_value, dict) else {}
18351852
lines = [
18361853
"# Catalog Canary Plan",
18371854
"",

‎loopx/canary/premerge.py‎

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import Any, Callable
88

99
from ..contract import scan_public_boundary
10-
from .planner import REPO_ROOT
10+
from .planner import REPO_ROOT, resolve_canary_catalog_path
1111
from .runner import build_canary_smoke_suite_run
1212

1313

@@ -172,8 +172,8 @@ def _public_boundary_changed_files_run(
172172
except ValueError:
173173
display_paths.append(str(path))
174174
display_argv = ["loopx", "check"]
175-
for path in display_paths:
176-
display_argv.extend(["--scan-path", path])
175+
for display_path in display_paths:
176+
display_argv.extend(["--scan-path", display_path])
177177
check: dict[str, Any] = {
178178
"source": "premerge_public_boundary",
179179
"tier": "default",
@@ -621,11 +621,8 @@ def _gate_status(
621621

622622

623623
def _check_command(check: dict[str, Any]) -> str:
624-
normalized = (
625-
check.get("normalized")
626-
if isinstance(check.get("normalized"), dict)
627-
else {}
628-
)
624+
normalized_value = check.get('normalized')
625+
normalized = normalized_value if isinstance(normalized_value, dict) else {}
629626
display_argv = normalized.get("display_argv")
630627
if isinstance(display_argv, list) and display_argv:
631628
return " ".join(str(part) for part in display_argv)
@@ -717,12 +714,10 @@ def apply_change_quality_verification(
717714
verification.get("enforcement_applied")
718715
and verification.get("ok") is False
719716
)
720-
gate = payload.get("gate") if isinstance(payload.get("gate"), dict) else {}
721-
summary = (
722-
payload.get("validation_summary")
723-
if isinstance(payload.get("validation_summary"), dict)
724-
else {}
725-
)
717+
gate_value = payload.get('gate')
718+
gate = gate_value if isinstance(gate_value, dict) else {}
719+
summary_value = payload.get('validation_summary')
720+
summary = summary_value if isinstance(summary_value, dict) else {}
726721
gate["quality_receipt_failure_count"] = 1 if enforced_failure else 0
727722
summary["policy_failure_count"] = 1 if enforced_failure else 0
728723
if enforced_failure:
@@ -830,9 +825,12 @@ def build_premerge_validation_gate(
830825
include_deep_checks: bool | None = None,
831826
progress_callback: ProgressCallback | None = None,
832827
repo_root: Path | None = None,
828+
catalog_path: Path | None = None,
833829
) -> dict[str, Any]:
834830
target_repo_root = (repo_root or REPO_ROOT).resolve()
835831
files = _dedupe(list(changed_files or []))
832+
if files:
833+
catalog_path = resolve_canary_catalog_path(catalog_path, repo_root=target_repo_root)
836834
classification = classify_premerge_surfaces(files, repo_root=target_repo_root)
837835
limits = _tier_limits(tier)
838836
include_deep = bool(limits["deep"] if include_deep_checks is None else include_deep_checks)
@@ -874,6 +872,7 @@ def build_premerge_validation_gate(
874872
if module_colocation is not None:
875873
direct_checks.append(module_colocation)
876874

875+
catalog_run: dict[str, Any]
877876
if files:
878877
catalog_progress = _section_progress_callback(
879878
progress_callback,
@@ -886,6 +885,8 @@ def build_premerge_validation_gate(
886885
)
887886
catalog_run = build_canary_smoke_suite_run(
888887
suite="catalog-plan",
888+
repo_root=target_repo_root,
889+
catalog_path=catalog_path,
889890
changed_files=files,
890891
include_deep_checks=include_deep,
891892
max_checks_per_family=3,
@@ -905,10 +906,12 @@ def build_premerge_validation_gate(
905906
executed_check_count=catalog_run.get("executed_check_count"),
906907
failure_count=catalog_run.get("failure_count"),
907908
)
908-
catalog_run = downgrade_inherited_baseline_failures(
909+
adjusted_catalog_run = downgrade_inherited_baseline_failures(
909910
catalog_run,
910911
changed_files=files,
911912
)
913+
assert adjusted_catalog_run is not None # A dictionary input retains a result.
914+
catalog_run = adjusted_catalog_run
912915
else:
913916
catalog_run = _empty_smoke_suite_run(
914917
suite="catalog-plan",
@@ -940,6 +943,8 @@ def build_premerge_validation_gate(
940943
)
941944
risk_profile_run = build_canary_smoke_suite_run(
942945
suite="default-public",
946+
repo_root=target_repo_root,
947+
catalog_path=catalog_path,
943948
profiles=risk_profiles,
944949
include_deep_checks=include_deep,
945950
limit=profile_limit,
@@ -1094,12 +1099,10 @@ def _emit_section_progress(
10941099

10951100

10961101
def render_premerge_validation_gate_markdown(payload: dict[str, Any]) -> str:
1097-
gate = payload.get("gate") if isinstance(payload.get("gate"), dict) else {}
1098-
classification = (
1099-
payload.get("classification")
1100-
if isinstance(payload.get("classification"), dict)
1101-
else {}
1102-
)
1102+
gate_value = payload.get('gate')
1103+
gate = gate_value if isinstance(gate_value, dict) else {}
1104+
classification_value = payload.get('classification')
1105+
classification = classification_value if isinstance(classification_value, dict) else {}
11031106
lines = [
11041107
"# Pre-Merge Validation Gate",
11051108
"",
@@ -1152,11 +1155,8 @@ def append_run(title: str, run: Any) -> None:
11521155
for check in run.get("selected_checks", [])[:12]:
11531156
if not isinstance(check, dict):
11541157
continue
1155-
normalized = (
1156-
check.get("normalized")
1157-
if isinstance(check.get("normalized"), dict)
1158-
else {}
1159-
)
1158+
normalized_value = check.get('normalized')
1159+
normalized = normalized_value if isinstance(normalized_value, dict) else {}
11601160
command = " ".join(str(part) for part in normalized.get("display_argv") or [])
11611161
lines.append(f"- `{check.get('status') or 'ready'}` {command or check.get('command')}")
11621162
if check.get("advisory_reason"):
@@ -1172,11 +1172,8 @@ def append_run(title: str, run: Any) -> None:
11721172
else None
11731173
)
11741174
if quality is not None:
1175-
evidence = (
1176-
quality.get("pr_evidence")
1177-
if isinstance(quality.get("pr_evidence"), dict)
1178-
else {}
1179-
)
1175+
evidence_value = quality.get('pr_evidence')
1176+
evidence = evidence_value if isinstance(evidence_value, dict) else {}
11801177
lines.extend(
11811178
[
11821179
"",

0 commit comments

Comments
 (0)