@ccoalm/ccl-skills 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/marketplace/plugins/ccl-skills/hooks/hooks.json +11 -0
  3. package/dist/assets/marketplace/plugins/ccl-skills/hooks/remind-unverified-cli-flag.sh +309 -0
  4. package/dist/assets/marketplace/plugins/ccl-skills/hooks/test_remind_unverified_cli_flag.sh +483 -0
  5. package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +5 -0
  6. package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/SKILL.md +10 -8
  7. package/dist/assets/marketplace/plugins/ccl-skills/skills/app-cross-platform-dev/references/mobile-quality-release.md +1 -1
  8. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/SKILL.md +16 -17
  9. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/client-routing.md +1 -1
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/staged-review-contract.md +195 -7
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/references/timeout-auth-and-capabilities.md +3 -3
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/claude_review.sh +13 -5
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/codex_review.sh +9 -3
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/kimi_review.sh +9 -3
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/normalize_review_timeout.sh +22 -0
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/opencode_review.sh +9 -3
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +1540 -129
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_claude_review_probe.sh +8 -3
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_client_compat.py +76 -1
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +1858 -3
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_update_review_plan_intent.sh +789 -0
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/update_review_plan_intent.py +513 -0
  23. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/SKILL.md +1 -1
  24. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/architecture-playbook.md +2 -0
  25. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/data-platform-architecture.md +1 -1
  26. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/event-driven-architecture.md +14 -11
  27. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-architecture/references/multi-tenant-isolation.md +2 -2
  28. package/dist/assets/marketplace/plugins/ccl-skills/skills/go-microservice-dev/SKILL.md +5 -1
  29. package/dist/assets/marketplace/plugins/ccl-skills/skills/llm-inference-integration/SKILL.md +2 -1
  30. package/dist/assets/marketplace/plugins/ccl-skills/skills/miniapp-product-dev/SKILL.md +13 -11
  31. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/SKILL.md +64 -0
  32. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/agents/openai.yaml +4 -0
  33. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/async-lifecycle-and-performance.md +72 -0
  34. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/runtime-and-project-contract.md +58 -0
  35. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/source-map.md +41 -0
  36. package/dist/assets/marketplace/plugins/ccl-skills/skills/nodejs-service-dev/references/verification-diagnostics-and-security.md +63 -0
  37. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/SKILL.md +1 -1
  38. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/sli-slo-design.md +25 -9
  39. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/source-register.md +1 -0
  40. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/SKILL.md +1 -1
  41. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/promotion-gate-and-review.md +16 -0
  42. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-release-engineering/references/secret-and-config-management.md +7 -0
  43. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/retry-timeout-circuit-breaker.md +11 -0
  44. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/SKILL.md +8 -10
  45. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/design-routing-and-readiness.md +10 -14
  46. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/verify-developer-experience.md +1 -1
  47. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/SKILL.md +135 -86
  48. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/behavioral-aesthetic-logic.md +66 -80
  49. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/delivery-contract.md +275 -0
  50. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-execution-checklist.md +88 -214
  51. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-impl-naming-and-versioning.md +2 -2
  52. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-intake-and-acceptance.md +10 -8
  53. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/design-system-source-of-truth.md +4 -5
  54. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/external-ui-ux-quality-benchmarks.md +112 -95
  55. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/frontend-code-evidence-map.md +30 -21
  56. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/interaction-design-patterns.md +22 -3
  57. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/layout-recipes-and-screenshot-acceptance.md +20 -17
  58. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-project-token-consistency.md +7 -9
  59. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/multi-stack-strategy.md +14 -10
  60. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/operational-processing-workflows.md +2 -0
  61. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/platform-mobile-patterns.md +1 -1
  62. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-lifecycle-acceptance-and-iteration.md +9 -6
  63. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/product-surface-patterns.md +3 -0
  64. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/source-map.md +37 -10
  65. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/tokens-and-components.md +7 -1
  66. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-audit.md +8 -5
  67. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/ui-ux-design-development.md +16 -5
  68. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-ui-ux-design/references/visual-craft.md +4 -2
  69. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/SKILL.md +5 -1
  70. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/architecture-playbook.md +1 -1
  71. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/audit-history-architecture.md +31 -0
  72. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/data-platform-architecture.md +1 -1
  73. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/event-driven-architecture.md +7 -4
  74. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/multi-tenant-isolation.md +2 -2
  75. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/notification-architecture.md +28 -0
  76. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/packaging-runtime-readiness.md +1 -1
  77. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/replay-comparison-architecture.md +28 -0
  78. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-architecture/references/workflow-state-architecture.md +39 -0
  79. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/SKILL.md +10 -7
  80. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/ai-service-wiring-patterns.md +8 -0
  81. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/audit-history-patterns.md +29 -0
  82. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/background-job-patterns.md +16 -0
  83. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/batch-and-artifact-patterns.md +25 -1
  84. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/notification-patterns.md +40 -0
  85. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/public-api-security-patterns.md +1 -1
  86. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/replay-comparison-patterns.md +30 -0
  87. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/state-machine-task-patterns.md +48 -0
  88. package/dist/assets/marketplace/plugins/ccl-skills/skills/python-service-dev/references/testing-and-quality-patterns.md +10 -1
  89. package/dist/assets/marketplace/plugins/ccl-skills/skills/release-coordination/SKILL.md +2 -0
  90. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/SKILL.md +4 -4
  91. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/coverage-exhaustion-traps.md +45 -0
  92. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/dual-track-review-gate.md +142 -4
  93. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +21 -2
  94. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/extraction-quickstart.md +11 -9
  95. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/firing-point-placement.md +8 -0
  96. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/parallel-stack-references-pattern.md +5 -4
  97. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/r0-leakage-audit.md +102 -0
  98. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +69 -0
  99. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-to-skill-extraction.md +10 -0
  100. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/uiux-judgment-extraction.md +6 -6
  101. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/validation-and-landing.md +4 -3
  102. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh +93 -2
  103. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/check-parallel-stack-parity.sh +119 -0
  104. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/extraction_review_gate.sh +22 -0
  105. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +49 -4
  106. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/obligation-ledger.py +2748 -0
  107. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/register-firing-path-resolution.rb +20 -5
  108. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/shared_git_surface_gate.py +1142 -0
  109. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_parallel_stack_parity.sh +183 -0
  110. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_regressions.sh +19 -0
  111. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_skill_catalog.sh +41 -4
  112. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_ci_checkout_ref_binding.sh +120 -0
  113. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_entrypoint_domain_scan_terms.sh +82 -8
  114. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_extraction_review_gate.sh +336 -0
  115. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_impact_chain_self_adjudication.sh +82 -10
  116. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger.sh +1416 -0
  117. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_obligation_ledger_repo_audit.sh +57 -0
  118. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_register_firing_path_wiring.sh +141 -4
  119. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_routing_pointer_integrity.sh +3 -1
  120. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_shared_git_surface_gate.sh +1696 -0
  121. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_delivery_contract.sh +2117 -0
  122. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_uiux_loading_budget.sh +316 -0
  123. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_extraction_review_state.sh +1176 -0
  124. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_validate_skill_cross_refs.sh +31 -1
  125. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate-skill.sh +9 -4
  126. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/validate_extraction_review_state.py +980 -0
  127. package/dist/assets/marketplace/plugins/ccl-skills/skills/terminal-cli-dev/SKILL.md +9 -6
  128. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/SKILL.md +11 -11
  129. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/client-runtime-test-matrices.md +10 -2
  130. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/fitness-functions.md +16 -0
  131. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/scenario-testing.md +1 -1
  132. package/dist/assets/marketplace/plugins/ccl-skills/skills/testing-strategy/references/test-code-authoring-patterns.md +16 -5
  133. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/SKILL.md +5 -3
  134. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/delivery-face-closeout.md +16 -6
  135. package/dist/assets/marketplace/plugins/ccl-skills/skills/tighten-doc/references/self-benchmark-baseline.md +37 -0
  136. package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/SKILL.md +7 -5
  137. package/dist/assets/marketplace/plugins/ccl-skills/skills/web-react-dev/references/complex-workspace-patterns.md +1 -1
  138. package/dist/assets/release.json +275 -105
  139. package/package.json +1 -1
@@ -0,0 +1,1696 @@
1
+ #!/usr/bin/env bash
2
+ # Behavior regression for the shared Git/PR metadata leakage gate.
3
+ set -euo pipefail
4
+
5
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
6
+ GATE="$SCRIPT_DIR/shared_git_surface_gate.py"
7
+ [ -f "$GATE" ] || { echo "FAIL: gate not found: $GATE" >&2; exit 1; }
8
+
9
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/shared-git-surface.XXXXXX")"
10
+ trap 'rm -rf "$TMP"' EXIT
11
+
12
+ fail() { echo "FAIL: $*" >&2; exit 1; }
13
+ assert_rc() { [ "$1" = "$2" ] || fail "expected rc=$2 got rc=$1 ($3)"; }
14
+ assert_equals() { [ "$1" = "$2" ] || fail "expected '$1' = '$2' ($3)"; }
15
+ assert_contains() { case "$2" in *"$1"*) : ;; *) fail "expected '$1' ($3): $2";; esac; }
16
+ assert_not_contains() { case "$2" in *"$1"*) fail "unexpected '$1' ($3): $2";; *) : ;; esac; }
17
+
18
+ REPO="$TMP/repo"
19
+ mkdir -p "$REPO"
20
+ git init -q -b main "$REPO"
21
+ git -C "$REPO" config user.email tester@example.invalid
22
+ git -C "$REPO" config user.name "Synthetic Tester"
23
+ printf 'baseline\n' >"$REPO/file.txt"
24
+ git -C "$REPO" add file.txt
25
+
26
+ # Construct the provider URL at runtime so the repository test source does not
27
+ # itself carry a clickable session URL.
28
+ session_url="https://claude"'.'"ai/code/"'synthetic_fixture_123456'
29
+ claude_product_url="https://claude"'.'"ai/code"
30
+ kimi_url="https://kimi"'.'"com/share/"'synthetic_fixture_abcdef'
31
+ gemini_url="https://g"'.'"co/gemini/share/"'synthetic_fixture_ghijkl'
32
+ google_product_url="https://g"'.'"co/product/share/"'synthetic_fixture_product'
33
+ conversation_uuid='12345678-1234-4abc-8def-123456789abc'
34
+ chatgpt_conversation_url="https://chatgpt"'.'"com/c/$conversation_uuid"
35
+ claude_chat_url="https://claude"'.'"ai/chat/$conversation_uuid"
36
+ chatgpt_https_default_port_url="https://chatgpt"'.'"com:443/share/$conversation_uuid"
37
+ chatgpt_http_default_port_url="http://chatgpt"'.'"com:80/share/$conversation_uuid"
38
+ chatgpt_wrong_port_url="https://chatgpt"'.'"com:80/share/$conversation_uuid"
39
+ chatgpt_nested_help_url="https://chatgpt"'.'"com/help/chat/$conversation_uuid"
40
+ claude_nonconversation_url="https://claude"'.'"ai/chat/product-guide"
41
+ printf 'baseline debt\n\nClaude-Session: %s\n' "$session_url" >"$TMP/message.txt"
42
+ git -C "$REPO" commit -q -F "$TMP/message.txt"
43
+ BASE="$(git -C "$REPO" rev-parse HEAD)"
44
+ git -C "$REPO" switch -q -c feature/sanitized-capability
45
+
46
+ run_gate() {
47
+ set +e
48
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
49
+ python3 "$GATE" --repo "$REPO" --base-ref "$BASE" "$@" 2>&1)"
50
+ rc=$?
51
+ set -e
52
+ }
53
+
54
+ run_gate_without_base() {
55
+ set +e
56
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
57
+ python3 "$GATE" --repo "$REPO" "$@" 2>&1)"
58
+ rc=$?
59
+ set -e
60
+ }
61
+
62
+ run_gate_without_base
63
+ assert_rc "$rc" 2 "an unbound candidate base must fail closed"
64
+ assert_contains "candidate base is unresolved" "$out" "unresolved-base reason"
65
+
66
+ set +e
67
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
68
+ python3 "$GATE" --repo "$REPO" --base-ref refs/heads/missing-candidate-base \
69
+ --default-base-ref "$BASE" 2>&1)"
70
+ rc=$?
71
+ set -e
72
+ assert_rc "$rc" 2 "an invalid explicit base must not fall through to a lower-priority default"
73
+ assert_contains "explicit candidate base does not resolve" "$out" "invalid explicit-base reason"
74
+
75
+ # A branch-creation push has an all-zero `before` SHA. The repository-owned
76
+ # fallback can then point at the just-pushed HEAD and silently produce an empty
77
+ # range, skipping the commit message that crossed the merge boundary.
78
+ git -C "$REPO" update-ref refs/remotes/origin/dev "$BASE"
79
+ python3 - "$TMP/zero-before-push.json" <<'PY'
80
+ import json
81
+ import sys
82
+ from pathlib import Path
83
+
84
+ Path(sys.argv[1]).write_text(json.dumps({
85
+ "ref": "refs/heads/dev",
86
+ "before": "0" * 40,
87
+ }), encoding="utf-8")
88
+ PY
89
+ run_gate_without_base \
90
+ --event-json "$TMP/zero-before-push.json" \
91
+ --default-base-ref refs/remotes/origin/dev
92
+ assert_rc "$rc" 2 "a zero-before direct push with an empty candidate range must fail closed"
93
+ assert_contains "direct-push candidate commit range is empty" "$out" "zero-before push reason"
94
+
95
+ # PR text cannot substitute for the unscanned commit surface on a direct push.
96
+ printf 'Neutral proposed PR summary.\n' >"$TMP/zero-before-proposed-pr.txt"
97
+ run_gate_without_base \
98
+ --event-json "$TMP/zero-before-push.json" \
99
+ --default-base-ref refs/remotes/origin/dev \
100
+ --pr-text-file "$TMP/zero-before-proposed-pr.txt"
101
+ assert_rc "$rc" 2 "proposed PR text must not excuse an empty direct-push range"
102
+ assert_contains "direct-push candidate commit range is empty" "$out" "PR text cannot mask the push"
103
+
104
+ # A non-zero but unreachable `before` SHA is already a hard error and must not
105
+ # fall through to the repository-owned fallback.
106
+ python3 - "$TMP/unreachable-before-push.json" <<'PY'
107
+ import json
108
+ import sys
109
+ from pathlib import Path
110
+
111
+ Path(sys.argv[1]).write_text(json.dumps({
112
+ "ref": "refs/heads/dev",
113
+ "before": "f" * 40,
114
+ }), encoding="utf-8")
115
+ PY
116
+ run_gate_without_base \
117
+ --event-json "$TMP/unreachable-before-push.json" \
118
+ --default-base-ref refs/remotes/origin/dev
119
+ assert_rc "$rc" 2 "an unreachable non-zero push base must fail closed"
120
+ assert_contains "trusted event candidate base does not resolve" "$out" "unreachable push-base reason"
121
+
122
+ # Outside a direct-push event, an explicit empty range is valid for local
123
+ # pre-commit validation; historical debt at/before the base stays excluded.
124
+ run_gate
125
+ assert_rc "$rc" 0 "a local explicit empty range must remain valid"
126
+ assert_contains "commits=0" "$out" "local empty range remains explicit"
127
+
128
+ printf 'clean candidate\n' >>"$REPO/file.txt"
129
+ git -C "$REPO" commit -qam "describe bounded capability"
130
+ clean_head="$(git -C "$REPO" rev-parse HEAD)"
131
+ run_gate
132
+ assert_rc "$rc" 0 "sanitized candidate commit should pass while historical debt stays excluded"
133
+
134
+ # A pre-publication PR text check remains useful even when no candidate commit
135
+ # exists yet. The supplied non-empty file is itself an explicit scanned surface.
136
+ printf 'Neutral proposed PR summary.\n' >"$TMP/empty-range-proposed-pr.txt"
137
+ set +e
138
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
139
+ python3 "$GATE" --repo "$REPO" --base-ref "$clean_head" \
140
+ --branch-name feature/sanitized-capability \
141
+ --pr-text-file "$TMP/empty-range-proposed-pr.txt" 2>&1)"
142
+ rc=$?
143
+ set -e
144
+ assert_rc "$rc" 0 "a non-empty proposed PR surface may be checked with no candidate commits"
145
+ assert_contains "commits=0" "$out" "the allowed empty range remains explicit"
146
+
147
+ for empty_pr_case in zero-byte whitespace-only; do
148
+ case "$empty_pr_case" in
149
+ zero-byte) : >"$TMP/empty-range-proposed-pr.txt" ;;
150
+ whitespace-only) printf ' \t\r\n' >"$TMP/empty-range-proposed-pr.txt" ;;
151
+ esac
152
+ set +e
153
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
154
+ python3 "$GATE" --repo "$REPO" --base-ref "$clean_head" \
155
+ --branch-name feature/sanitized-capability \
156
+ --pr-text-file "$TMP/empty-range-proposed-pr.txt" 2>&1)"
157
+ rc=$?
158
+ set -e
159
+ assert_rc "$rc" 2 "$empty_pr_case proposed PR text must fail closed"
160
+ assert_contains \
161
+ "proposed PR text must contain non-whitespace content" "$out" \
162
+ "$empty_pr_case proposed PR diagnostic"
163
+ done
164
+
165
+ printf 'Private session: %s\n' "$session_url" >"$TMP/empty-range-proposed-pr.txt"
166
+ set +e
167
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
168
+ python3 "$GATE" --repo "$REPO" --base-ref "$clean_head" \
169
+ --branch-name feature/sanitized-capability \
170
+ --pr-text-file "$TMP/empty-range-proposed-pr.txt" 2>&1)"
171
+ rc=$?
172
+ set -e
173
+ assert_rc "$rc" 1 "a prohibited proposed PR surface must still block with no candidate commits"
174
+ assert_contains "category=ai_session_url" "$out" "empty-range proposed PR category"
175
+ assert_not_contains "synthetic_fixture_123456" "$out" "empty-range diagnostic stays redacted"
176
+
177
+ python3 - "$TMP/empty-range-pr-event.json" "$clean_head" <<'PY'
178
+ import json
179
+ import sys
180
+ from pathlib import Path
181
+
182
+ Path(sys.argv[1]).write_text(json.dumps({
183
+ "pull_request": {
184
+ "title": "Bound a shared capability",
185
+ "body": "Neutral behavior and verification summary.",
186
+ "head": {"ref": "feature/sanitized-capability"},
187
+ "base": {"sha": sys.argv[2]},
188
+ }
189
+ }), encoding="utf-8")
190
+ PY
191
+ run_gate_without_base --event-json "$TMP/empty-range-pr-event.json"
192
+ assert_rc "$rc" 0 "trusted non-empty PR text may be checked with no candidate commits"
193
+ assert_contains "commits=0" "$out" "the trusted PR exception remains explicit"
194
+
195
+ python3 - "$TMP/empty-range-pr-event.json" "$clean_head" "$session_url" <<'PY'
196
+ import json
197
+ import sys
198
+ from pathlib import Path
199
+
200
+ Path(sys.argv[1]).write_text(json.dumps({
201
+ "pull_request": {
202
+ "title": "Bound a shared capability",
203
+ "body": "Private session: " + sys.argv[3],
204
+ "head": {"ref": "feature/sanitized-capability"},
205
+ "base": {"sha": sys.argv[2]},
206
+ }
207
+ }), encoding="utf-8")
208
+ PY
209
+ run_gate_without_base --event-json "$TMP/empty-range-pr-event.json"
210
+ assert_rc "$rc" 1 "prohibited trusted PR text must block with no candidate commits"
211
+ assert_contains "surface=pull_request_body" "$out" "empty-range trusted PR locator"
212
+ assert_not_contains "synthetic_fixture_123456" "$out" "empty-range trusted PR stays redacted"
213
+
214
+ candidate_case() {
215
+ local branch="$1" message_file="$2"
216
+ git -C "$REPO" switch -q -C "$branch" "$clean_head"
217
+ printf '%s\n' "$branch" >>"$REPO/file.txt"
218
+ git -C "$REPO" add file.txt
219
+ git -C "$REPO" commit -q -F "$message_file"
220
+ }
221
+
222
+ candidate_identity_case() {
223
+ local branch="$1" author_name="$2" author_email="$3"
224
+ local committer_name="$4" committer_email="$5"
225
+ git -C "$REPO" switch -q -C "$branch" "$clean_head"
226
+ printf '%s\n' "$branch" >>"$REPO/file.txt"
227
+ git -C "$REPO" add file.txt
228
+ GIT_AUTHOR_NAME="$author_name" GIT_AUTHOR_EMAIL="$author_email" \
229
+ GIT_COMMITTER_NAME="$committer_name" GIT_COMMITTER_EMAIL="$committer_email" \
230
+ git -C "$REPO" commit -q -m "describe bounded capability"
231
+ }
232
+
233
+ printf 'change summary\n\nClaude-Session: %s\n' "$session_url" >"$TMP/session-message.txt"
234
+ candidate_case case/session-trailer "$TMP/session-message.txt"
235
+ session_commit="$(git -C "$REPO" rev-parse --short=12 HEAD)"
236
+ run_gate
237
+ assert_rc "$rc" 1 "new session trailer must block"
238
+ assert_contains "category=ai_session_url" "$out" "URL category"
239
+ assert_contains "category=ai_session_trailer" "$out" "trailer category"
240
+ assert_not_contains "synthetic_fixture_123456" "$out" "diagnostic must redact the session id"
241
+ run_gate --event-json "$TMP/zero-before-push.json"
242
+ assert_rc "$rc" 1 "an explicit ancestor must recover scanning for a zero-before push"
243
+ assert_contains "category=ai_session_trailer" "$out" "explicit push base scans the candidate commit"
244
+
245
+ # A clean HEAD must not hide a prohibited message in an earlier candidate
246
+ # commit. This kills a tempting `git log -1`/last-record-only regression.
247
+ printf 'clean follow-up\n' >>"$REPO/file.txt"
248
+ git -C "$REPO" commit -qam "follow up with neutral wording"
249
+ run_gate
250
+ assert_rc "$rc" 1 "an earlier candidate violation must remain blocking"
251
+ assert_contains "locator=$session_commit" "$out" "the earlier candidate commit was scanned"
252
+
253
+ # `--repo` is the single repository identity. Ambient Git routing variables
254
+ # must not redirect every subprocess to a clean decoy and certify the wrong
255
+ # object graph. Exercise both the shortest bypass and the full same-shape set.
256
+ DECOY_REPO="$TMP/git-env-decoy"
257
+ git clone -q "$REPO" "$DECOY_REPO"
258
+ git -C "$DECOY_REPO" reset -q --hard "$clean_head"
259
+ git -C "$DECOY_REPO" config user.email tester@example.invalid
260
+ git -C "$DECOY_REPO" config user.name "Synthetic Tester"
261
+ git -C "$DECOY_REPO" commit -q --allow-empty -m "neutral decoy head"
262
+ : >"$TMP/empty-grafts"
263
+
264
+ set +e
265
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
266
+ GIT_DIR="$DECOY_REPO/.git" \
267
+ python3 "$GATE" --repo "$REPO" --base-ref "$BASE" \
268
+ --branch-name feature/sanitized-capability 2>&1)"
269
+ rc=$?
270
+ set -e
271
+ assert_rc "$rc" 1 "ambient GIT_DIR must not redirect the candidate scan"
272
+ assert_contains "locator=$session_commit" "$out" "GIT_DIR isolation keeps the real candidate"
273
+
274
+ set +e
275
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
276
+ GIT_DIR="$DECOY_REPO/.git" \
277
+ GIT_WORK_TREE="$DECOY_REPO" \
278
+ GIT_INDEX_FILE="$DECOY_REPO/.git/index" \
279
+ GIT_COMMON_DIR="$DECOY_REPO/.git" \
280
+ GIT_NAMESPACE=synthetic-clean \
281
+ GIT_OBJECT_DIRECTORY="$DECOY_REPO/.git/objects" \
282
+ GIT_ALTERNATE_OBJECT_DIRECTORIES="$DECOY_REPO/.git/objects" \
283
+ GIT_CEILING_DIRECTORIES="$DECOY_REPO" \
284
+ GIT_GRAFT_FILE="$TMP/empty-grafts" \
285
+ python3 "$GATE" --repo "$REPO" --base-ref "$BASE" \
286
+ --branch-name feature/sanitized-capability 2>&1)"
287
+ rc=$?
288
+ set -e
289
+ assert_rc "$rc" 1 "ambient Git repository-routing matrix must be ignored"
290
+ assert_contains "locator=$session_commit" "$out" "routing-matrix isolation keeps the real candidate"
291
+ assert_not_contains "$DECOY_REPO" "$out" "routing diagnostics do not echo decoy paths"
292
+
293
+ # Git's runtime config channels are repository-routing channels too. An
294
+ # injected include can supply core.worktree and executable options even though
295
+ # every command also uses `-C <repo>`. Keep the end-to-end range assertion and
296
+ # probe the shared subprocess helper so sanitization cannot regress one key at
297
+ # a time.
298
+ python3 - "$GATE" "$REPO" "$DECOY_REPO" "$TMP" "$BASE" "$session_commit" <<'PY'
299
+ import importlib.util
300
+ import os
301
+ import shlex
302
+ import subprocess
303
+ import sys
304
+ from pathlib import Path
305
+
306
+ gate_path = Path(sys.argv[1])
307
+ repo_path = Path(sys.argv[2])
308
+ decoy_path = Path(sys.argv[3])
309
+ fixture_root = Path(sys.argv[4])
310
+ base = sys.argv[5]
311
+ candidate_locator = sys.argv[6]
312
+ config_path = fixture_root / "ambient-git-config.cfg"
313
+ fsmonitor_path = fixture_root / "ambient-fsmonitor.sh"
314
+ fsmonitor_marker = fixture_root / "ambient-fsmonitor-ran"
315
+
316
+ fsmonitor_path.write_text(
317
+ "#!/bin/sh\n: >" + shlex.quote(str(fsmonitor_marker)) + "\nexit 0\n",
318
+ encoding="utf-8",
319
+ )
320
+ fsmonitor_path.chmod(0o700)
321
+
322
+ clean_setup_env = os.environ.copy()
323
+ for key in tuple(clean_setup_env):
324
+ if key in {
325
+ "GIT_CONFIG",
326
+ "GIT_CONFIG_COUNT",
327
+ "GIT_CONFIG_GLOBAL",
328
+ "GIT_CONFIG_SYSTEM",
329
+ "GIT_CONFIG_NOSYSTEM",
330
+ "GIT_CONFIG_PARAMETERS",
331
+ } or key.startswith(("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")):
332
+ clean_setup_env.pop(key, None)
333
+ subprocess.run(
334
+ ["git", "config", "--file", str(config_path), "core.worktree", str(decoy_path)],
335
+ check=True,
336
+ env=clean_setup_env,
337
+ )
338
+ subprocess.run(
339
+ ["git", "config", "--file", str(config_path), "core.fsmonitor", str(fsmonitor_path)],
340
+ check=True,
341
+ env=clean_setup_env,
342
+ )
343
+
344
+ counted_config = {
345
+ "GIT_CONFIG_COUNT": "1",
346
+ "GIT_CONFIG_KEY_0": "include.path",
347
+ "GIT_CONFIG_VALUE_0": str(config_path),
348
+ }
349
+ ambient_env = clean_setup_env | counted_config
350
+ candidate = subprocess.run(
351
+ [
352
+ sys.executable,
353
+ str(gate_path),
354
+ "--repo",
355
+ str(repo_path),
356
+ "--base-ref",
357
+ base,
358
+ "--branch-name",
359
+ "feature/sanitized-capability",
360
+ ],
361
+ stdout=subprocess.PIPE,
362
+ stderr=subprocess.PIPE,
363
+ check=False,
364
+ env=ambient_env,
365
+ text=True,
366
+ )
367
+ assert candidate.returncode == 1, candidate.stderr
368
+ assert f"locator={candidate_locator}" in candidate.stderr
369
+ assert str(decoy_path) not in candidate.stderr
370
+ assert not fsmonitor_marker.exists()
371
+
372
+ spec = importlib.util.spec_from_file_location(
373
+ "shared_git_surface_gate_config_probe", gate_path
374
+ )
375
+ module = importlib.util.module_from_spec(spec)
376
+ sys.modules[spec.name] = module
377
+ spec.loader.exec_module(module)
378
+
379
+ original_environment = os.environ.copy()
380
+ try:
381
+ os.environ.clear()
382
+ os.environ.update(ambient_env)
383
+ try:
384
+ configured_worktree = module.git(
385
+ decoy_path,
386
+ "config",
387
+ "--get",
388
+ "core.worktree",
389
+ operation="ambient core.worktree probe",
390
+ ).decode("utf-8").strip()
391
+ except module.GateError as exc:
392
+ assert str(exc) == "ambient core.worktree probe failed"
393
+ configured_worktree = None
394
+ module.git(
395
+ decoy_path,
396
+ "status",
397
+ "--porcelain=v1",
398
+ operation="ambient fsmonitor probe",
399
+ )
400
+ finally:
401
+ os.environ.clear()
402
+ os.environ.update(original_environment)
403
+
404
+ absorbed = []
405
+ if configured_worktree is not None:
406
+ absorbed.append("include.path/core.worktree")
407
+ if fsmonitor_marker.exists():
408
+ absorbed.append("core.fsmonitor command")
409
+ assert not absorbed, "ambient config reached Git subprocess: " + ", ".join(absorbed)
410
+
411
+ blocked_config_keys = {
412
+ "GIT_CONFIG",
413
+ "GIT_CONFIG_COUNT",
414
+ "GIT_CONFIG_GLOBAL",
415
+ "GIT_CONFIG_SYSTEM",
416
+ "GIT_CONFIG_NOSYSTEM",
417
+ "GIT_CONFIG_PARAMETERS",
418
+ }
419
+ blocked_config_prefixes = ("GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_")
420
+ synthetic_environment = {
421
+ key: f"synthetic-{index}"
422
+ for index, key in enumerate(module.GIT_REPOSITORY_ROUTING_ENV)
423
+ }
424
+ synthetic_environment.update(
425
+ {
426
+ key: str(config_path)
427
+ for key in blocked_config_keys
428
+ }
429
+ )
430
+ synthetic_environment.update(
431
+ {
432
+ "GIT_CONFIG_KEY_0": "include.path",
433
+ "GIT_CONFIG_VALUE_0": str(config_path),
434
+ "GIT_CONFIG_KEY_91": "core.worktree",
435
+ "GIT_CONFIG_VALUE_91": str(decoy_path),
436
+ "GIT_TERMINAL_PROMPT": "0",
437
+ "CCL_SKILL_BASE_REF": base,
438
+ "SYNTH_GIT_ENV_CONTROL": "preserved",
439
+ }
440
+ )
441
+ try:
442
+ os.environ.update(synthetic_environment)
443
+ sanitized = module.git_environment()
444
+ finally:
445
+ os.environ.clear()
446
+ os.environ.update(original_environment)
447
+
448
+ for key in module.GIT_REPOSITORY_ROUTING_ENV:
449
+ assert key not in sanitized, key
450
+ for key in blocked_config_keys:
451
+ assert key not in sanitized, key
452
+ assert not any(key.startswith(blocked_config_prefixes) for key in sanitized)
453
+ for key in (
454
+ "GIT_TERMINAL_PROMPT",
455
+ "CCL_SKILL_BASE_REF",
456
+ "SYNTH_GIT_ENV_CONTROL",
457
+ ):
458
+ assert sanitized[key] == synthetic_environment[key], key
459
+ PY
460
+
461
+ printf '%s\n' 'change summary' 'Codex'"-Session-ID: synthetic_fixture_654321" >"$TMP/session-id-message.txt"
462
+ candidate_case case/session-id "$TMP/session-id-message.txt"
463
+ run_gate
464
+ assert_rc "$rc" 1 "new session identifier must block"
465
+ assert_contains "category=ai_session_id" "$out" "session-id category"
466
+ assert_not_contains "synthetic_fixture_654321" "$out" "session-id diagnostic must redact the identifier"
467
+
468
+ printf '%s\n' 'change summary' 'Kimi-Session-ID: synthetic_fixture_777777' >"$TMP/kimi-session-id-message.txt"
469
+ candidate_case case/kimi-session-id "$TMP/kimi-session-id-message.txt"
470
+ run_gate
471
+ assert_rc "$rc" 1 "Kimi session identifier must block"
472
+ assert_contains "category=ai_session_id" "$out" "expanded session-id provider category"
473
+
474
+ codex_task_url="https://chatgpt"'.'"com/codex/tasks/"'task_e_synthetic123456'
475
+ printf 'change summary\n\nTracked in %s\n' "$codex_task_url" >"$TMP/codex-task-url-message.txt"
476
+ candidate_case case/codex-task-url "$TMP/codex-task-url-message.txt"
477
+ run_gate
478
+ assert_rc "$rc" 1 "a Codex task URL on a session origin must block"
479
+ assert_contains "category=ai_session_url" "$out" "Codex task URL category"
480
+
481
+ codex_task_near_miss="https://chatgpt"'.'"com/help/codex-tasks-overview"
482
+ printf 'change summary\n\nDocs at %s\n' "$codex_task_near_miss" >"$TMP/codex-task-near-miss-message.txt"
483
+ candidate_case case/codex-task-near-miss "$TMP/codex-task-near-miss-message.txt"
484
+ run_gate
485
+ assert_rc "$rc" 0 "a product page near a Codex task path must remain valid"
486
+
487
+ printf 'change summary\n\nPrivate review: %s\n' "$kimi_url" >"$TMP/kimi-url-message.txt"
488
+ candidate_case case/kimi-url "$TMP/kimi-url-message.txt"
489
+ run_gate
490
+ assert_rc "$rc" 1 "Kimi session URL must block"
491
+ assert_contains "category=ai_session_url" "$out" "expanded session URL provider category"
492
+ assert_not_contains "synthetic_fixture_abcdef" "$out" "expanded-provider URL must be redacted"
493
+
494
+ printf 'change summary\n\nCo-Authored-By: Claude <noreply@example.invalid>\n' >"$TMP/coauthor-message.txt"
495
+ candidate_case case/coauthor "$TMP/coauthor-message.txt"
496
+ run_gate
497
+ assert_rc "$rc" 1 "AI co-author trailer must block"
498
+ assert_contains "category=ai_coauthor_trailer" "$out" "co-author category"
499
+
500
+ printf 'change summary\n\nCo-Authored-By: Human Reviewer <reviewer@example.invalid>\n' >"$TMP/human-coauthor-message.txt"
501
+ candidate_case case/human-coauthor "$TMP/human-coauthor-message.txt"
502
+ run_gate
503
+ assert_rc "$rc" 0 "a human co-author trailer must remain valid"
504
+
505
+ printf 'change summary\n\nCo-Authored-By: Ai Weiwei <reviewer@example.invalid>\n' >"$TMP/ai-human-coauthor-message.txt"
506
+ candidate_case case/ai-human-coauthor "$TMP/ai-human-coauthor-message.txt"
507
+ run_gate
508
+ assert_rc "$rc" 0 "a human whose name starts with Ai must not be treated as an AI provider"
509
+
510
+ printf 'change summary\n\nCo-Authored-By: Kimi <k@example.invalid>\n' >"$TMP/provider-name-human-message.txt"
511
+ candidate_case case/provider-name-human "$TMP/provider-name-human-message.txt"
512
+ run_gate
513
+ assert_rc "$rc" 0 "a human whose exact name is also a provider alias must remain valid"
514
+
515
+ printf 'change summary\n\nCo-Authored-By: Qwen Li <qwen.li@example.invalid>\n' >"$TMP/provider-prefix-human-message.txt"
516
+ candidate_case case/provider-prefix-human "$TMP/provider-prefix-human-message.txt"
517
+ run_gate
518
+ assert_rc "$rc" 0 "a human surname after a provider-shaped given name must remain valid"
519
+
520
+ printf 'change summary\n\nCo-Authored-By: Gemini Proctor <gemini.proctor@example.invalid>\n' >"$TMP/ambiguous-provider-human-message.txt"
521
+ candidate_case case/ambiguous-provider-human "$TMP/ambiguous-provider-human-message.txt"
522
+ run_gate
523
+ assert_rc "$rc" 0 "an ambiguous provider prefix in a human name must remain valid"
524
+
525
+ printf 'change summary\n\nCo-Authored-By: Claude Code <helper@example.invalid>\n' >"$TMP/unambiguous-provider-coauthor-message.txt"
526
+ candidate_case case/unambiguous-provider-coauthor "$TMP/unambiguous-provider-coauthor-message.txt"
527
+ run_gate
528
+ assert_rc "$rc" 1 "an unambiguous AI display name must block with an ordinary email"
529
+ assert_contains "category=ai_coauthor_trailer" "$out" "unambiguous AI display-name co-author category"
530
+
531
+ printf 'change summary\n\nCo-Authored-By: **Claude Code** <helper@example.invalid>\n' >"$TMP/emphasized-identity-coauthor-message.txt"
532
+ candidate_case case/emphasized-identity-coauthor "$TMP/emphasized-identity-coauthor-message.txt"
533
+ run_gate
534
+ assert_rc "$rc" 1 "emphasis around only the identity must still block"
535
+ assert_contains "category=ai_coauthor_trailer" "$out" \
536
+ "emphasized-identity co-author category"
537
+
538
+ for qualified_identity in \
539
+ "Claude Sonnet" "Claude 3.5 Sonnet" "Claude v3.5 Sonnet" \
540
+ "Claude Fable 5" "Claude Mythos 5" "Claude 5 Fable" \
541
+ "OpenAI Codex" "ChatGPT-5" "Qwen2.5-Coder" "Gemini 2.5 Pro" \
542
+ "GPT-5.6" "OpenAI GPT-5.6" "Gemini 2.5 Flash"; do
543
+ printf 'change summary\n\nCo-Authored-By: %s <helper@example.invalid>\n' \
544
+ "$qualified_identity" >"$TMP/model-qualified-coauthor-message.txt"
545
+ candidate_case "case/model-qualified-coauthor-${qualified_identity//[^A-Za-z0-9]/-}" \
546
+ "$TMP/model-qualified-coauthor-message.txt"
547
+ run_gate
548
+ assert_rc "$rc" 1 "model-qualified AI co-author $qualified_identity must block"
549
+ assert_contains "category=ai_coauthor_trailer" "$out" \
550
+ "model-qualified AI co-author category"
551
+ done
552
+
553
+ printf 'change summary\n\nCo-Authored-By: Claude\tCode <helper@example.invalid>\n' >"$TMP/tab-qualified-coauthor-message.txt"
554
+ candidate_case case/tab-qualified-coauthor "$TMP/tab-qualified-coauthor-message.txt"
555
+ run_gate
556
+ assert_rc "$rc" 1 "a tab-qualified AI co-author identity must block"
557
+ assert_contains "category=ai_coauthor_trailer" "$out" \
558
+ "tab-qualified AI co-author category"
559
+
560
+ printf 'change summary\n\nCo-Authored-By: Codex <helper@example.invalid>\n' >"$TMP/codex-ordinary-email-message.txt"
561
+ candidate_case case/codex-ordinary-email "$TMP/codex-ordinary-email-message.txt"
562
+ run_gate
563
+ assert_rc "$rc" 1 "a non-human provider name must block with an ordinary email"
564
+ assert_contains "category=ai_coauthor_trailer" "$out" "ordinary-email provider co-author category"
565
+
566
+ printf 'change summary\n\nCo-Authored-By: ChatGPT <noreply@example.invalid>\n' >"$TMP/chatgpt-coauthor-message.txt"
567
+ candidate_case case/chatgpt-coauthor "$TMP/chatgpt-coauthor-message.txt"
568
+ run_gate
569
+ assert_rc "$rc" 1 "another AI-family co-author trailer must block"
570
+ assert_contains "category=ai_coauthor_trailer" "$out" "AI-family co-author category"
571
+
572
+ printf 'change summary\n\nCo-Authored-By: OpenCode <noreply@example.invalid>\n' >"$TMP/opencode-coauthor-message.txt"
573
+ candidate_case case/opencode-coauthor "$TMP/opencode-coauthor-message.txt"
574
+ run_gate
575
+ assert_rc "$rc" 1 "OpenCode co-author trailer must block"
576
+ assert_contains "category=ai_coauthor_trailer" "$out" "expanded co-author provider category"
577
+
578
+ printf 'change summary\n\nCo-Authored-By: claude[bot] <123+claude[bot]@users.noreply.github.com>\n' >"$TMP/bracketed-bot-coauthor-message.txt"
579
+ candidate_case case/bracketed-bot-coauthor "$TMP/bracketed-bot-coauthor-message.txt"
580
+ run_gate
581
+ assert_rc "$rc" 1 "a bracketed GitHub App co-author identity must block"
582
+ assert_contains "category=ai_coauthor_trailer" "$out" "bracketed bot co-author category"
583
+
584
+ printf 'change summary\n\nCo-Authored-By: claude-code[bot] <123+claude-code[bot]@users.noreply.github.com>\n' >"$TMP/hyphenated-bot-coauthor-message.txt"
585
+ candidate_case case/hyphenated-bot-coauthor "$TMP/hyphenated-bot-coauthor-message.txt"
586
+ run_gate
587
+ assert_rc "$rc" 1 "a hyphenated provider GitHub App co-author identity must block"
588
+ assert_contains "category=ai_coauthor_trailer" "$out" "hyphenated bot co-author category"
589
+
590
+ candidate_identity_case case/ai-author "Claude Code" helper@example.invalid \
591
+ "Synthetic Tester" tester@example.invalid
592
+ run_gate
593
+ assert_rc "$rc" 1 "an unambiguous AI author identity must block"
594
+ assert_contains "surface=commit_author" "$out" "AI author surface"
595
+ assert_contains "category=ai_commit_identity" "$out" "AI author category"
596
+
597
+ candidate_identity_case case/ai-committer "Synthetic Tester" tester@example.invalid \
598
+ Codex helper@example.invalid
599
+ run_gate
600
+ assert_rc "$rc" 1 "an unambiguous AI committer identity must block"
601
+ assert_contains "surface=commit_committer" "$out" "AI committer surface"
602
+ assert_contains "category=ai_commit_identity" "$out" "AI committer category"
603
+
604
+ candidate_identity_case case/model-qualified-ai-author "OpenAI Codex" \
605
+ helper@example.invalid "Synthetic Tester" tester@example.invalid
606
+ run_gate
607
+ assert_rc "$rc" 1 "a model-qualified AI author identity must block"
608
+ assert_contains "surface=commit_author" "$out" "model-qualified AI author surface"
609
+ assert_contains "category=ai_commit_identity" "$out" "model-qualified AI author category"
610
+
611
+ candidate_identity_case case/model-qualified-ai-committer "Synthetic Tester" \
612
+ tester@example.invalid "Claude Sonnet" helper@example.invalid
613
+ run_gate
614
+ assert_rc "$rc" 1 "a model-qualified AI committer identity must block"
615
+ assert_contains "surface=commit_committer" "$out" "model-qualified AI committer surface"
616
+ assert_contains "category=ai_commit_identity" "$out" "model-qualified AI committer category"
617
+
618
+ candidate_identity_case case/fable-ai-author "Claude Fable 5" \
619
+ helper@example.invalid "Synthetic Tester" tester@example.invalid
620
+ run_gate
621
+ assert_rc "$rc" 1 "a Fable-model AI author identity must block"
622
+ assert_contains "surface=commit_author" "$out" "Fable AI author surface"
623
+ assert_contains "category=ai_commit_identity" "$out" "Fable AI author category"
624
+
625
+ candidate_identity_case case/mythos-ai-committer "Synthetic Tester" \
626
+ tester@example.invalid "Claude Mythos 5" helper@example.invalid
627
+ run_gate
628
+ assert_rc "$rc" 1 "a Mythos-model AI committer identity must block"
629
+ assert_contains "surface=commit_committer" "$out" "Mythos AI committer surface"
630
+ assert_contains "category=ai_commit_identity" "$out" "Mythos AI committer category"
631
+
632
+ # Annotated tag messages are a pushed surface resolve_head peels past; the
633
+ # gate must scan every tag-object layer of the candidate ref.
634
+ printf 'neutral tag base change\n' >"$TMP/tag-base-message.txt"
635
+ candidate_case case/tag-surface "$TMP/tag-base-message.txt"
636
+ git -C "$REPO" tag -a synthetic-annotated-bad -F - HEAD <<EOF_TAG
637
+ release notes
638
+
639
+ Co-Authored-By: Claude Code <helper@example.invalid>
640
+ EOF_TAG
641
+ run_gate --head-ref refs/tags/synthetic-annotated-bad
642
+ assert_rc "$rc" 1 "an annotated tag message with an AI trailer must block"
643
+ assert_contains "surface=tag_message" "$out" "tag-message surface"
644
+ assert_contains "category=ai_coauthor_trailer" "$out" "tag-message co-author category"
645
+
646
+ git -C "$REPO" tag -a synthetic-annotated-clean -m "neutral release notes" HEAD
647
+ run_gate --head-ref refs/tags/synthetic-annotated-clean
648
+ assert_rc "$rc" 0 "a clean annotated tag must pass"
649
+ assert_contains "tag_objects=1" "$out" "annotated tag surface count"
650
+
651
+ git -C "$REPO" tag synthetic-lightweight HEAD
652
+ run_gate --head-ref refs/tags/synthetic-lightweight
653
+ assert_rc "$rc" 0 "a lightweight tag must pass without tag objects"
654
+ assert_contains "tag_objects=0" "$out" "lightweight tag surface count"
655
+
656
+ git -C "$REPO" -c advice.nestedTag=false tag -a synthetic-nested -F - refs/tags/synthetic-annotated-clean <<EOF_TAG
657
+ wrapper notes
658
+
659
+ Claude-Session: $session_url
660
+ EOF_TAG
661
+ run_gate --head-ref refs/tags/synthetic-nested
662
+ assert_rc "$rc" 1 "a nested annotated tag layer must be scanned"
663
+ assert_contains "surface=tag_message" "$out" "nested tag-message surface"
664
+
665
+ GIT_COMMITTER_NAME="Claude Fable 5" GIT_COMMITTER_EMAIL=helper@example.invalid \
666
+ git -C "$REPO" tag -a synthetic-ai-tagger -m "neutral notes" HEAD
667
+ run_gate --head-ref refs/tags/synthetic-ai-tagger
668
+ assert_rc "$rc" 1 "an AI tagger identity must block"
669
+ assert_contains "surface=tag_tagger" "$out" "tag tagger surface"
670
+ assert_contains "category=ai_commit_identity" "$out" "tag tagger category"
671
+
672
+ # A literal tag object can duplicate the object header to point a lenient
673
+ # parser at a clean decoy while Git peels the first target; fail closed.
674
+ dup_target="$(git -C "$REPO" rev-parse HEAD)"
675
+ {
676
+ printf 'object %s\n' "$dup_target"
677
+ printf 'type commit\n'
678
+ printf 'tag synthetic-dup-header\n'
679
+ printf 'object %s\n' "$dup_target"
680
+ printf 'tagger Synthetic Tester <tester@example.invalid> 1700000000 +0000\n'
681
+ printf '\nneutral message\n'
682
+ } >"$TMP/dup-tag-payload.txt"
683
+ dup_tag_oid="$(git -C "$REPO" hash-object -t tag -w --literally "$TMP/dup-tag-payload.txt")"
684
+ git -C "$REPO" update-ref refs/tags/synthetic-dup-header "$dup_tag_oid"
685
+ run_gate --head-ref refs/tags/synthetic-dup-header
686
+ assert_rc "$rc" 2 "a duplicated tag object header must fail closed"
687
+ assert_contains "duplicate headers" "$out" "duplicate tag header reason"
688
+
689
+ candidate_identity_case case/version-infix-ai-author "Claude 3.5 Sonnet" \
690
+ helper@example.invalid "Synthetic Tester" tester@example.invalid
691
+ run_gate
692
+ assert_rc "$rc" 1 "a version-infix AI author identity must block"
693
+ assert_contains "surface=commit_author" "$out" "version-infix AI author surface"
694
+ assert_contains "category=ai_commit_identity" "$out" "version-infix AI author category"
695
+
696
+ candidate_identity_case case/version-infix-human-control "Claude 3.5 Monet" \
697
+ painter@example.invalid "Synthetic Tester" tester@example.invalid
698
+ run_gate
699
+ assert_rc "$rc" 0 "a version-infix human name must remain valid"
700
+
701
+ candidate_identity_case case/non-claude-human-controls "Codex Ramirez" \
702
+ codex.ramirez@example.invalid "OpenAI Smith" openai.smith@example.invalid
703
+ run_gate
704
+ assert_rc "$rc" 0 "non-Claude provider-shaped human names must remain valid"
705
+
706
+ candidate_identity_case case/versioned-provider-human-controls "Qwen Li" \
707
+ qwen.li@example.invalid "Gemini Proctor" gemini.proctor@example.invalid
708
+ run_gate
709
+ assert_rc "$rc" 0 "versioned-provider grammar must preserve nearby human names"
710
+
711
+ for qualified_identity in "Qwen2.5-Coder" "Gemini 2.5 Pro"; do
712
+ identity_slug="${qualified_identity//[^A-Za-z0-9]/-}"
713
+ candidate_identity_case "case/model-qualified-author-$identity_slug" \
714
+ "$qualified_identity" helper@example.invalid \
715
+ "Synthetic Tester" tester@example.invalid
716
+ run_gate
717
+ assert_rc "$rc" 1 "model-qualified AI author $qualified_identity must block"
718
+ assert_contains "surface=commit_author" "$out" \
719
+ "model-qualified AI author $qualified_identity surface"
720
+
721
+ candidate_identity_case "case/model-qualified-committer-$identity_slug" \
722
+ "Synthetic Tester" tester@example.invalid \
723
+ "$qualified_identity" helper@example.invalid
724
+ run_gate
725
+ assert_rc "$rc" 1 "model-qualified AI committer $qualified_identity must block"
726
+ assert_contains "surface=commit_committer" "$out" \
727
+ "model-qualified AI committer $qualified_identity surface"
728
+ done
729
+
730
+ candidate_identity_case case/tab-qualified-ai-author $'Claude\tCode' \
731
+ helper@example.invalid "Synthetic Tester" tester@example.invalid
732
+ run_gate
733
+ assert_rc "$rc" 1 "a tab-qualified AI author identity must block"
734
+ assert_contains "surface=commit_author" "$out" "tab-qualified AI author surface"
735
+ assert_contains "category=ai_commit_identity" "$out" "tab-qualified AI author category"
736
+
737
+ candidate_identity_case case/bracketed-bot-author 'claude[bot]' \
738
+ '123+claude[bot]@users.noreply.github.com' "Synthetic Tester" tester@example.invalid
739
+ run_gate
740
+ assert_rc "$rc" 1 "a bracketed GitHub App author identity must block"
741
+ assert_contains "surface=commit_author" "$out" "bracketed bot author surface"
742
+ assert_contains "category=ai_commit_identity" "$out" "bracketed bot author category"
743
+
744
+ candidate_identity_case case/bracketed-bot-committer "Synthetic Tester" tester@example.invalid \
745
+ 'copilot[bot]' '123+copilot[bot]@users.noreply.github.com'
746
+ run_gate
747
+ assert_rc "$rc" 1 "a bracketed GitHub App committer identity must block"
748
+ assert_contains "surface=commit_committer" "$out" "bracketed bot committer surface"
749
+ assert_contains "category=ai_commit_identity" "$out" "bracketed bot committer category"
750
+
751
+ candidate_identity_case case/hyphenated-bot-author 'github-copilot[bot]' \
752
+ '123+github-copilot[bot]@users.noreply.github.com' \
753
+ "Synthetic Tester" tester@example.invalid
754
+ run_gate
755
+ assert_rc "$rc" 1 "a separator-normalized provider GitHub App author must block"
756
+ assert_contains "surface=commit_author" "$out" "hyphenated bot author surface"
757
+ assert_contains "category=ai_commit_identity" "$out" "hyphenated bot author category"
758
+
759
+ candidate_identity_case case/provider-bot-email "Synthetic App" \
760
+ '123+copilot-swe-agent[bot]@users.noreply.github.com' \
761
+ "Synthetic Tester" tester@example.invalid
762
+ run_gate
763
+ assert_rc "$rc" 1 "a known provider GitHub App email must block with a neutral display name"
764
+ assert_contains "surface=commit_author" "$out" "provider bot email author surface"
765
+ assert_contains "category=ai_commit_identity" "$out" "provider bot email author category"
766
+
767
+ candidate_identity_case case/unrelated-bot-email "Synthetic App" \
768
+ '123+release-helper[bot]@users.noreply.github.com' \
769
+ "Synthetic Tester" tester@example.invalid
770
+ run_gate
771
+ assert_rc "$rc" 0 "an unrelated automation bot identity must remain outside the AI gate"
772
+
773
+ for bot_near_miss in release-codex not-claude human-copilot pre-openai; do
774
+ candidate_identity_case "case/bot-near-miss-$bot_near_miss" "Synthetic App" \
775
+ "123+$bot_near_miss[bot]@users.noreply.github.com" \
776
+ "Synthetic Tester" tester@example.invalid
777
+ run_gate
778
+ assert_rc "$rc" 0 "a provider-token suffix inside another bot account must remain allowed"
779
+ done
780
+
781
+ candidate_identity_case case/provider-name-human Kimi k@example.invalid \
782
+ "Synthetic Tester" tester@example.invalid
783
+ run_gate
784
+ assert_rc "$rc" 0 "a human author whose name is also a provider alias must remain valid"
785
+
786
+ for human_case in claude:Claude kimi:Kimi poe:Poe; do
787
+ human_slug="${human_case%%:*}"
788
+ human_name="${human_case#*:}"
789
+ candidate_identity_case "case/noreply-human-$human_slug" "$human_name" \
790
+ "12345+human.noreply@example.invalid" "Synthetic Tester" tester@example.invalid
791
+ run_gate
792
+ assert_rc "$rc" 0 "a human $human_name author using a privacy-style address must remain valid"
793
+ done
794
+
795
+ printf 'change summary\n\n🤖 Claude Code\n' >"$TMP/footer-message.txt"
796
+ candidate_case case/generated-footer "$TMP/footer-message.txt"
797
+ run_gate
798
+ assert_rc "$rc" 1 "a bot footer must block without depending on its prose"
799
+ assert_contains "category=generated_by_footer" "$out" "footer category"
800
+
801
+ printf 'change summary\n\nGenerated with Gemini\n' >"$TMP/plain-footer-message.txt"
802
+ candidate_case case/plain-generated-footer "$TMP/plain-footer-message.txt"
803
+ run_gate
804
+ assert_rc "$rc" 1 "a generated footer without an emoji must block"
805
+ assert_contains "category=generated_by_footer" "$out" "plain footer category"
806
+
807
+ printf 'change summary\n\n🤖 Generated with [Claude Code](%s)\n' "$claude_product_url" >"$TMP/markdown-footer-message.txt"
808
+ candidate_case case/markdown-generated-footer "$TMP/markdown-footer-message.txt"
809
+ run_gate
810
+ assert_rc "$rc" 1 "a markdown-linked host footer must block"
811
+ assert_contains "category=generated_by_footer" "$out" "markdown-linked footer category"
812
+
813
+ printf '%s\n' 'change summary' '会话'"过程:synthetic internal narration" >"$TMP/process-message.txt"
814
+ candidate_case case/conversation-process "$TMP/process-message.txt"
815
+ run_gate
816
+ assert_rc "$rc" 1 "conversation-process narration must block"
817
+ assert_contains "category=conversation_process" "$out" "conversation-process category"
818
+
819
+ # A repository may request a legacy log output encoding even when each commit
820
+ # correctly declares its own message encoding. The gate must override that
821
+ # ambient presentation choice so CJK-only rules cannot disappear during decode.
822
+ python3 - "$TMP/gb18030-process-message.txt" <<'PY'
823
+ import sys
824
+ from pathlib import Path
825
+
826
+ Path(sys.argv[1]).write_bytes(
827
+ "change summary\n\n会话过程:synthetic legacy narration\n".encode("gb18030")
828
+ )
829
+ PY
830
+ git -C "$REPO" switch -q -C case/gb18030-conversation-process "$clean_head"
831
+ printf '%s\n' 'case/gb18030-conversation-process' >>"$REPO/file.txt"
832
+ git -C "$REPO" add file.txt
833
+ git -C "$REPO" -c i18n.commitEncoding=GB18030 commit -q \
834
+ -F "$TMP/gb18030-process-message.txt"
835
+ git -C "$REPO" config i18n.logOutputEncoding GB18030
836
+ run_gate
837
+ git -C "$REPO" config --unset i18n.logOutputEncoding
838
+ assert_rc "$rc" 1 "ambient legacy log encoding must not hide a CJK violation"
839
+ assert_contains "category=conversation_process" "$out" "legacy-encoding category"
840
+ assert_not_contains "synthetic legacy narration" "$out" "legacy diagnostic stays redacted"
841
+
842
+ # Git's pretty formatter silently truncates a malformed raw commit message at
843
+ # an embedded NUL. Validate the raw object before trusting formatted metadata,
844
+ # otherwise a neutral prefix can hide a prohibited tail.
845
+ nul_tree="$(git -C "$REPO" rev-parse "$clean_head^{tree}")"
846
+ python3 - "$TMP/nul-commit-object" "$nul_tree" "$clean_head" <<'PY'
847
+ import sys
848
+ from pathlib import Path
849
+
850
+ tree, parent = sys.argv[2:4]
851
+ headers = (
852
+ f"tree {tree}\n"
853
+ f"parent {parent}\n"
854
+ "author Synthetic Tester <tester@example.invalid> 1700000000 +0000\n"
855
+ "committer Synthetic Tester <tester@example.invalid> 1700000000 +0000\n"
856
+ "\n"
857
+ ).encode("ascii")
858
+ Path(sys.argv[1]).write_bytes(
859
+ headers
860
+ + b"neutral summary\x00Claude-Session: SYNTH_NUL_MESSAGE_SENTINEL\n"
861
+ )
862
+ PY
863
+ nul_commit="$(git -C "$REPO" hash-object --literally -t commit -w \
864
+ "$TMP/nul-commit-object")"
865
+ git -C "$REPO" switch -q -C case/nul-commit-message "$clean_head"
866
+ git -C "$REPO" update-ref refs/heads/case/nul-commit-message "$nul_commit"
867
+ run_gate
868
+ assert_rc "$rc" 2 "an embedded NUL in a raw commit object must fail closed"
869
+ assert_contains "candidate commit object contains NUL" "$out" "raw commit NUL reason"
870
+ assert_not_contains "SYNTH_NUL_MESSAGE_SENTINEL" "$out" "raw NUL diagnostic stays redacted"
871
+
872
+ # Replacement refs are a local view only: a push still sends the original OID
873
+ # and object. Every Git read in the gate must therefore ignore a clean local
874
+ # replacement rather than certifying bytes that will never be published.
875
+ clean_replacement="$(printf 'neutral replacement message\n' | \
876
+ git -C "$REPO" commit-tree "$nul_tree" -p "$clean_head")"
877
+ git -C "$REPO" replace "$nul_commit" "$clean_replacement"
878
+ run_gate
879
+ git -C "$REPO" replace -d "$nul_commit" >/dev/null
880
+ assert_rc "$rc" 2 "a clean replacement ref must not hide the pushed raw object"
881
+ assert_contains "candidate commit object contains NUL" "$out" "replacement ref NUL reason"
882
+ assert_not_contains "SYNTH_NUL_MESSAGE_SENTINEL" "$out" "replacement diagnostic stays redacted"
883
+
884
+ # Legacy grafts rewrite ancestry independently of replacement objects. A clean
885
+ # grafted head can hide a prohibited intermediate commit even though a push
886
+ # publishes the original parent chain.
887
+ graft_bad="$(printf '%s\n' 'Claude-Session-ID: SYNTH_GRAFT_SENTINEL' | \
888
+ git -C "$REPO" commit-tree "$nul_tree" -p "$clean_head")"
889
+ graft_head="$(printf 'neutral graft head\n' | \
890
+ git -C "$REPO" commit-tree "$nul_tree" -p "$graft_bad")"
891
+ git -C "$REPO" switch -q -C case/graft-history "$clean_head"
892
+ git -C "$REPO" update-ref refs/heads/case/graft-history "$graft_head"
893
+ graft_file="$(git -C "$REPO" rev-parse --git-path info/grafts)"
894
+ case "$graft_file" in
895
+ /*) ;;
896
+ *) graft_file="$REPO/$graft_file" ;;
897
+ esac
898
+ printf '%s %s\n' "$graft_head" "$clean_head" >"$graft_file"
899
+ run_gate
900
+ mv "$graft_file" "$graft_file.disabled"
901
+ assert_rc "$rc" 2 "a non-empty graft file must not hide pushed ancestry"
902
+ assert_contains "candidate history uses unsupported grafts" "$out" "graft reason"
903
+ assert_not_contains "SYNTH_GRAFT_SENTINEL" "$out" "graft diagnostic stays redacted"
904
+
905
+ printf 'tighten-doc: 外部写作实践吸收\n' >"$TMP/provenance-message.txt"
906
+ candidate_case case/provenance "$TMP/provenance-message.txt"
907
+ run_gate
908
+ assert_rc "$rc" 1 "provenance wording must block"
909
+ assert_contains "category=provenance_wording" "$out" "provenance category"
910
+
911
+ # Benign use of the verbs is not provenance unless it names an outside source.
912
+ printf 'review loop: 吸收用户反馈经验并收敛规则\n' >"$TMP/benign-message.txt"
913
+ candidate_case case/benign-wording "$TMP/benign-message.txt"
914
+ run_gate
915
+ assert_rc "$rc" 0 "ordinary feedback wording must not be treated as provenance"
916
+ assert_contains "commits=2" "$out" "the full multi-commit candidate range is scanned"
917
+
918
+ printf 'document ordinary product URL https://example.com/session/share-not-ai\n' >"$TMP/product-url-message.txt"
919
+ candidate_case case/product-url "$TMP/product-url-message.txt"
920
+ run_gate
921
+ assert_rc "$rc" 0 "an ordinary product URL must not be treated as an AI session"
922
+
923
+ # Branch names are shared even before a commit exists.
924
+ git -C "$REPO" switch -q -C feature/claude-session_synthetic123 "$clean_head"
925
+ run_gate
926
+ assert_rc "$rc" 1 "session-shaped branch name must block"
927
+ assert_contains "surface=branch" "$out" "branch locator"
928
+ assert_not_contains "synthetic123" "$out" "branch diagnostic must not echo the identifier"
929
+
930
+ assert_branch_block() {
931
+ local branch_name="$1" category="$2" reason="$3"
932
+ set +e
933
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
934
+ python3 "$GATE" --repo "$REPO" --base-ref "$clean_head" \
935
+ --head-ref "$clean_head" --branch-name "$branch_name" 2>&1)"
936
+ rc=$?
937
+ set -e
938
+ assert_rc "$rc" 1 "$reason"
939
+ assert_contains "surface=branch" "$out" "$reason surface"
940
+ assert_contains "category=$category" "$out" "$reason category"
941
+ }
942
+
943
+ assert_branch_clean() {
944
+ local branch_name="$1" reason="$2"
945
+ set +e
946
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
947
+ python3 "$GATE" --repo "$REPO" --base-ref "$clean_head" \
948
+ --head-ref "$clean_head" --branch-name "$branch_name" 2>&1)"
949
+ rc=$?
950
+ set -e
951
+ assert_rc "$rc" 0 "$reason"
952
+ }
953
+
954
+ # Branch refs cannot contain spaces, so prose regexes must not be reused as-is.
955
+ # Exercise each branch-only metadata class with every ordinary slug separator.
956
+ for branch_separator in '-' '_' '/' '.'; do
957
+ assert_branch_block \
958
+ "feature${branch_separator}inspired${branch_separator}by${branch_separator}claude" \
959
+ provenance_wording "branch provenance wording with '$branch_separator' separators"
960
+ assert_branch_block \
961
+ "feature${branch_separator}adapted${branch_separator}from${branch_separator}gemini" \
962
+ provenance_wording "branch adapted-from wording with '$branch_separator' separators"
963
+ assert_branch_block \
964
+ "feature${branch_separator}generated${branch_separator}by${branch_separator}claude" \
965
+ generated_by_footer "branch generated-by wording with '$branch_separator' separators"
966
+ assert_branch_block \
967
+ "feature${branch_separator}co${branch_separator}authored${branch_separator}by${branch_separator}claude" \
968
+ ai_coauthor_trailer "branch co-author wording with '$branch_separator' separators"
969
+ assert_branch_block \
970
+ "feature${branch_separator}claude${branch_separator}analysis${branch_separator}secret" \
971
+ conversation_process "branch conversation label with '$branch_separator' separators"
972
+ done
973
+
974
+ # A forbidden phrase is a branch segment sequence, not necessarily the final
975
+ # text in the ref. Suffixes must not turn shared metadata into an allowed ref.
976
+ assert_branch_block \
977
+ feature/inspired-by-claude-fix provenance_wording \
978
+ "branch provenance phrase before a suffix"
979
+ assert_branch_block \
980
+ feature/generated-by-claude-cleanup generated_by_footer \
981
+ "branch generated-by phrase before a suffix"
982
+ assert_branch_block \
983
+ feature/co-authored-by-claude-change ai_coauthor_trailer \
984
+ "branch co-author phrase before a suffix"
985
+ for conversation_branch in \
986
+ feature/user-prompt-secret \
987
+ feature/conversation-process-export \
988
+ feature/conversation-transcript-export \
989
+ feature/claude-analysis-findings \
990
+ feature/用户原话-secret \
991
+ feature/会话过程-export \
992
+ feature/模型分析-internal \
993
+ feature/claude-分析-findings; do
994
+ assert_branch_block \
995
+ "$conversation_branch" conversation_process \
996
+ "branch conversation-process phrase before a suffix"
997
+ done
998
+
999
+ for benign_branch in \
1000
+ feature/inspired-by-human-review \
1001
+ feature/generated-schema \
1002
+ feature/co-authored-policy \
1003
+ feature/claude-sdk-compatibility \
1004
+ feature/analysis-dashboard \
1005
+ feature/analysis-engine \
1006
+ feature/user-profile-export \
1007
+ feature/conversation-list-export \
1008
+ feature/用户反馈-export \
1009
+ feature/模型能力-internal \
1010
+ feature/claude-分析器-findings; do
1011
+ assert_branch_clean "$benign_branch" "ordinary branch capability wording remains accepted"
1012
+ done
1013
+
1014
+ # PR title/body arrive through the GitHub event payload in CI.
1015
+ git -C "$REPO" switch -q -C feature/sanitized-pr "$clean_head"
1016
+ python3 - "$TMP/pr-event.json" "$session_url" <<'PY'
1017
+ import json
1018
+ import sys
1019
+ from pathlib import Path
1020
+
1021
+ Path(sys.argv[1]).write_text(json.dumps({
1022
+ "pull_request": {
1023
+ "title": "Bound a shared capability",
1024
+ "body": "Details and private session: " + sys.argv[2],
1025
+ "head": {"ref": "feature/sanitized-pr"},
1026
+ "base": {"sha": ""},
1027
+ }
1028
+ }), encoding="utf-8")
1029
+ PY
1030
+ run_gate --event-json "$TMP/pr-event.json"
1031
+ assert_rc "$rc" 1 "PR body session URL must block"
1032
+ assert_contains "surface=pull_request_body" "$out" "PR body locator"
1033
+ assert_not_contains "synthetic_fixture_123456" "$out" "PR diagnostic must redact the session id"
1034
+
1035
+ python3 - "$TMP/pr-event.json" "$BASE" <<'PY'
1036
+ import json
1037
+ import sys
1038
+ from pathlib import Path
1039
+
1040
+ Path(sys.argv[1]).write_text(json.dumps({
1041
+ "pull_request": {
1042
+ "title": "借鉴外部实践的规则",
1043
+ "body": "Neutral behavior and verification summary.",
1044
+ "head": {"ref": "feature/sanitized-pr"},
1045
+ "base": {"sha": sys.argv[2]},
1046
+ }
1047
+ }), encoding="utf-8")
1048
+ PY
1049
+ run_gate --event-json "$TMP/pr-event.json"
1050
+ assert_rc "$rc" 1 "PR title provenance wording must block"
1051
+ assert_contains "surface=pull_request_title" "$out" "PR title locator"
1052
+
1053
+ python3 - "$TMP/pr-event.json" "$BASE" <<'PY'
1054
+ import json
1055
+ import sys
1056
+ from pathlib import Path
1057
+
1058
+ Path(sys.argv[1]).write_text(json.dumps({
1059
+ "pull_request": {
1060
+ "title": "Bound a shared capability",
1061
+ "body": "Neutral behavior and verification summary.",
1062
+ "head": {"ref": "feature/sanitized-pr"},
1063
+ "base": {"sha": sys.argv[2]},
1064
+ }
1065
+ }), encoding="utf-8")
1066
+ PY
1067
+ run_gate --event-json "$TMP/pr-event.json"
1068
+ assert_rc "$rc" 0 "sanitized PR metadata should pass"
1069
+
1070
+ # A trusted PR event defines the candidate range in CI. An ambient local
1071
+ # override must not move the base forward to HEAD and hide a violating commit.
1072
+ candidate_case case/event-base-precedence "$TMP/session-message.txt"
1073
+ event_head="$(git -C "$REPO" rev-parse HEAD)"
1074
+ python3 - "$TMP/pr-event.json" "$BASE" <<'PY'
1075
+ import json
1076
+ import sys
1077
+ from pathlib import Path
1078
+
1079
+ Path(sys.argv[1]).write_text(json.dumps({
1080
+ "pull_request": {
1081
+ "title": "Bound a shared capability",
1082
+ "body": "Neutral behavior and verification summary.",
1083
+ "head": {"ref": "case/event-base-precedence"},
1084
+ "base": {"sha": sys.argv[2]},
1085
+ }
1086
+ }), encoding="utf-8")
1087
+ PY
1088
+ set +e
1089
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
1090
+ CCL_SKILL_BASE_REF="$event_head" python3 "$GATE" --repo "$REPO" \
1091
+ --event-json "$TMP/pr-event.json" 2>&1)"
1092
+ rc=$?
1093
+ set -e
1094
+ assert_rc "$rc" 1 "trusted PR event base must outrank an ambient environment base"
1095
+ assert_contains "category=ai_session_trailer" "$out" "event-base precedence preserves the candidate range"
1096
+ git -C "$REPO" switch -q -C feature/sanitized-pr "$clean_head"
1097
+
1098
+ printf 'Neutral proposed PR summary.\n' >"$TMP/proposed-pr.txt"
1099
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1100
+ assert_rc "$rc" 0 "sanitized proposed PR text should pass"
1101
+ printf 'Private session: %s\n' "$session_url" >"$TMP/proposed-pr.txt"
1102
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1103
+ assert_rc "$rc" 1 "prohibited proposed PR text must block before publication"
1104
+ assert_contains "surface=proposed_pr_text" "$out" "proposed PR locator"
1105
+ assert_not_contains "synthetic_fixture_123456" "$out" "proposed PR diagnostic must redact the session id"
1106
+
1107
+ # Every provider-shaped surface uses the same provider registry. These cases
1108
+ # kill partial fixes where a name is added to session IDs but not co-authors,
1109
+ # narration/provenance, generated attribution, or share URLs.
1110
+ assert_proposed_block() {
1111
+ local text="$1" category="$2" reason="$3"
1112
+ printf '%s\n' "$text" >"$TMP/proposed-pr.txt"
1113
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1114
+ assert_rc "$rc" 1 "$reason"
1115
+ assert_contains "category=$category" "$out" "$reason category"
1116
+ }
1117
+ assert_proposed_block "Built with Claude Code" generated_by_footer "generated attribution verb"
1118
+ assert_proposed_block "- Generated with Claude Code" generated_by_footer "list-wrapped generated attribution"
1119
+ assert_proposed_block "> 🤖 Generated with Claude Code" generated_by_footer "quote-wrapped generated attribution"
1120
+ assert_proposed_block "1. Generated with Claude Code" generated_by_footer "ordered-list generated attribution"
1121
+ assert_proposed_block "### Generated with Claude Code" generated_by_footer "heading-wrapped generated attribution"
1122
+
1123
+ # Markdown wrappers are presentation only. Exercise every line-oriented
1124
+ # metadata class against the same wrapper matrix so a one-pattern fix cannot
1125
+ # leave a sibling category bypassable.
1126
+ for markdown_prefix in '- ' '> ' '1. ' '### ' '> - ' '- [ ] ' '> 1. [x] '; do
1127
+ assert_proposed_block \
1128
+ "${markdown_prefix}Generated with Claude Code" \
1129
+ generated_by_footer "Markdown-wrapped generated attribution"
1130
+ assert_proposed_block \
1131
+ "${markdown_prefix}Claude-Session: synthetic_markdown_123456" \
1132
+ ai_session_trailer "Markdown-wrapped session trailer"
1133
+ assert_proposed_block \
1134
+ "${markdown_prefix}Co-Authored-By: Claude Code <bot@example.invalid>" \
1135
+ ai_coauthor_trailer "Markdown-wrapped AI co-author trailer"
1136
+ assert_proposed_block \
1137
+ "${markdown_prefix}会话过程:synthetic internal narration" \
1138
+ conversation_process "Markdown-wrapped conversation-process label"
1139
+ done
1140
+
1141
+ # Inline emphasis/code spans are presentation wrappers too. Keep the opener and
1142
+ # closer paired so an ordinary leading marker cannot be consumed accidentally.
1143
+ for inline_pair in '*|*' '_|_' '**|**' '__|__' '~~|~~' '`|`'; do
1144
+ inline_open="${inline_pair%%|*}"
1145
+ inline_close="${inline_pair#*|}"
1146
+ assert_proposed_block \
1147
+ "${inline_open}Generated with Claude Code${inline_close}" \
1148
+ generated_by_footer "inline-Markdown generated attribution"
1149
+ assert_proposed_block \
1150
+ "${inline_open}Claude-Session: synthetic_inline_123456${inline_close}" \
1151
+ ai_session_trailer "inline-Markdown session trailer"
1152
+ assert_proposed_block \
1153
+ "${inline_open}Co-Authored-By: Claude Code <bot@example.invalid>${inline_close}" \
1154
+ ai_coauthor_trailer "inline-Markdown AI co-author trailer"
1155
+ assert_proposed_block \
1156
+ "${inline_open}会话过程:synthetic inline narration${inline_close}" \
1157
+ conversation_process "inline-Markdown conversation-process label"
1158
+
1159
+ printf '%s\n' "${inline_open}Human-authored compatibility note${inline_close}" >"$TMP/proposed-pr.txt"
1160
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1161
+ assert_rc "$rc" 0 "paired inline Markdown around neutral prose remains accepted"
1162
+ done
1163
+
1164
+ # Wrapper depth is input-controlled. Five levels killed the former fixed-depth
1165
+ # loop; 64 levels keeps the regression honest without making the fixture large.
1166
+ for nested_depth in 5 64; do
1167
+ nested_marker=''
1168
+ for ((nested_index = 0; nested_index < nested_depth; nested_index += 1)); do
1169
+ nested_marker="${nested_marker}**"
1170
+ done
1171
+ assert_proposed_block \
1172
+ "${nested_marker}Generated with Claude Code${nested_marker}" \
1173
+ generated_by_footer "deep inline wrapper generated attribution"
1174
+ assert_proposed_block \
1175
+ "${nested_marker}Claude-Session: synthetic_nested_123456${nested_marker}" \
1176
+ ai_session_trailer "deep inline wrapper session trailer"
1177
+ assert_proposed_block \
1178
+ "${nested_marker}Co-Authored-By: Claude Code <bot@example.invalid>${nested_marker}" \
1179
+ ai_coauthor_trailer "deep inline wrapper co-author trailer"
1180
+ assert_proposed_block \
1181
+ "${nested_marker}会话过程:synthetic nested narration${nested_marker}" \
1182
+ conversation_process "deep inline wrapper conversation label"
1183
+ done
1184
+
1185
+ printf '**Generated with Claude Code__\n' >"$TMP/proposed-pr.txt"
1186
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1187
+ assert_rc "$rc" 0 "mismatched inline wrappers are not normalized as a pair"
1188
+
1189
+ assert_proposed_block \
1190
+ '[Generated with Claude Code](https://example.invalid)' \
1191
+ generated_by_footer "outer Markdown-link generated attribution"
1192
+ assert_proposed_block \
1193
+ '[Co-Authored-By: Claude Code <bot@example.invalid>](https://example.invalid)' \
1194
+ ai_coauthor_trailer "outer Markdown-link co-author trailer"
1195
+ assert_proposed_block \
1196
+ '[会话过程:摘要](https://example.invalid)' \
1197
+ conversation_process "outer Markdown-link conversation label"
1198
+
1199
+ printf '[Claude Code integration guide](https://example.invalid)\n' >"$TMP/proposed-pr.txt"
1200
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1201
+ assert_rc "$rc" 0 "an ordinary complete Markdown link remains accepted"
1202
+ printf '[Ordinary review](%s)\n' "$session_url" >"$TMP/proposed-pr.txt"
1203
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1204
+ assert_rc "$rc" 1 "a normalized outer link must retain destination scanning"
1205
+ assert_contains "category=ai_session_url" "$out" "outer-link destination URL category"
1206
+ assert_not_contains "synthetic_fixture_123456" "$out" "outer-link destination remains redacted"
1207
+
1208
+ assert_proposed_block \
1209
+ '> **Generated with Claude Code**' \
1210
+ generated_by_footer "block and inline Markdown wrappers compose"
1211
+
1212
+ for near_miss_prefix in '[x] ' '- [xx] '; do
1213
+ printf '%s\n' "${near_miss_prefix}Generated with Claude Code" >"$TMP/proposed-pr.txt"
1214
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1215
+ assert_rc "$rc" 0 "a non-task-list checkbox shape must not be consumed as Markdown framing"
1216
+ done
1217
+
1218
+ assert_proposed_block "Co-Authored-By: GitHub Copilot <bot@example.invalid>" ai_coauthor_trailer "provider alias co-author"
1219
+ assert_proposed_block "Gemini 分析:synthetic internal narration" conversation_process "provider analysis label"
1220
+ assert_proposed_block "Adapted from Gemini" provenance_wording "provider provenance wording"
1221
+ assert_proposed_block "Cursor-Session-ID: synthetic_provider_123456" ai_session_id "provider session ID"
1222
+ assert_not_contains "synthetic_provider_123456" "$out" "expanded provider ID remains redacted"
1223
+ assert_proposed_block "AI-Session-ID: synthetic_generic_123456" ai_session_id "generic AI session ID"
1224
+ assert_not_contains "synthetic_generic_123456" "$out" "generic AI ID remains redacted"
1225
+ assert_proposed_block "Generated by AI" generated_by_footer "generic AI attribution"
1226
+ assert_proposed_block "Generated by AI model" generated_by_footer "generic AI model attribution"
1227
+ for model_credit in \
1228
+ 'Generated by AI Model' \
1229
+ 'Generated by Gemini Pro' \
1230
+ 'Generated by Claude Sonnet'; do
1231
+ assert_proposed_block \
1232
+ "$model_credit" generated_by_footer \
1233
+ "common model/product continuation remains generated attribution"
1234
+ done
1235
+ assert_proposed_block "Private review: $gemini_url" ai_session_url "provider share URL"
1236
+ assert_not_contains "synthetic_fixture_ghijkl" "$out" "provider URL remains redacted"
1237
+ assert_proposed_block "Private review: $chatgpt_conversation_url" ai_session_url "root conversation URL"
1238
+ assert_not_contains "$conversation_uuid" "$out" "root conversation URL remains redacted"
1239
+ assert_proposed_block "Private review: $claude_chat_url" ai_session_url "root chat URL"
1240
+ assert_not_contains "$conversation_uuid" "$out" "root chat URL remains redacted"
1241
+ assert_proposed_block "Private review: $chatgpt_https_default_port_url" ai_session_url "HTTPS default-port session URL"
1242
+ assert_not_contains "$conversation_uuid" "$out" "HTTPS default-port URL remains redacted"
1243
+ assert_proposed_block "Private review: $chatgpt_http_default_port_url" ai_session_url "HTTP default-port session URL"
1244
+ assert_not_contains "$conversation_uuid" "$out" "HTTP default-port URL remains redacted"
1245
+
1246
+ printf 'Private review: %s\n' "$chatgpt_wrong_port_url" >"$TMP/proposed-pr.txt"
1247
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1248
+ assert_rc "$rc" 0 "a cross-scheme non-default port is not treated as the canonical origin"
1249
+
1250
+ for human_credit in 'Written by Ai Weiwei' 'Created by Ai Weiwei for the exhibition' 'Written by Claude Monet'; do
1251
+ printf '%s\n' "$human_credit" >"$TMP/proposed-pr.txt"
1252
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1253
+ assert_rc "$rc" 0 "a human-name credit must not be treated as generated attribution"
1254
+ done
1255
+
1256
+ assert_proposed_block "Inspired by Claude" provenance_wording "standard English provenance wording"
1257
+ printf 'Inspired by Claude Monet\n' >"$TMP/proposed-pr.txt"
1258
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1259
+ assert_rc "$rc" 0 "a human-name inspiration credit remains accepted"
1260
+
1261
+ printf 'Summary\r\n\r\nCo-Authored-By: Claude Code <helper@example.invalid>\r\n🤖 Claude Code\r\n' >"$TMP/proposed-pr.txt"
1262
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1263
+ assert_rc "$rc" 1 "CRLF proposed PR text must not bypass line-anchored metadata checks"
1264
+ assert_contains "category=ai_coauthor_trailer" "$out" "CRLF co-author trailer category"
1265
+ assert_contains "category=generated_by_footer" "$out" "CRLF generated footer category"
1266
+ printf 'Neutral CRLF proposed PR summary.\r\n' >"$TMP/proposed-pr.txt"
1267
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1268
+ assert_rc "$rc" 0 "neutral CRLF proposed PR text remains accepted"
1269
+
1270
+ printf '🤖 OpenCode compatibility matrix\n' >"$TMP/proposed-pr.txt"
1271
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1272
+ assert_rc "$rc" 0 "a provider compatibility heading is not a generated footer"
1273
+ printf 'Ordinary product link: %s\n' "$google_product_url" >"$TMP/proposed-pr.txt"
1274
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1275
+ assert_rc "$rc" 0 "a non-Gemini g.co share path is not an AI session URL"
1276
+ printf 'Nested help link: %s\n' "$chatgpt_nested_help_url" >"$TMP/proposed-pr.txt"
1277
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1278
+ assert_rc "$rc" 0 "a nested help/chat path is not a root conversation URL"
1279
+ printf 'Product guide: %s\n' "$claude_nonconversation_url" >"$TMP/proposed-pr.txt"
1280
+ run_gate --event-json "$TMP/pr-event.json" --pr-text-file "$TMP/proposed-pr.txt"
1281
+ assert_rc "$rc" 0 "a non-UUID root chat path is not a conversation URL"
1282
+
1283
+ # A detached Actions checkout still obtains both the branch and base from the
1284
+ # event, rather than silently skipping either surface.
1285
+ git -C "$REPO" switch -q --detach "$clean_head"
1286
+ run_gate_without_base --event-json "$TMP/pr-event.json"
1287
+ assert_rc "$rc" 0 "detached checkout with complete PR event should pass"
1288
+ assert_contains "branch=1" "$out" "event head ref supplied the branch surface"
1289
+ git -C "$REPO" switch -q -C feature/sanitized-pr "$clean_head"
1290
+
1291
+ printf '{ malformed' >"$TMP/pr-event.json"
1292
+ run_gate --event-json "$TMP/pr-event.json"
1293
+ assert_rc "$rc" 2 "malformed CI event must fail closed"
1294
+ assert_contains "shared_git_surface_gate_error" "$out" "malformed-event error marker"
1295
+
1296
+ printf '\377' >"$TMP/pr-event.json"
1297
+ run_gate --event-json "$TMP/pr-event.json"
1298
+ assert_rc "$rc" 2 "non-UTF-8 CI event must fail closed"
1299
+ assert_contains "event JSON is not UTF-8" "$out" "non-UTF-8 event reason"
1300
+
1301
+ # Error diagnostics are themselves shared log surfaces. Ref/path/error details
1302
+ # must never repeat the sentinel that made the command fail.
1303
+ orphan_tree="$(git -C "$REPO" mktree </dev/null)"
1304
+ orphan_commit="$(printf 'orphan probe\n' | git -C "$REPO" commit-tree "$orphan_tree")"
1305
+ error_ref='refs/heads/feature/claude-session_SYNTH_ERROR_SENTINEL'
1306
+ git -C "$REPO" update-ref "$error_ref" "$orphan_commit"
1307
+ set +e
1308
+ out="$(env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME -u CCL_SKILL_BASE_REF \
1309
+ python3 "$GATE" --repo "$REPO" --base-ref "$error_ref" 2>&1)"
1310
+ rc=$?
1311
+ set -e
1312
+ assert_rc "$rc" 2 "a base without a merge-base must fail closed"
1313
+ assert_contains "shared_git_surface_gate_error" "$out" "merge-base error marker"
1314
+ assert_not_contains "SYNTH_ERROR_SENTINEL" "$out" "git errors must not echo a private ref"
1315
+
1316
+ missing_event="$TMP/claude-session_SYNTH_PATH_SENTINEL.json"
1317
+ run_gate --event-json "$missing_event"
1318
+ assert_rc "$rc" 2 "an unreadable event path must fail closed"
1319
+ assert_contains "event JSON unreadable" "$out" "unreadable event reason"
1320
+ assert_not_contains "SYNTH_PATH_SENTINEL" "$out" "file errors must not echo a private path"
1321
+
1322
+ # Pin the secure-open mechanism, not only its happy-path result: opening first
1323
+ # with no-follow/nonblocking flags and then fstat/read on that descriptor closes
1324
+ # the lstat→open swap and FIFO-blocking windows.
1325
+ python3 - "$GATE" "$TMP" "$REPO" <<'PY'
1326
+ import importlib.util
1327
+ import os
1328
+ import signal
1329
+ import sys
1330
+ from pathlib import Path
1331
+
1332
+ gate_path = Path(sys.argv[1])
1333
+ probe_path = Path(sys.argv[2]) / "reader-mechanism-probe.json"
1334
+ repo_path = Path(sys.argv[3])
1335
+ probe_path.write_text('{"probe": true}', encoding="utf-8")
1336
+ spec = importlib.util.spec_from_file_location("shared_git_surface_gate_probe", gate_path)
1337
+ module = importlib.util.module_from_spec(spec)
1338
+ sys.modules[spec.name] = module
1339
+ spec.loader.exec_module(module)
1340
+
1341
+ # A candidate controls commit/PR text. Repeating a recognized origin without a
1342
+ # session path must remain bounded by the public PR-text limit rather than make
1343
+ # the regex rescan the full suffix once per origin.
1344
+ class RegexDeadline(Exception):
1345
+ pass
1346
+
1347
+ def regex_deadline(_signum, _frame):
1348
+ raise RegexDeadline
1349
+
1350
+ origin_near_miss = "https://claude" + "." + "ai/aaaaaaaa"
1351
+ adversarial_text = (origin_near_miss * 4_000)[:module.MAX_PR_TEXT_BYTES]
1352
+ previous_alarm = signal.signal(signal.SIGALRM, regex_deadline)
1353
+ signal.setitimer(signal.ITIMER_REAL, 1.0)
1354
+ try:
1355
+ assert module.violations("proposed_pr_text", "file", adversarial_text) == []
1356
+ except RegexDeadline as exc:
1357
+ raise AssertionError("session URL near-miss scan exceeded its bounded deadline") from exc
1358
+ finally:
1359
+ signal.setitimer(signal.ITIMER_REAL, 0)
1360
+ signal.signal(signal.SIGALRM, previous_alarm)
1361
+
1362
+ # Branch slug separators are also input-controlled. Boundary recognition must
1363
+ # not greedily rescan the remaining suffix at every separator position.
1364
+ branch_near_miss = "-" * 8_000 + "neutral"
1365
+ previous_alarm = signal.signal(signal.SIGALRM, regex_deadline)
1366
+ signal.setitimer(signal.ITIMER_REAL, 1.0)
1367
+ try:
1368
+ assert module.violations("branch", "current", branch_near_miss) == []
1369
+ except RegexDeadline as exc:
1370
+ raise AssertionError("branch separator near-miss scan exceeded its bounded deadline") from exc
1371
+ finally:
1372
+ signal.setitimer(signal.ITIMER_REAL, 0)
1373
+ signal.signal(signal.SIGALRM, previous_alarm)
1374
+
1375
+ real_git = module.git
1376
+ seen_commit_args = []
1377
+
1378
+ def batch_frame(oid, payload=b"synthetic raw commit without a NUL\n"):
1379
+ return (
1380
+ oid
1381
+ + b" commit "
1382
+ + str(len(payload)).encode("ascii")
1383
+ + b"\n"
1384
+ + payload
1385
+ + b"\n"
1386
+ )
1387
+
1388
+ sha1_oid = b"e" * 40
1389
+ sha256_oid = b"f" * 64
1390
+ module.validate_commit_batch(batch_frame(sha1_oid), [sha1_oid.decode("ascii")])
1391
+ module.validate_commit_batch(batch_frame(sha256_oid), [sha256_oid.decode("ascii")])
1392
+ malformed_batches = (
1393
+ b"",
1394
+ b"wrong commit 0\n\n",
1395
+ sha1_oid + b" blob 0\n\n",
1396
+ sha1_oid + b" commit 01\nx\n",
1397
+ sha1_oid + b" commit 4\nabc\n",
1398
+ sha1_oid + b" commit 3\nabc",
1399
+ batch_frame(sha1_oid) + b"trailing",
1400
+ )
1401
+ for malformed_batch in malformed_batches:
1402
+ try:
1403
+ module.validate_commit_batch(malformed_batch, [sha1_oid.decode("ascii")])
1404
+ except module.GateError as exc:
1405
+ assert str(exc) == "candidate commit object scan returned malformed records"
1406
+ else:
1407
+ raise AssertionError("malformed batch boundary did not fail closed")
1408
+ try:
1409
+ module.validate_commit_batch(batch_frame(sha1_oid, b"left\x00right"), [sha1_oid.decode("ascii")])
1410
+ except module.GateError as exc:
1411
+ assert str(exc) == "candidate commit object contains NUL"
1412
+ else:
1413
+ raise AssertionError("a NUL inside a length-framed payload did not fail closed")
1414
+
1415
+ def clean_commit_git(repo, *args, **kwargs):
1416
+ seen_commit_args.extend(args)
1417
+ if args[0] == "rev-list":
1418
+ return b"a" * 40 + b"\n"
1419
+ if args[:2] == ("cat-file", "--batch"):
1420
+ assert kwargs["input_bytes"] == b"a" * 40 + b"\n"
1421
+ return batch_frame(b"a" * 40)
1422
+ return b"a" * 40 + b"\0neutral message\0Human <h@example.invalid>\0Human <h@example.invalid>\0"
1423
+
1424
+ module.git = clean_commit_git
1425
+ assert len(module.commit_messages(repo_path, "base", "head")) == 1
1426
+ assert "--encoding=UTF-8" in seen_commit_args
1427
+
1428
+ def malformed_batch_git(repo, *args, **kwargs):
1429
+ if args[0] == "rev-list":
1430
+ return b"d" * 40 + b"\n"
1431
+ if args[:2] == ("cat-file", "--batch"):
1432
+ return b"not a length-framed batch response\n"
1433
+ return b"d" * 40 + b"\0neutral\0Human <h@example.invalid>\0Human <h@example.invalid>\0"
1434
+
1435
+ module.git = malformed_batch_git
1436
+ try:
1437
+ module.commit_messages(repo_path, "base", "head")
1438
+ except module.GateError as exc:
1439
+ assert str(exc) == "candidate commit object scan returned malformed records"
1440
+ else:
1441
+ raise AssertionError("malformed cat-file batch framing did not fail closed")
1442
+
1443
+ prefix = b"c" * 12
1444
+ first_oid = prefix + b"0" * 28
1445
+ second_oid = prefix + b"1" * 28
1446
+
1447
+ def colliding_prefix_git(repo, *args, **kwargs):
1448
+ if args[0] == "rev-list":
1449
+ return first_oid + b"\n" + second_oid + b"\n"
1450
+ if args[:2] == ("cat-file", "--batch"):
1451
+ return batch_frame(first_oid) + batch_frame(second_oid)
1452
+ one = b"\0neutral one\0Human <h@example.invalid>\0Human <h@example.invalid>\0"
1453
+ two = b"\0neutral two\0Human <h@example.invalid>\0Human <h@example.invalid>\0"
1454
+ return second_oid + two + first_oid + one
1455
+
1456
+ module.git = colliding_prefix_git
1457
+ try:
1458
+ module.commit_messages(repo_path, "base", "head")
1459
+ except module.GateError as exc:
1460
+ assert str(exc) == "candidate commit scan returned inconsistent records"
1461
+ else:
1462
+ raise AssertionError("full object-id ordering was compared only by locator")
1463
+
1464
+ for undecodable in (b"\xff", "\ufffd".encode("utf-8")):
1465
+ def undecodable_commit_git(repo, *args, payload=undecodable, **kwargs):
1466
+ if args[0] == "rev-list":
1467
+ return b"b" * 40 + b"\n"
1468
+ if args[:2] == ("cat-file", "--batch"):
1469
+ return batch_frame(b"b" * 40)
1470
+ return (
1471
+ b"b" * 40
1472
+ + b"\0"
1473
+ + payload
1474
+ + b"\0Human <h@example.invalid>\0Human <h@example.invalid>\0"
1475
+ )
1476
+
1477
+ module.git = undecodable_commit_git
1478
+ try:
1479
+ module.commit_messages(repo_path, "base", "head")
1480
+ except module.GateError as exc:
1481
+ assert str(exc) == (
1482
+ "candidate commit metadata is not valid UTF-8 "
1483
+ "locator=bbbbbbbbbbbb field=message"
1484
+ )
1485
+ else:
1486
+ raise AssertionError("undecodable commit metadata did not fail closed")
1487
+
1488
+ module.git = real_git
1489
+ seen = {"flags": None, "fstat": 0, "max_read": 0}
1490
+ real_open = module.os.open
1491
+ real_fstat = module.os.fstat
1492
+ real_read = module.read_fd
1493
+
1494
+ def tracked_open(path, flags):
1495
+ seen["flags"] = flags
1496
+ return real_open(path, flags)
1497
+
1498
+ def tracked_fstat(fd):
1499
+ seen["fstat"] += 1
1500
+ return real_fstat(fd)
1501
+
1502
+ def tracked_read(fd, size):
1503
+ seen["max_read"] = max(seen["max_read"], size)
1504
+ return real_read(fd, size)
1505
+
1506
+ module.os.open = tracked_open
1507
+ module.os.fstat = tracked_fstat
1508
+ module.read_fd = tracked_read
1509
+ text = module.read_regular(probe_path, max_bytes=32, label="probe")
1510
+ assert text == '{"probe": true}'
1511
+ assert seen["flags"] & os.O_NOFOLLOW
1512
+ assert seen["flags"] & os.O_NONBLOCK
1513
+ assert seen["fstat"] == 1
1514
+ assert 0 < seen["max_read"] <= 33
1515
+
1516
+ module.os.open = real_open
1517
+ module.os.fstat = real_fstat
1518
+ seen["max_read"] = 0
1519
+ try:
1520
+ module.git(
1521
+ repo_path,
1522
+ "log",
1523
+ "-1",
1524
+ "--format=%B",
1525
+ max_bytes=8,
1526
+ operation="bounded git probe",
1527
+ )
1528
+ except module.GateError as exc:
1529
+ assert str(exc) == "bounded git probe output exceeds the safety limit"
1530
+ else:
1531
+ raise AssertionError("oversized Git output did not fail closed")
1532
+ assert 0 < seen["max_read"] <= 9, seen
1533
+ PY
1534
+
1535
+ # Exercise the hook against a synthetic repository because string matching
1536
+ # cannot prove that every ref is actually scanned with the right head/base.
1537
+ # Makefile/CI wiring belongs to test_ci_checkout_ref_binding.sh so this focused
1538
+ # suite remains a self-contained behavior check for the gate and hook.
1539
+ ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd -P)"
1540
+ python3 - "$ROOT/AGENTS.md" <<'PY'
1541
+ import re
1542
+ import sys
1543
+ from pathlib import Path
1544
+
1545
+ source = Path(sys.argv[1]).read_text(encoding="utf-8")
1546
+ rule = re.search(
1547
+ r"^- \*\*共享 Git 面去会话化\*\*:.*?(?=^- \*\*|\Z)",
1548
+ source,
1549
+ re.MULTILINE | re.DOTALL,
1550
+ )
1551
+ if rule is None or "--pr-text-file <proposed-pr.md>" not in rule.group(0):
1552
+ raise SystemExit(
1553
+ "FAIL: the root pre-PR contract must scan the proposed title/body "
1554
+ "with --pr-text-file <proposed-pr.md>"
1555
+ )
1556
+ PY
1557
+ HOOK_REPO="$TMP/hook-repo"
1558
+ mkdir -p "$HOOK_REPO"
1559
+ git init -q -b dev "$HOOK_REPO"
1560
+ git -C "$HOOK_REPO" config user.email tester@example.invalid
1561
+ git -C "$HOOK_REPO" config user.name "Synthetic Tester"
1562
+ printf 'baseline\n' >"$HOOK_REPO/file.txt"
1563
+ git -C "$HOOK_REPO" add file.txt
1564
+ git -C "$HOOK_REPO" commit -qm "baseline"
1565
+ HOOK_BASE="$(git -C "$HOOK_REPO" rev-parse HEAD)"
1566
+ HOOK_TREE="$(git -C "$HOOK_REPO" rev-parse 'HEAD^{tree}')"
1567
+ git -C "$HOOK_REPO" update-ref refs/remotes/origin/dev "$HOOK_BASE"
1568
+ mkdir -p "$HOOK_REPO/.githooks" "$HOOK_REPO/skills/skill-extraction-workflow/scripts"
1569
+ cp "$ROOT/.githooks/pre-push" "$HOOK_REPO/.githooks/pre-push"
1570
+ cp "$GATE" "$HOOK_REPO/skills/skill-extraction-workflow/scripts/shared_git_surface_gate.py"
1571
+ printf '#!/usr/bin/env bash\nexit 0\n' >"$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
1572
+ chmod +x "$HOOK_REPO/.githooks/pre-push" "$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
1573
+
1574
+ run_hook_for_remote() {
1575
+ local remote_name="$1"
1576
+ shift
1577
+ set +e
1578
+ out="$(printf '%s\n' "$@" | (
1579
+ cd "$HOOK_REPO"
1580
+ env -u CCL_SKILL_BASE_REF -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
1581
+ .githooks/pre-push "$remote_name" synthetic
1582
+ ) 2>&1)"
1583
+ rc=$?
1584
+ set -e
1585
+ }
1586
+
1587
+ run_hook() {
1588
+ run_hook_for_remote origin "$@"
1589
+ }
1590
+
1591
+ run_hook_with_base() {
1592
+ local base_override="$1"
1593
+ shift
1594
+ set +e
1595
+ out="$(printf '%s\n' "$@" | (
1596
+ cd "$HOOK_REPO"
1597
+ env -u GITHUB_EVENT_PATH -u GITHUB_HEAD_REF -u GITHUB_REF_NAME \
1598
+ CCL_SKILL_BASE_REF="$base_override" .githooks/pre-push origin synthetic
1599
+ ) 2>&1)"
1600
+ rc=$?
1601
+ set -e
1602
+ }
1603
+
1604
+ zero=0000000000000000000000000000000000000000
1605
+ run_hook "refs/heads/feature/deleted $zero refs/heads/feature/deleted $HOOK_BASE"
1606
+ assert_rc "$rc" 0 "a delete-only push publishes no new metadata and must be skipped"
1607
+ assert_equals "" "$out" "a delete-only push exits before checkout or metadata gates"
1608
+
1609
+ printf 'candidate leak\n\nClaude-Session-ID: SYNTH_REMOTE_TIP_SENTINEL\n' >"$TMP/hook-bad-message.txt"
1610
+ hook_bad="$(git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$HOOK_BASE" -F "$TMP/hook-bad-message.txt")"
1611
+ hook_follow="$(printf 'neutral follow-up\n' | git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$hook_bad")"
1612
+ git -C "$HOOK_REPO" update-ref refs/heads/feature/base-probe "$hook_follow"
1613
+ git -C "$HOOK_REPO" symbolic-ref HEAD refs/heads/feature/base-probe
1614
+ run_hook "refs/heads/feature/base-probe $hook_follow refs/heads/feature/base-probe $hook_bad"
1615
+ assert_rc "$rc" 1 "an existing feature remote tip must not hide an earlier candidate leak"
1616
+ assert_contains "category=ai_session_id" "$out" "feature push uses the landing target base"
1617
+ assert_not_contains "SYNTH_REMOTE_TIP_SENTINEL" "$out" "hook failure remains redacted"
1618
+
1619
+ hook_clean="$(printf 'neutral candidate\n' | git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$HOOK_BASE")"
1620
+ printf 'other ref leak\n\nCursor-Session-ID: SYNTH_MULTI_REF_SENTINEL\n' >"$TMP/hook-other-message.txt"
1621
+ hook_other="$(git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$HOOK_BASE" -F "$TMP/hook-other-message.txt")"
1622
+ git -C "$HOOK_REPO" update-ref refs/heads/feature/clean "$hook_clean"
1623
+ git -C "$HOOK_REPO" update-ref refs/heads/feature/other "$hook_other"
1624
+ git -C "$HOOK_REPO" symbolic-ref HEAD refs/heads/feature/clean
1625
+ before_head="$(git -C "$HOOK_REPO" rev-parse HEAD)"
1626
+ before_status="$(git -C "$HOOK_REPO" status --porcelain=v1)"
1627
+ run_hook \
1628
+ "refs/heads/feature/clean $hook_clean refs/heads/feature/clean $zero" \
1629
+ "refs/heads/feature/other $hook_other refs/heads/feature/other $zero"
1630
+ assert_rc "$rc" 1 "every non-delete ref in a multi-ref push must be scanned"
1631
+ assert_contains "category=ai_session_id" "$out" "non-HEAD pushed ref finding"
1632
+ assert_not_contains "SYNTH_MULTI_REF_SENTINEL" "$out" "multi-ref finding remains redacted"
1633
+ assert_equals "$before_head" "$(git -C "$HOOK_REPO" rev-parse HEAD)" "hook must not move HEAD"
1634
+ assert_equals "$before_status" "$(git -C "$HOOK_REPO" status --porcelain=v1)" "hook must not alter the working tree"
1635
+
1636
+ # The checkout-level structural gate is about HEAD/working-tree state. A clean
1637
+ # non-HEAD ref must still receive metadata scanning without being rejected by
1638
+ # an unrelated ambient checkout failure.
1639
+ hook_non_head="$(printf 'neutral non-head candidate\n' | git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$HOOK_BASE")"
1640
+ git -C "$HOOK_REPO" update-ref refs/heads/feature/non-head "$hook_non_head"
1641
+ printf '#!/usr/bin/env bash\necho SYNTH_AMBIENT_GATE_RAN\nexit 9\n' >"$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
1642
+ chmod +x "$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
1643
+ run_hook "refs/heads/feature/non-head $hook_non_head refs/heads/feature/non-head $zero"
1644
+ assert_rc "$rc" 0 "a non-HEAD push must not inherit ambient checkout failure"
1645
+ assert_not_contains "SYNTH_AMBIENT_GATE_RAN" "$out" "checkout gate must be skipped for a non-HEAD-only push"
1646
+ printf '#!/usr/bin/env bash\nexit 0\n' >"$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
1647
+ chmod +x "$HOOK_REPO/skills/skill-extraction-workflow/scripts/check-ccl-skills.sh"
1648
+
1649
+ run_hook "refs/heads/feature/clean $hook_clean refs/heads/feature/claude-session_SYNTH_DEST_SENTINEL $zero"
1650
+ assert_rc "$rc" 1 "the destination branch name must be scanned"
1651
+ assert_contains "surface=branch" "$out" "destination branch finding"
1652
+ assert_not_contains "SYNTH_DEST_SENTINEL" "$out" "destination finding remains redacted"
1653
+
1654
+ # A violation already present on the actual main target is outside the new
1655
+ # main candidate range. This allowed case prevents replacing every base with
1656
+ # origin/dev and re-policing target history.
1657
+ printf 'main known debt\n\nClaude-Session-ID: SYNTH_MAIN_DEBT\n' >"$TMP/hook-main-message.txt"
1658
+ hook_main="$(git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$HOOK_BASE" -F "$TMP/hook-main-message.txt")"
1659
+ hook_main_head="$(printf 'neutral promotion\n' | git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$hook_main")"
1660
+ git -C "$HOOK_REPO" update-ref refs/heads/dev "$hook_main_head"
1661
+ git -C "$HOOK_REPO" symbolic-ref HEAD refs/heads/dev
1662
+ run_hook "refs/heads/dev $hook_main_head refs/heads/main $hook_main"
1663
+ assert_rc "$rc" 0 "an existing landing target SHA is the main promotion base"
1664
+
1665
+ printf 'landing candidate leak\n\nCodex-Session-ID: SYNTH_LANDING_BASE_SENTINEL\n' >"$TMP/hook-landing-leak-message.txt"
1666
+ hook_landing_leak="$(git -C "$HOOK_REPO" commit-tree "$HOOK_TREE" -p "$hook_main" -F "$TMP/hook-landing-leak-message.txt")"
1667
+ git -C "$HOOK_REPO" update-ref refs/heads/dev "$hook_landing_leak"
1668
+ for landing_ref in refs/heads/dev refs/heads/main; do
1669
+ run_hook_with_base "$hook_landing_leak" \
1670
+ "refs/heads/dev $hook_landing_leak $landing_ref $hook_main"
1671
+ assert_rc "$rc" 1 "an environment base must not narrow an existing $landing_ref candidate"
1672
+ assert_contains "category=ai_session_id" "$out" "existing landing target remote SHA outranks environment base"
1673
+ assert_not_contains "SYNTH_LANDING_BASE_SENTINEL" "$out" "landing-target finding remains redacted"
1674
+ done
1675
+ git -C "$HOOK_REPO" update-ref refs/heads/dev "$hook_main_head"
1676
+
1677
+ run_hook "refs/heads/dev $hook_main_head refs/heads/main $zero"
1678
+ assert_rc "$rc" 1 "a new landing target without an explicit base must fail closed"
1679
+ assert_contains "candidate base is unresolved" "$out" "new landing target failure reason"
1680
+
1681
+ run_hook_with_base "$HOOK_BASE" \
1682
+ "refs/heads/feature/clean $hook_clean refs/heads/main $zero"
1683
+ assert_rc "$rc" 0 "an explicit base may delimit a new landing target"
1684
+
1685
+ git -C "$HOOK_REPO" update-ref -d refs/remotes/origin/dev
1686
+ git -C "$HOOK_REPO" update-ref refs/remotes/upstream/dev "$HOOK_BASE"
1687
+ run_hook_for_remote upstream \
1688
+ "refs/heads/feature/clean $hook_clean refs/heads/feature/clean $zero"
1689
+ assert_rc "$rc" 0 "a feature push derives its dev base from the pushed remote name"
1690
+ git -C "$HOOK_REPO" update-ref -d refs/remotes/upstream/dev
1691
+ run_hook "refs/heads/feature/clean $hook_clean refs/heads/feature/clean $zero"
1692
+ assert_rc "$rc" 1 "a feature push without a resolvable default landing base must fail closed"
1693
+ assert_contains "set CCL_SKILL_BASE_REF" "$out" "missing default-base remediation hint"
1694
+ git -C "$HOOK_REPO" update-ref refs/remotes/origin/dev "$HOOK_BASE"
1695
+
1696
+ echo "test_shared_git_surface_gate: ok"